From 190e1f014e78e595a476176fd9e3d076f5feb392 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Tue, 9 Jul 2024 10:41:54 -0700 Subject: [PATCH 01/40] Add git ignore for vs --- src/vs/.gitignore | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 src/vs/.gitignore diff --git a/src/vs/.gitignore b/src/vs/.gitignore new file mode 100644 index 0000000000..83e8188bf7 --- /dev/null +++ b/src/vs/.gitignore @@ -0,0 +1,13 @@ +temp/ + +# Tests +base/test/ +base/**/test/ + +# Unwanted modules +base/node/ +base/parts/ +base/worker/ + +# Binary files +base/browser/ui/codicons/codicon/codicon.ttf From c30bffc60c616cccaa8cd2997814d169bc0c5b40 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Tue, 9 Jul 2024 10:52:09 -0700 Subject: [PATCH 02/40] Set up vs project --- .eslintrc.json | 1 + bin/esbuild.mjs | 2 +- bin/update_vs_base.ps1 | 45 +++++++++++ package.json | 14 +--- src/browser/tsconfig.json | 6 +- src/common/tsconfig.json | 8 +- src/vs/README.md | 9 +++ src/vs/patches/nls.ts | 90 ++++++++++++++++++++++ src/vs/tsconfig.json | 71 +++++++++++++++++ src/vs/typings/base-common.d.ts | 20 +++++ src/vs/typings/require.d.ts | 42 ++++++++++ src/vs/typings/thenable.d.ts | 12 +++ src/vs/typings/vscode-globals-nls.d.ts | 36 +++++++++ src/vs/typings/vscode-globals-product.d.ts | 33 ++++++++ yarn.lock | 7 +- 15 files changed, 380 insertions(+), 16 deletions(-) create mode 100644 bin/update_vs_base.ps1 create mode 100644 src/vs/README.md create mode 100644 src/vs/patches/nls.ts create mode 100644 src/vs/tsconfig.json create mode 100644 src/vs/typings/base-common.d.ts create mode 100644 src/vs/typings/require.d.ts create mode 100644 src/vs/typings/thenable.d.ts create mode 100644 src/vs/typings/vscode-globals-nls.d.ts create mode 100644 src/vs/typings/vscode-globals-product.d.ts diff --git a/.eslintrc.json b/.eslintrc.json index 9c8493dd09..345188c8eb 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -43,6 +43,7 @@ }, "ignorePatterns": [ "addons/*/src/third-party/*.ts", + "src/vs/*", "out/*", "out-test/*", "out-esbuild/*", diff --git a/bin/esbuild.mjs b/bin/esbuild.mjs index f056e30db4..3e792712de 100644 --- a/bin/esbuild.mjs +++ b/bin/esbuild.mjs @@ -35,7 +35,7 @@ const devOptions = { const prodOptions = { minify: true, treeShaking: true, - logLevel: 'debug', + logLevel: 'verbose', legalComments: 'none', // TODO: Mangling private and protected properties will reduce bundle size quite a bit, we must // make sure we don't cast privates to `any` in order to prevent regressions. diff --git a/bin/update_vs_base.ps1 b/bin/update_vs_base.ps1 new file mode 100644 index 0000000000..234bb92f4c --- /dev/null +++ b/bin/update_vs_base.ps1 @@ -0,0 +1,45 @@ +# Get latest vscode repo +if (Test-Path -Path "src/vs/temp") { + Write-Host "`e[32m> Fetching latest`e[0m" + git -C src/vs/temp checkout + git -C src/vs/temp pull +} else { + Write-Host "`e[32m> Cloning microsoft/vscode`e[0m" + $null = New-Item -ItemType Directory -Path "src/vs/temp" -Force + git clone https://github.com/microsoft/vscode src/vs/temp +} + +# Delete old base +Write-Host "`e[32m> Deleting old base`e[0m" +$null = Remove-Item -Recurse -Force "src/vs/base" + +# Copy base +Write-Host "`e[32m> Copying base`e[0m" +Copy-Item -Path "src/vs/temp/src/vs/base" -Destination "src/vs/base" -Recurse + +# Comment out any CSS imports +Write-Host "`e[32m> Commenting out CSS imports`e[0m" -NoNewline +$baseFiles = Get-ChildItem -Path "src/vs/base" -Recurse -File +$count = 0 +foreach ($file in $baseFiles) { + $content = Get-Content -Path $file.FullName + $updatedContent = $content | ForEach-Object { + if ($_ -match "^import 'vs/css!") { + Write-Host "`e[32m." -NoNewline + $count++ + "// $_" + } else { + $_ + } + } + $updatedContent | Set-Content -Path $file.FullName +} +Write-Host " $count files patched" + +# Copy typings +Write-Host "`e[32m> Copying typings`e[0m" +Copy-Item -Path "src/vs/temp/src/typings" -Destination "src/vs" -Recurse -Force + +# Deleting unwanted typings +Write-Host "`e[32m> Deleting unwanted typings`e[0m" +$null = Remove-Item -Path "src/vs/typings/vscode-globals-modules.d.ts" -Force diff --git a/package.json b/package.json index 941f78c957..e5ae2b2a49 100644 --- a/package.json +++ b/package.json @@ -27,15 +27,12 @@ "setup": "npm run build", "presetup": "npm run install-addons", "install-addons": "node ./bin/install-addons.js", - "start": "node demo/start", "build-demo": "webpack --config ./demo/webpack.config.js", - "build": "npm run tsc", "watch": "npm run tsc-watch", "tsc": "tsc -b ./tsconfig.all.json", "tsc-watch": "tsc -b -w ./tsconfig.all.json --preserveWatchOutput", - "esbuild": "node bin/esbuild_all.mjs", "esbuild-watch": "node bin/esbuild_all.mjs --watch", "esbuild-package": "node bin/esbuild_all.mjs --prod", @@ -43,33 +40,26 @@ "esbuild-package-headless-only": "node bin/esbuild.mjs --prod --headless", "esbuild-demo": "node bin/esbuild.mjs --demo-client", "esbuild-demo-watch": "node bin/esbuild.mjs --demo-client --watch", - "test": "npm run test-unit", "posttest": "npm run lint", - "lint": "eslint -c .eslintrc.json --max-warnings 0 --ext .ts src/ addons/", "lint-api": "eslint --no-eslintrc -c .eslintrc.json.typings --max-warnings 0 --no-ignore --ext .d.ts typings/", - "test-unit": "node ./bin/test_unit.js", "test-unit-coverage": "node ./bin/test_unit.js --coverage", "test-unit-dev": "cross-env NODE_PATH='./out' mocha", - "test-integration": "node ./bin/test_integration.js --workers=75%", "test-integration-chromium": "node ./bin/test_integration.js --workers=75% \"--project=ChromeStable\"", "test-integration-firefox": "node ./bin/test_integration.js --workers=75% \"--project=FirefoxStable\"", "test-integration-webkit": "node ./bin/test_integration.js --workers=75% \"--project=WebKit\"", "test-integration-debug": "node ./bin/test_integration.js --workers=1 --headed --timeout=30000", - "benchmark": "NODE_PATH=./out xterm-benchmark -r 5 -c test/benchmark/benchmark.json", "benchmark-baseline": "NODE_PATH=./out xterm-benchmark -r 5 -c test/benchmark/benchmark.json --baseline out-tsc/test-benchmark/test/benchmark/*benchmark.js", "benchmark-eval": "NODE_PATH=./out xterm-benchmark -r 5 -c test/benchmark/benchmark.json --eval out-tsc/test-benchmark/test/benchmark/*benchmark.js", - "clean": "rm -rf lib out addons/*/lib addons/*/out", "vtfeatures": "node bin/extract_vtfeatures.js src/**/*.ts src/*.ts", - "prepackage": "npm run build", "package": "webpack", - "postpackage":"npm run esbuild-package", + "postpackage": "npm run esbuild-package", "prepackage-headless": "npm run esbuild-package-headless-only", "package-headless": "webpack --config ./webpack.config.headless.js", "postpackage-headless": "node ./bin/package_headless.js", @@ -88,6 +78,7 @@ "@types/jsdom": "^16.2.13", "@types/mocha": "^9.0.0", "@types/node": "^18.16.0", + "@types/trusted-types": "^1.0.6", "@types/utf8": "^3.0.0", "@types/webpack": "^5.28.0", "@types/ws": "^8.2.0", @@ -107,6 +98,7 @@ "mustache": "^4.2.0", "node-pty": "1.1.0-beta5", "nyc": "^15.1.0", + "semver": "^7.6.2", "source-map-loader": "^3.0.0", "source-map-support": "^0.5.20", "ts-loader": "^9.3.1", diff --git a/src/browser/tsconfig.json b/src/browser/tsconfig.json index 673a55a6cb..d8f3a189aa 100644 --- a/src/browser/tsconfig.json +++ b/src/browser/tsconfig.json @@ -11,7 +11,8 @@ ], "baseUrl": "..", "paths": { - "common/*": [ "./common/*" ] + "common/*": [ "./common/*" ], + "vs/*": [ "./vs/*" ] } }, "include": [ @@ -19,6 +20,7 @@ "../../typings/xterm.d.ts" ], "references": [ - { "path": "../common" } + { "path": "../common" }, + { "path": "../vs" } ] } diff --git a/src/common/tsconfig.json b/src/common/tsconfig.json index b8baa6c09d..e9b3673ab2 100644 --- a/src/common/tsconfig.json +++ b/src/common/tsconfig.json @@ -9,10 +9,16 @@ "types": [ "../../node_modules/@types/mocha" ], - "baseUrl": ".." + "baseUrl": "..", + "paths": { + "vs/*": [ "./vs/*" ] + } }, "include": [ "./**/*", "../../typings/xterm.d.ts" + ], + "references": [ + { "path": "../vs" } ] } diff --git a/src/vs/README.md b/src/vs/README.md new file mode 100644 index 0000000000..002749b3c6 --- /dev/null +++ b/src/vs/README.md @@ -0,0 +1,9 @@ +This folder contains the `base/` module from the [Visual Studio Code repository](https://github.com/microsoft/vscode) which has many helpers that are useful to xterm.js. + +To update against upstream: + +``` +./bin/update_vs_base.ps1 +``` + +TODO: Mention review `src/vs/base` in xterm.js diff --git a/src/vs/patches/nls.ts b/src/vs/patches/nls.ts new file mode 100644 index 0000000000..1661dfb04b --- /dev/null +++ b/src/vs/patches/nls.ts @@ -0,0 +1,90 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export interface ILocalizeInfo { + key: string; + comment: string[]; +} + +export function localize(info: ILocalizeInfo | string, message: string, ...args: (string | number | boolean | undefined | null)[]): string { + return message; +} + +export interface INLSLanguagePackConfiguration { + + /** + * The path to the translations config file that contains pointers to + * all message bundles for `main` and extensions. + */ + readonly translationsConfigFile: string; + + /** + * The path to the file containing the translations for this language + * pack as flat string array. + */ + readonly messagesFile: string; + + /** + * The path to the file that can be used to signal a corrupt language + * pack, for example when reading the `messagesFile` fails. This will + * instruct the application to re-create the cache on next startup. + */ + readonly corruptMarkerFile: string; +} + +export interface INLSConfiguration { + + /** + * Locale as defined in `argv.json` or `app.getLocale()`. + */ + readonly userLocale: string; + + /** + * Locale as defined by the OS (e.g. `app.getPreferredSystemLanguages()`). + */ + readonly osLocale: string; + + /** + * The actual language of the UI that ends up being used considering `userLocale` + * and `osLocale`. + */ + readonly resolvedLanguage: string; + + /** + * Defined if a language pack is used that is not the + * default english language pack. This requires a language + * pack to be installed as extension. + */ + readonly languagePack?: INLSLanguagePackConfiguration; + + /** + * The path to the file containing the default english messages + * as flat string array. The file is only present in built + * versions of the application. + */ + readonly defaultMessagesFile: string; + + /** + * Below properties are deprecated and only there to continue support + * for `vscode-nls` module that depends on them. + * Refs https://github.com/microsoft/vscode-nls/blob/main/src/node/main.ts#L36-L46 + */ + /** @deprecated */ + readonly locale: string; + /** @deprecated */ + readonly availableLanguages: Record; + /** @deprecated */ + readonly _languagePackSupport?: boolean; + /** @deprecated */ + readonly _languagePackId?: string; + /** @deprecated */ + readonly _translationsConfigFile?: string; + /** @deprecated */ + readonly _cacheRoot?: string; + /** @deprecated */ + readonly _resolvedLanguagePackCoreLocation?: string; + /** @deprecated */ + readonly _corruptedFile?: string; +} diff --git a/src/vs/tsconfig.json b/src/vs/tsconfig.json new file mode 100644 index 0000000000..134dd90812 --- /dev/null +++ b/src/vs/tsconfig.json @@ -0,0 +1,71 @@ +{ + "extends": "../tsconfig-library-base", + "compilerOptions": { + "experimentalDecorators": true, + "noImplicitReturns": true, + "noImplicitOverride": true, + "noUnusedLocals": true, + "allowUnreachableCode": false, + "strict": true, + "exactOptionalPropertyTypes": false, + "useUnknownInCatchVariables": false, + "forceConsistentCasingInFileNames": true, + "paths": { + "vs/*": [ + "./vs/*" + ], + "vs/nls": [ + "./vs/patches/nls" + ] + }, + "target": "es2022", + "useDefineForClassFields": false, + "lib": [ + "ES2022", + "DOM", + "DOM.Iterable", + "WebWorker.ImportScripts" + ], + "esModuleInterop": true, + "removeComments": false, + "preserveConstEnums": true, + "sourceMap": false, + "allowJs": true, + "resolveJsonModule": true, + "isolatedModules": true, + "types": [ + "semver", + "trusted-types", + ], + "composite": true, + "outDir": "../../out", + "baseUrl": "../", + }, + "include": [ + "./base/**/*", + "./patches/**/*", + "./typings/**/*", + ], + "exclude": [ + // Update repo folder + "temp/", + + // Unwanted layers/modules + "**/electron-main", + "**/electron-sandbox", + "**/node", + "**/test", + "**/sandbox", + "**/worker", + + // Problematic files + "base/common/amd.ts", + "base/browser/defaultWorkerFactory.ts", + "base/common/jsonc.js", + "base/common/performance.js", + "base/common/marked/marked.js", + "base/common/network.js", + + "typings/vscode-globals-modules.d.ts" + ] +} diff --git a/src/vs/typings/base-common.d.ts b/src/vs/typings/base-common.d.ts new file mode 100644 index 0000000000..4fc7b59856 --- /dev/null +++ b/src/vs/typings/base-common.d.ts @@ -0,0 +1,20 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// Declare types that we probe for to implement util and/or polyfill functions + +declare global { + + interface IdleDeadline { + readonly didTimeout: boolean; + timeRemaining(): number; + } + + function requestIdleCallback(callback: (args: IdleDeadline) => void, options?: { timeout: number }): number; + function cancelIdleCallback(handle: number): void; + +} + +export { } diff --git a/src/vs/typings/require.d.ts b/src/vs/typings/require.d.ts new file mode 100644 index 0000000000..7934279012 --- /dev/null +++ b/src/vs/typings/require.d.ts @@ -0,0 +1,42 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +declare class LoaderEvent { + readonly type: number; + readonly timestamp: number; + readonly detail: string; +} + +declare const define: { + (moduleName: string, dependencies: string[], callback: (...args: any[]) => any): any; + (moduleName: string, dependencies: string[], definition: any): any; + (moduleName: string, callback: (...args: any[]) => any): any; + (moduleName: string, definition: any): any; + (dependencies: string[], callback: (...args: any[]) => any): any; + (dependencies: string[], definition: any): any; +}; + +interface NodeRequire { + /** + * @deprecated use `FileAccess.asFileUri()` for node.js contexts or `FileAccess.asBrowserUri` for browser contexts. + */ + toUrl(path: string): string; + + /** + * @deprecated MUST not be used anymore + * + * With the move from AMD to ESM we cannot use this anymore. There will be NO MORE node require like this. + */ + __$__nodeRequire(moduleName: string): T; + + (dependencies: string[], callback: (...args: any[]) => any, errorback?: (err: any) => void): any; + config(data: any): any; + onError: Function; + getStats?(): ReadonlyArray; + hasDependencyCycle?(): boolean; + define(amdModuleId: string, dependencies: string[], callback: (...args: any[]) => any): any; +} + +declare var require: NodeRequire; diff --git a/src/vs/typings/thenable.d.ts b/src/vs/typings/thenable.d.ts new file mode 100644 index 0000000000..73373eadba --- /dev/null +++ b/src/vs/typings/thenable.d.ts @@ -0,0 +1,12 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Thenable is a common denominator between ES6 promises, Q, jquery.Deferred, WinJS.Promise, + * and others. This API makes no assumption about what promise library is being used which + * enables reusing existing code without migrating to a specific promise implementation. Still, + * we recommend the use of native promises which are available in VS Code. + */ +interface Thenable extends PromiseLike { } diff --git a/src/vs/typings/vscode-globals-nls.d.ts b/src/vs/typings/vscode-globals-nls.d.ts new file mode 100644 index 0000000000..bc48076762 --- /dev/null +++ b/src/vs/typings/vscode-globals-nls.d.ts @@ -0,0 +1,36 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// AMD2ESM mirgation relevant + +/** + * NLS Globals: these need to be defined in all contexts that make + * use of our `nls.localize` and `nls.localize2` functions. This includes: + * - Electron main process + * - Electron window (renderer) process + * - Utility Process + * - Node.js + * - Browser + * - Web worker + * + * That is because during build time we strip out all english strings from + * the resulting JS code and replace it with a that is then looked + * up from the `_VSCODE_NLS_MESSAGES` array. + */ +declare global { + /** + * All NLS messages produced by `localize` and `localize2` calls + * under `src/vs` translated to the language as indicated by + * `_VSCODE_NLS_LANGUAGE`. + */ + var _VSCODE_NLS_MESSAGES: string[]; + /** + * The actual language of the NLS messages (e.g. 'en', de' or 'pt-br'). + */ + var _VSCODE_NLS_LANGUAGE: string | undefined; +} + +// fake export to make global work +export { } diff --git a/src/vs/typings/vscode-globals-product.d.ts b/src/vs/typings/vscode-globals-product.d.ts new file mode 100644 index 0000000000..f6aa62bfe9 --- /dev/null +++ b/src/vs/typings/vscode-globals-product.d.ts @@ -0,0 +1,33 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// AMD2ESM mirgation relevant + +declare global { + + /** + * Holds the file root for resources. + */ + var _VSCODE_FILE_ROOT: string; + + /** + * CSS loader that's available during development time. + * DO NOT call directly, instead just import css modules, like `import 'some.css'` + */ + var _VSCODE_CSS_LOAD: (module: string) => void; + + /** + * @deprecated You MUST use `IProductService` whenever possible. + */ + var _VSCODE_PRODUCT_JSON: Record; + /** + * @deprecated You MUST use `IProductService` whenever possible. + */ + var _VSCODE_PACKAGE_JSON: Record; + +} + +// fake export to make global work +export { } diff --git a/yarn.lock b/yarn.lock index 1422c7db3e..e353d8ab3b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -830,6 +830,11 @@ resolved "https://registry.yarnpkg.com/@types/tough-cookie/-/tough-cookie-4.0.2.tgz#6286b4c7228d58ab7866d19716f3696e03a09397" integrity sha512-Q5vtl1W5ue16D+nIaW8JWebSSraJVlK+EthKn7e7UcD4KWsaSJ8BqGPXNaPghgtcn/fhvrN17Tv8ksUsQpiplw== +"@types/trusted-types@^1.0.6": + version "1.0.6" + resolved "https://registry.yarnpkg.com/@types/trusted-types/-/trusted-types-1.0.6.tgz#569b8a08121d3203398290d602d84d73c8dcf5da" + integrity sha512-230RC8sFeHoT6sSUlRO6a8cAnclO06eeiq1QDfiv2FGCLWFvvERWgwIQD4FWqD9A69BN7Lzee4OXwoMVnnsWDw== + "@types/utf8@^3.0.0": version "3.0.1" resolved "https://registry.yarnpkg.com/@types/utf8/-/utf8-3.0.1.tgz#bf081663d4fff05ee63b41f377a35f8b189f7e5b" @@ -3672,7 +3677,7 @@ semver@^7.3.4, semver@^7.5.3, semver@^7.5.4: dependencies: lru-cache "^6.0.0" -semver@^7.6.0: +semver@^7.6.0, semver@^7.6.2: version "7.6.2" resolved "https://registry.yarnpkg.com/semver/-/semver-7.6.2.tgz#1e3b34759f896e8f14d6134732ce798aeb0c6e13" integrity sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w== From 4789c24e1ed073b83b74a4aeee5408c70f5add1e Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Tue, 9 Jul 2024 11:19:15 -0700 Subject: [PATCH 03/40] Get esbuild working and adopt vs scrollbar --- bin/esbuild.mjs | 10 +- css/xterm.css | 4 - src/browser/CoreBrowserTerminal.ts | 73 ++- src/browser/Terminal.test.ts | 2 - src/browser/Types.ts | 1 - src/browser/Viewport.ts | 446 +++--------------- .../decorations/OverviewRulerRenderer.ts | 19 +- src/common/tsconfig.json | 8 +- src/vs/tsconfig.json | 5 +- 9 files changed, 132 insertions(+), 436 deletions(-) diff --git a/bin/esbuild.mjs b/bin/esbuild.mjs index 3e792712de..291ad4067a 100644 --- a/bin/esbuild.mjs +++ b/bin/esbuild.mjs @@ -35,7 +35,7 @@ const devOptions = { const prodOptions = { minify: true, treeShaking: true, - logLevel: 'verbose', + logLevel: 'debug', legalComments: 'none', // TODO: Mangling private and protected properties will reduce bundle size quite a bit, we must // make sure we don't cast privates to `any` in order to prevent regressions. @@ -171,7 +171,13 @@ if (config.addon) { }; outConfig = { ...outConfig, - entryPoints: ['src/**/*.ts'], + entryPoints: [ + `src/browser/public/Terminal.ts`, + `src/headless/public/Terminal.ts`, + `src/browser/*.test.ts`, + `src/common/*.test.ts`, + `src/headless/*.test.ts` + ], outdir: 'out-esbuild/' }; outTestConfig = { diff --git a/css/xterm.css b/css/xterm.css index 51e9b39e76..ef39dfea60 100644 --- a/css/xterm.css +++ b/css/xterm.css @@ -112,10 +112,6 @@ top: 0; } -.xterm .xterm-scroll-area { - visibility: hidden; -} - .xterm-char-measure-element { display: inline-block; visibility: hidden; diff --git a/src/browser/CoreBrowserTerminal.ts b/src/browser/CoreBrowserTerminal.ts index f9eefaed35..e06afdd4bd 100644 --- a/src/browser/CoreBrowserTerminal.ts +++ b/src/browser/CoreBrowserTerminal.ts @@ -26,7 +26,7 @@ import { addDisposableDomListener } from 'browser/Lifecycle'; import { Linkifier } from './Linkifier'; import * as Strings from 'browser/LocalizableStrings'; import { OscLinkProvider } from 'browser/OscLinkProvider'; -import { CharacterJoinerHandler, CustomKeyEventHandler, CustomWheelEventHandler, IBrowser, IBufferRange, ICompositionHelper, ILinkifier2, ITerminal, IViewport } from 'browser/Types'; +import { CharacterJoinerHandler, CustomKeyEventHandler, CustomWheelEventHandler, IBrowser, IBufferRange, ICompositionHelper, ILinkifier2, ITerminal } from 'browser/Types'; import { Viewport } from 'browser/Viewport'; import { BufferDecorationRenderer } from 'browser/decorations/BufferDecorationRenderer'; import { OverviewRulerRenderer } from 'browser/decorations/OverviewRulerRenderer'; @@ -65,7 +65,6 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal { public screenElement: HTMLElement | undefined; private _document: Document | undefined; - private _viewportScrollArea: HTMLElement | undefined; private _viewportElement: HTMLElement | undefined; private _helperContainer: HTMLElement | undefined; private _compositionView: HTMLElement | undefined; @@ -118,7 +117,6 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal { */ private _unprocessedDeadKey: boolean = false; - public viewport: IViewport | undefined; private _compositionHelper: ICompositionHelper | undefined; private _accessibilityManager: MutableDisposable = this.register(new MutableDisposable()); @@ -427,10 +425,6 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal { this._viewportElement.classList.add('xterm-viewport'); fragment.appendChild(this._viewportElement); - this._viewportScrollArea = this._document.createElement('div'); - this._viewportScrollArea.classList.add('xterm-scroll-area'); - this._viewportElement.appendChild(this._viewportScrollArea); - this.screenElement = this._document.createElement('div'); this.screenElement.classList.add('xterm-screen'); this.register(addDisposableDomListener(this.screenElement, 'mousemove', (ev: MouseEvent) => this.updateCursorStyle(ev))); @@ -503,11 +497,6 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal { this._renderService.setRenderer(this._createRenderer()); } - this.viewport = this._instantiationService.createInstance(Viewport, this._viewportElement, this._viewportScrollArea); - this.viewport.onRequestScrollLines(e => this.scrollLines(e.amount, e.suppressScrollEvent, ScrollSource.VIEWPORT)), - this.register(this._inputHandler.onRequestSyncScrollBar(() => this.viewport!.syncScrollArea())); - this.register(this.viewport); - this.register(this.onCursorMove(() => { this._renderService!.handleCursorMove(); this._syncTextArea(); @@ -515,7 +504,9 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal { this.register(this.onResize(() => this._renderService!.handleResize(this.cols, this.rows))); this.register(this.onBlur(() => this._renderService!.handleBlur())); this.register(this.onFocus(() => this._renderService!.handleFocus())); - this.register(this._renderService.onDimensionsChange(() => this.viewport!.syncScrollArea())); + + const viewport = this.register(this._instantiationService.createInstance(Viewport, this.element, this.screenElement)); + this.register(viewport.onRequestScrollLines(e => this.scrollLines(e, false, ScrollSource.VIEWPORT))); this._selectionService = this.register(this._instantiationService.createInstance(SelectionService, this.element, @@ -534,11 +525,7 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal { this.textarea!.focus(); this.textarea!.select(); })); - this.register(this._onScroll.event(ev => { - this.viewport!.syncScrollArea(); - this._selectionService!.refresh(); - })); - this.register(addDisposableDomListener(this._viewportElement, 'scroll', () => this._selectionService!.refresh())); + this.register(this._onScroll.event(() => this._selectionService!.refresh())); this.register(this._instantiationService.createInstance(BufferDecorationRenderer, this.screenElement)); this.register(addDisposableDomListener(this.element, 'mousedown', (e: MouseEvent) => this._selectionService!.handleMouseDown(e))); @@ -642,7 +629,9 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal { if (self._customWheelEventHandler && self._customWheelEventHandler(ev as WheelEvent) === false) { return false; } - const amount = self.viewport!.getLinesScrolled(ev as WheelEvent); + // TODO: Implement + const amount = 0; + // const amount = self.viewport!.getLinesScrolled(ev as WheelEvent); if (amount === 0) { return false; @@ -808,7 +797,8 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal { if (!this.buffer.hasScrollback) { // Convert wheel events into up/down events when the buffer does not have scrollback, this // enables scrolling in apps hosted in the alt buffer such as vim or tmux. - const amount = this.viewport!.getLinesScrolled(ev); + // TODSO: Impl + const amount = 0; // this.viewport!.getLinesScrolled(ev); // Do nothing if there's no vertical scroll if (amount === 0) { @@ -827,23 +817,24 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal { // normal viewport scrolling // conditionally stop event, if the viewport still had rows to scroll within - if (this.viewport!.handleWheel(ev)) { - return this.cancel(ev); - } + // if (this.viewport!.handleWheel(ev)) { + // return this.cancel(ev); + // } }, { passive: false })); - this.register(addDisposableDomListener(el, 'touchstart', (ev: TouchEvent) => { - if (this.coreMouseService.areMouseEventsActive) return; - this.viewport!.handleTouchStart(ev); - return this.cancel(ev); - }, { passive: true })); - - this.register(addDisposableDomListener(el, 'touchmove', (ev: TouchEvent) => { - if (this.coreMouseService.areMouseEventsActive) return; - if (!this.viewport!.handleTouchMove(ev)) { - return this.cancel(ev); - } - }, { passive: false })); + // TODO: Make sure this.coreMouseService.areMouseEventsActive still works + // this.register(addDisposableDomListener(el, 'touchstart', (ev: TouchEvent) => { + // if (this.coreMouseService.areMouseEventsActive) return; + // this.viewport!.handleTouchStart(ev); + // return this.cancel(ev); + // }, { passive: true })); + + // this.register(addDisposableDomListener(el, 'touchmove', (ev: TouchEvent) => { + // if (this.coreMouseService.areMouseEventsActive) return; + // if (!this.viewport!.handleTouchMove(ev)) { + // return this.cancel(ev); + // } + // }, { passive: false })); } @@ -882,8 +873,6 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal { if (source === ScrollSource.VIEWPORT) { super.scrollLines(disp, suppressScrollEvent, source); this.refresh(0, this.rows - 1); - } else { - this.viewport?.scrollLines(disp); } } @@ -1212,10 +1201,6 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal { private _afterResize(x: number, y: number): void { this._charSizeService?.measure(); - - // Sync the scroll area to make sure scroll events don't fire and scroll the viewport to an - // invalid location - this.viewport?.syncScrollArea(true); } /** @@ -1238,7 +1223,8 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal { // IMPORTANT: Fire scroll event before viewport is reset. This ensures embedders get the clear // scroll event and that the viewport's state will be valid for immediate writes. this._onScroll.fire({ position: this.buffer.ydisp, source: ScrollSource.TERMINAL }); - this.viewport?.reset(); + // TODO: Reset scrollable element? + // this.viewport?.reset(); this.refresh(0, this.rows - 1); } @@ -1263,7 +1249,8 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal { super.reset(); this._selectionService?.reset(); this._decorationService.reset(); - this.viewport?.reset(); + // TODO: Reset scrollable element? + // this.viewport?.reset(); // reattach this._customKeyEventHandler = customKeyEventHandler; diff --git a/src/browser/Terminal.test.ts b/src/browser/Terminal.test.ts index 11f676a340..85a3248eef 100644 --- a/src/browser/Terminal.test.ts +++ b/src/browser/Terminal.test.ts @@ -29,8 +29,6 @@ describe('Terminal', () => { term = new TestTerminal(termOptions); term.refresh = () => { }; (term as any).renderer = new MockRenderer(); - term.viewport = new MockViewport(); - term.viewport.onRequestScrollLines(e => term.scrollLines(e.amount, e.suppressScrollEvent, ScrollSource.VIEWPORT)); (term as any)._compositionHelper = new MockCompositionHelper(); (term as any).element = { classList: { diff --git a/src/browser/Types.ts b/src/browser/Types.ts index 9ef9d3a811..fd0bd9515d 100644 --- a/src/browser/Types.ts +++ b/src/browser/Types.ts @@ -19,7 +19,6 @@ export interface ITerminal extends InternalPassthroughApis, ICoreTerminal { browser: IBrowser; buffer: IBuffer; linkifier: ILinkifier2 | undefined; - viewport: IViewport | undefined; options: Required; onBlur: IEvent; diff --git a/src/browser/Viewport.ts b/src/browser/Viewport.ts index cb0f35ead4..211d7504f2 100644 --- a/src/browser/Viewport.ts +++ b/src/browser/Viewport.ts @@ -1,414 +1,124 @@ /** - * Copyright (c) 2016 The xterm.js authors. All rights reserved. + * Copyright (c) 2024 The xterm.js authors. All rights reserved. * @license MIT */ -import { addDisposableDomListener } from 'browser/Lifecycle'; -import { IViewport, ReadonlyColorSet } from 'browser/Types'; -import { IRenderDimensions } from 'browser/renderer/shared/Types'; -import { ICharSizeService, ICoreBrowserService, IRenderService, IThemeService } from 'browser/services/Services'; +import { IRenderService, IThemeService } from 'browser/services/Services'; import { EventEmitter } from 'common/EventEmitter'; import { Disposable } from 'common/Lifecycle'; -import { IBuffer } from 'common/buffer/Types'; import { IBufferService, IOptionsService } from 'common/services/Services'; +import { DomScrollableElement } from 'vs/base/browser/ui/scrollbar/scrollableElement'; +import type { ScrollableElementChangeOptions } from 'vs/base/browser/ui/scrollbar/scrollableElementOptions'; +import { ScrollbarVisibility, type ScrollEvent } from 'vs/base/common/scrollable'; -const FALLBACK_SCROLL_BAR_WIDTH = 15; +export class Viewport extends Disposable{ -interface ISmoothScrollState { - startTime: number; - origin: number; - target: number; -} - -/** - * Represents the viewport of a terminal, the visible area within the larger buffer of output. - * Logic for the virtual scroll bar is included in this object. - */ -export class Viewport extends Disposable implements IViewport { - public scrollBarWidth: number = 0; - private _currentRowHeight: number = 0; - private _currentDeviceCellHeight: number = 0; - private _lastRecordedBufferLength: number = 0; - private _lastRecordedViewportHeight: number = 0; - private _lastRecordedBufferHeight: number = 0; - private _lastTouchY: number = 0; - private _lastScrollTop: number = 0; - private _activeBuffer: IBuffer; - private _renderDimensions: IRenderDimensions; - - private _smoothScrollAnimationFrame: number = 0; - - // Stores a partial line amount when scrolling, this is used to keep track of how much of a line - // is scrolled so we can "scroll" over partial lines and feel natural on touchpads. This is a - // quick fix and could have a more robust solution in place that reset the value when needed. - private _wheelPartialScroll: number = 0; + protected _onRequestScrollLines = this.register(new EventEmitter()); + public readonly onRequestScrollLines = this._onRequestScrollLines.event; - private _refreshAnimationFrame: number | null = null; - private _ignoreNextScrollEvent: boolean = false; - private _smoothScrollState: ISmoothScrollState = { - startTime: 0, - origin: -1, - target: -1 - }; + private _scrollableElement: DomScrollableElement; - private _ensureTimeout: number; - - private readonly _onRequestScrollLines = this.register(new EventEmitter<{ amount: number, suppressScrollEvent: boolean }>()); - public readonly onRequestScrollLines = this._onRequestScrollLines.event; + private _queuedAnimationFrame?: number; + private _latestYDisp?: number; + private _isSyncing: boolean = false; + private _isHandlingScroll: boolean = false; + private _suppressOnScrollHandler: boolean = false; constructor( - private readonly _viewportElement: HTMLElement, - private readonly _scrollArea: HTMLElement, + element: HTMLElement, + screenElement: HTMLElement, @IBufferService private readonly _bufferService: IBufferService, + @IThemeService themeService: IThemeService, @IOptionsService private readonly _optionsService: IOptionsService, - @ICharSizeService private readonly _charSizeService: ICharSizeService, - @IRenderService private readonly _renderService: IRenderService, - @ICoreBrowserService private readonly _coreBrowserService: ICoreBrowserService, - @IThemeService themeService: IThemeService + @IRenderService private readonly _renderService: IRenderService ) { super(); - // Measure the width of the scrollbar. If it is 0 we can assume it's an OSX overlay scrollbar. - // Unfortunately the overlay scrollbar would be hidden underneath the screen element in that - // case, therefore we account for a standard amount to make it visible - this.scrollBarWidth = (this._viewportElement.offsetWidth - this._scrollArea.offsetWidth) || FALLBACK_SCROLL_BAR_WIDTH; - this.register(addDisposableDomListener(this._viewportElement, 'scroll', this._handleScroll.bind(this))); + // TODO: Support smooth scroll + // TODO: Support fastScrollModifier? + // TODO: overviewRulerWidth should deprecated in favor of scrollBarWidth? - // Track properties used in performance critical code manually to avoid using slow getters - this._activeBuffer = this._bufferService.buffer; - this.register(this._bufferService.buffers.onBufferActivate(e => this._activeBuffer = e.activeBuffer)); - this._renderDimensions = this._renderService.dimensions; - this.register(this._renderService.onDimensionsChange(e => this._renderDimensions = e)); + this._scrollableElement = this.register(new DomScrollableElement(screenElement, { + vertical: ScrollbarVisibility.Auto, + horizontal: ScrollbarVisibility.Hidden, + useShadows: false, + mouseWheelSmoothScroll: true, + ...this._getMutableOptions() + })); + this.register(this._optionsService.onMultipleOptionChange([ + 'scrollSensitivity', + 'fastScrollSensitivity', + 'overviewRulerWidth' + ], () => this._scrollableElement.updateOptions(this._getMutableOptions()))); - this._handleThemeChange(themeService.colors); - this.register(themeService.onChangeColors(e => this._handleThemeChange(e))); - this.register(this._optionsService.onSpecificOptionChange('scrollback', () => this.syncScrollArea())); + this._scrollableElement.setScrollDimensions({ height: 0, scrollHeight: 0 }); + this._scrollableElement.getDomNode().style.backgroundColor = themeService.colors.background.css; + element.appendChild(this._scrollableElement.getDomNode()); - // Perform this async to ensure the ICharSizeService is ready. - this._ensureTimeout = window.setTimeout(() => this.syncScrollArea()); - } + this.register(this._bufferService.onResize(() => this._queueSync())); + this.register(this._bufferService.onScroll(ydisp => this._queueSync(ydisp))); - private _handleThemeChange(colors: ReadonlyColorSet): void { - this._viewportElement.style.backgroundColor = colors.background.css; + this.register(this._scrollableElement.onScroll(e => this._handleScroll(e))); } - public reset(): void { - this._currentRowHeight = 0; - this._currentDeviceCellHeight = 0; - this._lastRecordedBufferLength = 0; - this._lastRecordedViewportHeight = 0; - this._lastRecordedBufferHeight = 0; - this._lastTouchY = 0; - this._lastScrollTop = 0; - // Sync on next animation frame to ensure the new terminal state is used - this._coreBrowserService.window.requestAnimationFrame(() => this.syncScrollArea()); + private _getMutableOptions(): ScrollableElementChangeOptions { + return { + mouseWheelScrollSensitivity: this._optionsService.rawOptions.scrollSensitivity, + fastScrollSensitivity: this._optionsService.rawOptions.fastScrollSensitivity, + verticalScrollbarSize: this._optionsService.rawOptions.overviewRulerWidth || 14 + }; } - /** - * Refreshes row height, setting line-height, viewport height and scroll area height if - * necessary. - */ - private _refresh(immediate: boolean): void { - if (immediate) { - this._innerRefresh(); - if (this._refreshAnimationFrame !== null) { - this._coreBrowserService.window.cancelAnimationFrame(this._refreshAnimationFrame); - } - return; - } - if (this._refreshAnimationFrame === null) { - this._refreshAnimationFrame = this._coreBrowserService.window.requestAnimationFrame(() => this._innerRefresh()); - } - } - - private _innerRefresh(): void { - if (this._charSizeService.height > 0) { - this._currentRowHeight = this._renderDimensions.device.cell.height / this._coreBrowserService.dpr; - this._currentDeviceCellHeight = this._renderDimensions.device.cell.height; - this._lastRecordedViewportHeight = this._viewportElement.offsetHeight; - const newBufferHeight = Math.round(this._currentRowHeight * this._lastRecordedBufferLength) + (this._lastRecordedViewportHeight - this._renderDimensions.css.canvas.height); - if (this._lastRecordedBufferHeight !== newBufferHeight) { - this._lastRecordedBufferHeight = newBufferHeight; - this._scrollArea.style.height = this._lastRecordedBufferHeight + 'px'; - } + private _queueSync(ydisp?: number): void { + // Update state + if (ydisp !== undefined) { + this._latestYDisp = ydisp; } - // Sync scrollTop - const scrollTop = this._bufferService.buffer.ydisp * this._currentRowHeight; - if (this._viewportElement.scrollTop !== scrollTop) { - // Ignore the next scroll event which will be triggered by setting the scrollTop as we do not - // want this event to scroll the terminal - this._ignoreNextScrollEvent = true; - this._viewportElement.scrollTop = scrollTop; + // Don't queue more than one callback + if (this._queuedAnimationFrame !== undefined) { + return; } - - this._refreshAnimationFrame = null; + this._queuedAnimationFrame = this._renderService.addRefreshCallback(() => this._sync(this._latestYDisp)); + this._latestYDisp = undefined; + this._queuedAnimationFrame = undefined; } - /** - * Updates dimensions and synchronizes the scroll area if necessary. - */ - public syncScrollArea(immediate: boolean = false): void { - // If buffer height changed - if (this._lastRecordedBufferLength !== this._bufferService.buffer.lines.length) { - this._lastRecordedBufferLength = this._bufferService.buffer.lines.length; - this._refresh(immediate); + private _sync(ydisp: number = this._bufferService.buffer.ydisp): void { + if (!this._renderService || this._isSyncing) { return; } + this._isSyncing = true; - // If viewport height changed - if (this._lastRecordedViewportHeight !== this._renderService.dimensions.css.canvas.height) { - this._refresh(immediate); - return; - } + // Ignore any onScroll event that happens as a result of dimensions changing as this should + // never cause a scrollLines call, only setScrollPosition can do that. + this._suppressOnScrollHandler = true; + this._scrollableElement.setScrollDimensions({ + height: this._renderService.dimensions.css.canvas.height, + scrollHeight: this._renderService.dimensions.css.cell.height * this._bufferService.buffer.lines.length + }); + this._suppressOnScrollHandler = false; - // If the buffer position doesn't match last scroll top - if (this._lastScrollTop !== this._activeBuffer.ydisp * this._currentRowHeight) { - this._refresh(immediate); - return; - } + this._scrollableElement.setScrollPosition({ + scrollTop: ydisp * this._renderService.dimensions.css.cell.height + }); - // If row height changed - if (this._renderDimensions.device.cell.height !== this._currentDeviceCellHeight) { - this._refresh(immediate); - return; - } + this._isSyncing = false; } - /** - * Handles scroll events on the viewport, calculating the new viewport and requesting the - * terminal to scroll to it. - * @param ev The scroll event. - */ - private _handleScroll(ev: Event): void { - // Record current scroll top position - this._lastScrollTop = this._viewportElement.scrollTop; - - // Don't attempt to scroll if the element is not visible, otherwise scrollTop will be corrupt - // which causes the terminal to scroll the buffer to the top - if (!this._viewportElement.offsetParent) { + private _handleScroll(e: ScrollEvent): void { + if (!this._renderService) { return; } - - // Ignore the event if it was flagged to ignore (when the source of the event is from Viewport) - if (this._ignoreNextScrollEvent) { - this._ignoreNextScrollEvent = false; - // Still trigger the scroll so lines get refreshed - this._onRequestScrollLines.fire({ amount: 0, suppressScrollEvent: true }); + if (this._isHandlingScroll || this._suppressOnScrollHandler) { return; } - - const newRow = Math.round(this._lastScrollTop / this._currentRowHeight); + this._isHandlingScroll = true; + const newRow = Math.round(e.scrollTop / this._renderService.dimensions.css.cell.height); const diff = newRow - this._bufferService.buffer.ydisp; - this._onRequestScrollLines.fire({ amount: diff, suppressScrollEvent: true }); - } - - private _smoothScroll(): void { - // Check valid state - if (this._isDisposed || this._smoothScrollState.origin === -1 || this._smoothScrollState.target === -1) { - return; - } - - // Calculate position complete - const percent = this._smoothScrollPercent(); - this._viewportElement.scrollTop = this._smoothScrollState.origin + Math.round(percent * (this._smoothScrollState.target - this._smoothScrollState.origin)); - - // Continue or finish smooth scroll - if (percent < 1) { - if (!this._smoothScrollAnimationFrame) { - this._smoothScrollAnimationFrame = this._coreBrowserService.window.requestAnimationFrame(() => { - this._smoothScrollAnimationFrame = 0; - this._smoothScroll(); - }); - } - } else { - this._clearSmoothScrollState(); - } - } - - private _smoothScrollPercent(): number { - if (!this._optionsService.rawOptions.smoothScrollDuration || !this._smoothScrollState.startTime) { - return 1; - } - return Math.max(Math.min((Date.now() - this._smoothScrollState.startTime) / this._optionsService.rawOptions.smoothScrollDuration, 1), 0); - } - - private _clearSmoothScrollState(): void { - this._smoothScrollState.startTime = 0; - this._smoothScrollState.origin = -1; - this._smoothScrollState.target = -1; - } - - /** - * Handles bubbling of scroll event in case the viewport has reached top or bottom - * @param ev The scroll event. - * @param amount The amount scrolled - */ - private _bubbleScroll(ev: Event, amount: number): boolean { - const scrollPosFromTop = this._viewportElement.scrollTop + this._lastRecordedViewportHeight; - if ((amount < 0 && this._viewportElement.scrollTop !== 0) || - (amount > 0 && scrollPosFromTop < this._lastRecordedBufferHeight)) { - if (ev.cancelable) { - ev.preventDefault(); - } - return false; - } - return true; - } - - /** - * Handles mouse wheel events by adjusting the viewport's scrollTop and delegating the actual - * scrolling to `onScroll`, this event needs to be attached manually by the consumer of - * `Viewport`. - * @param ev The mouse wheel event. - */ - public handleWheel(ev: WheelEvent): boolean { - const amount = this._getPixelsScrolled(ev); - if (amount === 0) { - return false; - } - if (!this._optionsService.rawOptions.smoothScrollDuration) { - this._viewportElement.scrollTop += amount; - } else { - this._smoothScrollState.startTime = Date.now(); - if (this._smoothScrollPercent() < 1) { - this._smoothScrollState.origin = this._viewportElement.scrollTop; - if (this._smoothScrollState.target === -1) { - this._smoothScrollState.target = this._viewportElement.scrollTop + amount; - } else { - this._smoothScrollState.target += amount; - } - this._smoothScrollState.target = Math.max(Math.min(this._smoothScrollState.target, this._viewportElement.scrollHeight), 0); - this._smoothScroll(); - } else { - this._clearSmoothScrollState(); - } + if (diff !== 0) { + this._onRequestScrollLines.fire(diff); } - return this._bubbleScroll(ev, amount); - } - - public scrollLines(disp: number): void { - if (disp === 0) { - return; - } - if (!this._optionsService.rawOptions.smoothScrollDuration) { - this._onRequestScrollLines.fire({ amount: disp, suppressScrollEvent: false }); - } else { - const amount = disp * this._currentRowHeight; - this._smoothScrollState.startTime = Date.now(); - if (this._smoothScrollPercent() < 1) { - this._smoothScrollState.origin = this._viewportElement.scrollTop; - this._smoothScrollState.target = this._smoothScrollState.origin + amount; - this._smoothScrollState.target = Math.max(Math.min(this._smoothScrollState.target, this._viewportElement.scrollHeight), 0); - this._smoothScroll(); - } else { - this._clearSmoothScrollState(); - } - } - } - - private _getPixelsScrolled(ev: WheelEvent): number { - // Do nothing if it's not a vertical scroll event - if (ev.deltaY === 0 || ev.shiftKey) { - return 0; - } - - // Fallback to WheelEvent.DOM_DELTA_PIXEL - let amount = this._applyScrollModifier(ev.deltaY, ev); - if (ev.deltaMode === WheelEvent.DOM_DELTA_LINE) { - amount *= this._currentRowHeight; - } else if (ev.deltaMode === WheelEvent.DOM_DELTA_PAGE) { - amount *= this._currentRowHeight * this._bufferService.rows; - } - return amount; - } - - - public getBufferElements(startLine: number, endLine?: number): { bufferElements: HTMLElement[], cursorElement?: HTMLElement } { - let currentLine: string = ''; - let cursorElement: HTMLElement | undefined; - const bufferElements: HTMLElement[] = []; - const end = endLine ?? this._bufferService.buffer.lines.length; - const lines = this._bufferService.buffer.lines; - for (let i = startLine; i < end; i++) { - const line = lines.get(i); - if (!line) { - continue; - } - const isWrapped = lines.get(i + 1)?.isWrapped; - currentLine += line.translateToString(!isWrapped); - if (!isWrapped || i === lines.length - 1) { - const div = document.createElement('div'); - div.textContent = currentLine; - bufferElements.push(div); - if (currentLine.length > 0) { - cursorElement = div; - } - currentLine = ''; - } - } - return { bufferElements, cursorElement }; - } - - /** - * Gets the number of pixels scrolled by the mouse event taking into account what type of delta - * is being used. - * @param ev The mouse wheel event. - */ - public getLinesScrolled(ev: WheelEvent): number { - // Do nothing if it's not a vertical scroll event - if (ev.deltaY === 0 || ev.shiftKey) { - return 0; - } - - // Fallback to WheelEvent.DOM_DELTA_LINE - let amount = this._applyScrollModifier(ev.deltaY, ev); - if (ev.deltaMode === WheelEvent.DOM_DELTA_PIXEL) { - amount /= this._currentRowHeight + 0.0; // Prevent integer division - this._wheelPartialScroll += amount; - amount = Math.floor(Math.abs(this._wheelPartialScroll)) * (this._wheelPartialScroll > 0 ? 1 : -1); - this._wheelPartialScroll %= 1; - } else if (ev.deltaMode === WheelEvent.DOM_DELTA_PAGE) { - amount *= this._bufferService.rows; - } - return amount; - } - - private _applyScrollModifier(amount: number, ev: WheelEvent): number { - const modifier = this._optionsService.rawOptions.fastScrollModifier; - // Multiply the scroll speed when the modifier is down - if ((modifier === 'alt' && ev.altKey) || - (modifier === 'ctrl' && ev.ctrlKey) || - (modifier === 'shift' && ev.shiftKey)) { - return amount * this._optionsService.rawOptions.fastScrollSensitivity * this._optionsService.rawOptions.scrollSensitivity; - } - - return amount * this._optionsService.rawOptions.scrollSensitivity; - } - - /** - * Handles the touchstart event, recording the touch occurred. - * @param ev The touch event. - */ - public handleTouchStart(ev: TouchEvent): void { - this._lastTouchY = ev.touches[0].pageY; - } - - /** - * Handles the touchmove event, scrolling the viewport if the position shifted. - * @param ev The touch event. - */ - public handleTouchMove(ev: TouchEvent): boolean { - const deltaY = this._lastTouchY - ev.touches[0].pageY; - this._lastTouchY = ev.touches[0].pageY; - if (deltaY === 0) { - return false; - } - this._viewportElement.scrollTop += deltaY; - return this._bubbleScroll(ev, deltaY); - } - - public dispose(): void { - clearTimeout(this._ensureTimeout); + this._isHandlingScroll = false; } } diff --git a/src/browser/decorations/OverviewRulerRenderer.ts b/src/browser/decorations/OverviewRulerRenderer.ts index 103d5d9a61..ee000dfcf1 100644 --- a/src/browser/decorations/OverviewRulerRenderer.ts +++ b/src/browser/decorations/OverviewRulerRenderer.ts @@ -118,8 +118,8 @@ export class OverviewRulerRenderer extends Disposable { private _refreshDrawConstants(): void { // width - const outerWidth = Math.floor(this._canvas.width / 3); - const innerWidth = Math.ceil(this._canvas.width / 3); + const outerWidth = Math.floor((this._canvas.width - 1) / 3); + const innerWidth = Math.ceil((this._canvas.width - 1) / 3); drawWidth.full = this._canvas.width; drawWidth.left = outerWidth; drawWidth.center = innerWidth; @@ -127,10 +127,10 @@ export class OverviewRulerRenderer extends Disposable { // height this._refreshDrawHeightConstants(); // x - drawX.full = 0; - drawX.left = 0; - drawX.center = drawWidth.left; - drawX.right = drawWidth.left + drawWidth.center; + drawX.full = 1; + drawX.left = 1; + drawX.center = 1 + drawWidth.left; + drawX.right = 1 + drawWidth.left + drawWidth.center; } private _refreshDrawHeightConstants(): void { @@ -173,6 +173,7 @@ export class OverviewRulerRenderer extends Disposable { this._colorZoneStore.addDecoration(decoration); } this._ctx.lineWidth = 1; + this._renderRulerOutline(); const zones = this._colorZoneStore.zones; for (const zone of zones) { if (zone.position !== 'full') { @@ -188,6 +189,12 @@ export class OverviewRulerRenderer extends Disposable { this._shouldUpdateAnchor = false; } + private _renderRulerOutline(): void { + // TODO: Support customizing the color + this._ctx.fillStyle = '#000'; + this._ctx.fillRect(0, 0, 1, this._canvas.height); + } + private _renderColorZone(zone: IColorZone): void { this._ctx.fillStyle = zone.color; this._ctx.fillRect( diff --git a/src/common/tsconfig.json b/src/common/tsconfig.json index e9b3673ab2..b8baa6c09d 100644 --- a/src/common/tsconfig.json +++ b/src/common/tsconfig.json @@ -9,16 +9,10 @@ "types": [ "../../node_modules/@types/mocha" ], - "baseUrl": "..", - "paths": { - "vs/*": [ "./vs/*" ] - } + "baseUrl": ".." }, "include": [ "./**/*", "../../typings/xterm.d.ts" - ], - "references": [ - { "path": "../vs" } ] } diff --git a/src/vs/tsconfig.json b/src/vs/tsconfig.json index 134dd90812..30f10c2399 100644 --- a/src/vs/tsconfig.json +++ b/src/vs/tsconfig.json @@ -64,8 +64,7 @@ "base/common/jsonc.js", "base/common/performance.js", "base/common/marked/marked.js", - "base/common/network.js", - - "typings/vscode-globals-modules.d.ts" + "base/common/network.ts", + "base/browser/markdownRenderer.ts" ] } From 99db197472b5be7045377669ff6b7f24a767de81 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Tue, 9 Jul 2024 11:59:22 -0700 Subject: [PATCH 04/40] . --- .eslintrc.json | 1 + .vscode/settings.json | 6 +- bin/esbuild.mjs | 9 +- src/vs/base/browser/broadcast.ts | 70 + src/vs/base/browser/browser.ts | 141 + src/vs/base/browser/canIUse.ts | 47 + src/vs/base/browser/defaultWorkerFactory.ts | 174 ++ src/vs/base/browser/deviceAccess.ts | 108 + src/vs/base/browser/dnd.ts | 110 + src/vs/base/browser/dom.ts | 2369 +++++++++++++++++ src/vs/base/browser/domObservable.ts | 17 + src/vs/base/browser/event.ts | 50 + src/vs/base/browser/fastDomNode.ts | 316 +++ src/vs/base/browser/fonts.ts | 16 + src/vs/base/browser/formattedTextRenderer.ts | 226 ++ .../base/browser/globalPointerMoveMonitor.ts | 112 + src/vs/base/browser/hash.ts | 32 + src/vs/base/browser/history.ts | 20 + src/vs/base/browser/iframe.ts | 135 + src/vs/base/browser/indexedDB.ts | 177 ++ src/vs/base/browser/keyboardEvent.ts | 213 ++ src/vs/base/browser/mouseEvent.ts | 229 ++ src/vs/base/browser/performance.ts | 272 ++ src/vs/base/browser/pixelRatio.ts | 114 + src/vs/base/browser/touch.ts | 372 +++ src/vs/base/browser/trustedTypes.ts | 35 + src/vs/base/browser/ui/aria/aria.css | 9 + src/vs/base/browser/ui/aria/aria.ts | 163 ++ .../ui/breadcrumbs/breadcrumbsWidget.css | 36 + .../ui/breadcrumbs/breadcrumbsWidget.ts | 356 +++ .../browser/ui/centered/centeredViewLayout.ts | 225 ++ .../ui/codicons/codicon/codicon-modifiers.css | 33 + .../browser/ui/codicons/codicon/codicon.css | 25 + .../base/browser/ui/codicons/codiconStyles.ts | 7 + .../base/browser/ui/countBadge/countBadge.css | 24 + .../base/browser/ui/countBadge/countBadge.ts | 69 + src/vs/base/browser/ui/grid/grid.ts | 947 +++++++ src/vs/base/browser/ui/grid/gridview.css | 16 + src/vs/base/browser/ui/grid/gridview.ts | 1836 +++++++++++++ .../browser/ui/mouseCursor/mouseCursor.css | 8 + .../browser/ui/mouseCursor/mouseCursor.ts | 8 + .../progressAccessibilitySignal.ts | 23 + .../browser/ui/progressbar/progressbar.css | 61 + .../browser/ui/progressbar/progressbar.ts | 224 ++ src/vs/base/browser/ui/resizable/resizable.ts | 190 ++ src/vs/base/browser/ui/sash/sash.css | 149 ++ src/vs/base/browser/ui/sash/sash.ts | 688 +++++ .../browser/ui/scrollbar/abstractScrollbar.ts | 303 +++ .../ui/scrollbar/horizontalScrollbar.ts | 114 + .../browser/ui/scrollbar/media/scrollbars.css | 72 + .../browser/ui/scrollbar/scrollableElement.ts | 718 +++++ .../ui/scrollbar/scrollableElementOptions.ts | 165 ++ .../browser/ui/scrollbar/scrollbarArrow.ts | 114 + .../browser/ui/scrollbar/scrollbarState.ts | 243 ++ .../scrollbarVisibilityController.ts | 118 + .../browser/ui/scrollbar/verticalScrollbar.ts | 116 + src/vs/base/browser/ui/splitview/paneview.css | 152 ++ src/vs/base/browser/ui/splitview/paneview.ts | 681 +++++ .../base/browser/ui/splitview/splitview.css | 70 + src/vs/base/browser/ui/splitview/splitview.ts | 1504 +++++++++++ src/vs/base/browser/ui/widget.ts | 57 + src/vs/base/browser/window.ts | 14 + src/vs/base/common/actions.ts | 271 ++ src/vs/base/common/amd.ts | 163 ++ src/vs/base/common/arrays.ts | 887 ++++++ src/vs/base/common/arraysFind.ts | 202 ++ src/vs/base/common/assert.ts | 71 + src/vs/base/common/async.ts | 1992 ++++++++++++++ src/vs/base/common/buffer.ts | 441 +++ src/vs/base/common/cache.ts | 120 + src/vs/base/common/cancellation.ts | 148 + src/vs/base/common/charCode.ts | 450 ++++ src/vs/base/common/codiconsUtil.ts | 28 + src/vs/base/common/collections.ts | 140 + src/vs/base/common/color.ts | 633 +++++ src/vs/base/common/comparers.ts | 355 +++ src/vs/base/common/controlFlow.ts | 69 + src/vs/base/common/date.ts | 242 ++ src/vs/base/common/decorators.ts | 130 + src/vs/base/common/desktopEnvironmentInfo.ts | 101 + src/vs/base/common/equals.ts | 146 + src/vs/base/common/errorMessage.ts | 113 + src/vs/base/common/errors.ts | 303 +++ src/vs/base/common/event.ts | 1762 ++++++++++++ src/vs/base/common/extpath.ts | 423 +++ src/vs/base/common/functional.ts | 32 + src/vs/base/common/glob.ts | 737 +++++ src/vs/base/common/hash.ts | 316 +++ src/vs/base/common/hierarchicalKind.ts | 31 + src/vs/base/common/history.ts | 277 ++ src/vs/base/common/hotReload.ts | 112 + src/vs/base/common/hotReloadHelpers.ts | 30 + src/vs/base/common/idGenerator.ts | 21 + src/vs/base/common/ime.ts | 36 + src/vs/base/common/iterator.ts | 159 ++ src/vs/base/common/json.ts | 1326 +++++++++ src/vs/base/common/jsonEdit.ts | 176 ++ src/vs/base/common/jsonErrorMessages.ts | 26 + src/vs/base/common/jsonFormatter.ts | 261 ++ src/vs/base/common/jsonSchema.ts | 267 ++ src/vs/base/common/jsonc.d.ts | 24 + src/vs/base/common/jsonc.js | 89 + src/vs/base/common/keyCodes.ts | 526 ++++ src/vs/base/common/keybindingLabels.ts | 184 ++ src/vs/base/common/keybindingParser.ts | 102 + src/vs/base/common/keybindings.ts | 284 ++ src/vs/base/common/lazy.ts | 47 + src/vs/base/common/lifecycle.ts | 801 ++++++ src/vs/base/common/linkedList.ts | 142 + src/vs/base/common/linkedText.ts | 55 + src/vs/base/common/map.ts | 202 ++ src/vs/base/common/mime.ts | 126 + src/vs/base/common/navigator.ts | 50 + src/vs/base/common/network.ts | 128 + src/vs/base/common/numbers.ts | 98 + src/vs/base/common/objects.ts | 274 ++ src/vs/base/common/observable.ts | 76 + src/vs/base/common/observableInternal/api.ts | 31 + .../base/common/observableInternal/autorun.ts | 281 ++ src/vs/base/common/observableInternal/base.ts | 489 ++++ .../common/observableInternal/debugName.ts | 145 + .../base/common/observableInternal/derived.ts | 428 +++ .../observableInternal/lazyObservableValue.ts | 146 + .../base/common/observableInternal/logging.ts | 328 +++ .../base/common/observableInternal/promise.ts | 209 ++ .../base/common/observableInternal/utils.ts | 610 +++++ src/vs/base/common/paging.ts | 189 ++ src/vs/base/common/parsers.ts | 79 + src/vs/base/common/path.ts | 1529 +++++++++++ src/vs/base/common/performance.d.ts | 16 + src/vs/base/common/performance.js | 135 + src/vs/base/common/platform.ts | 281 ++ src/vs/base/common/ports.ts | 13 + src/vs/base/common/prefixTree.ts | 252 ++ src/vs/base/common/process.ts | 76 + src/vs/base/common/processes.ts | 148 + src/vs/base/common/product.ts | 314 +++ src/vs/base/common/range.ts | 60 + src/vs/base/common/scrollable.ts | 522 ++++ src/vs/base/common/search.ts | 48 + src/vs/base/common/sequence.ts | 34 + src/vs/base/common/severity.ts | 56 + src/vs/base/common/skipList.ts | 204 ++ src/vs/base/common/stopwatch.ts | 43 + src/vs/base/common/stream.ts | 772 ++++++ src/vs/base/common/strings.ts | 557 ++++ src/vs/base/common/symbols.ts | 9 + src/vs/base/common/tfIdf.ts | 240 ++ src/vs/base/common/types.ts | 250 ++ src/vs/base/common/uint.ts | 59 + src/vs/base/common/uuid.ts | 81 + src/vs/base/common/verifier.ts | 87 + src/vs/tsconfig.json | 5 +- 153 files changed, 39831 insertions(+), 9 deletions(-) create mode 100644 src/vs/base/browser/broadcast.ts create mode 100644 src/vs/base/browser/browser.ts create mode 100644 src/vs/base/browser/canIUse.ts create mode 100644 src/vs/base/browser/defaultWorkerFactory.ts create mode 100644 src/vs/base/browser/deviceAccess.ts create mode 100644 src/vs/base/browser/dnd.ts create mode 100644 src/vs/base/browser/dom.ts create mode 100644 src/vs/base/browser/domObservable.ts create mode 100644 src/vs/base/browser/event.ts create mode 100644 src/vs/base/browser/fastDomNode.ts create mode 100644 src/vs/base/browser/fonts.ts create mode 100644 src/vs/base/browser/formattedTextRenderer.ts create mode 100644 src/vs/base/browser/globalPointerMoveMonitor.ts create mode 100644 src/vs/base/browser/hash.ts create mode 100644 src/vs/base/browser/history.ts create mode 100644 src/vs/base/browser/iframe.ts create mode 100644 src/vs/base/browser/indexedDB.ts create mode 100644 src/vs/base/browser/keyboardEvent.ts create mode 100644 src/vs/base/browser/mouseEvent.ts create mode 100644 src/vs/base/browser/performance.ts create mode 100644 src/vs/base/browser/pixelRatio.ts create mode 100644 src/vs/base/browser/touch.ts create mode 100644 src/vs/base/browser/trustedTypes.ts create mode 100644 src/vs/base/browser/ui/aria/aria.css create mode 100644 src/vs/base/browser/ui/aria/aria.ts create mode 100644 src/vs/base/browser/ui/breadcrumbs/breadcrumbsWidget.css create mode 100644 src/vs/base/browser/ui/breadcrumbs/breadcrumbsWidget.ts create mode 100644 src/vs/base/browser/ui/centered/centeredViewLayout.ts create mode 100644 src/vs/base/browser/ui/codicons/codicon/codicon-modifiers.css create mode 100644 src/vs/base/browser/ui/codicons/codicon/codicon.css create mode 100644 src/vs/base/browser/ui/codicons/codiconStyles.ts create mode 100644 src/vs/base/browser/ui/countBadge/countBadge.css create mode 100644 src/vs/base/browser/ui/countBadge/countBadge.ts create mode 100644 src/vs/base/browser/ui/grid/grid.ts create mode 100644 src/vs/base/browser/ui/grid/gridview.css create mode 100644 src/vs/base/browser/ui/grid/gridview.ts create mode 100644 src/vs/base/browser/ui/mouseCursor/mouseCursor.css create mode 100644 src/vs/base/browser/ui/mouseCursor/mouseCursor.ts create mode 100644 src/vs/base/browser/ui/progressbar/progressAccessibilitySignal.ts create mode 100644 src/vs/base/browser/ui/progressbar/progressbar.css create mode 100644 src/vs/base/browser/ui/progressbar/progressbar.ts create mode 100644 src/vs/base/browser/ui/resizable/resizable.ts create mode 100644 src/vs/base/browser/ui/sash/sash.css create mode 100644 src/vs/base/browser/ui/sash/sash.ts create mode 100644 src/vs/base/browser/ui/scrollbar/abstractScrollbar.ts create mode 100644 src/vs/base/browser/ui/scrollbar/horizontalScrollbar.ts create mode 100644 src/vs/base/browser/ui/scrollbar/media/scrollbars.css create mode 100644 src/vs/base/browser/ui/scrollbar/scrollableElement.ts create mode 100644 src/vs/base/browser/ui/scrollbar/scrollableElementOptions.ts create mode 100644 src/vs/base/browser/ui/scrollbar/scrollbarArrow.ts create mode 100644 src/vs/base/browser/ui/scrollbar/scrollbarState.ts create mode 100644 src/vs/base/browser/ui/scrollbar/scrollbarVisibilityController.ts create mode 100644 src/vs/base/browser/ui/scrollbar/verticalScrollbar.ts create mode 100644 src/vs/base/browser/ui/splitview/paneview.css create mode 100644 src/vs/base/browser/ui/splitview/paneview.ts create mode 100644 src/vs/base/browser/ui/splitview/splitview.css create mode 100644 src/vs/base/browser/ui/splitview/splitview.ts create mode 100644 src/vs/base/browser/ui/widget.ts create mode 100644 src/vs/base/browser/window.ts create mode 100644 src/vs/base/common/actions.ts create mode 100644 src/vs/base/common/amd.ts create mode 100644 src/vs/base/common/arrays.ts create mode 100644 src/vs/base/common/arraysFind.ts create mode 100644 src/vs/base/common/assert.ts create mode 100644 src/vs/base/common/async.ts create mode 100644 src/vs/base/common/buffer.ts create mode 100644 src/vs/base/common/cache.ts create mode 100644 src/vs/base/common/cancellation.ts create mode 100644 src/vs/base/common/charCode.ts create mode 100644 src/vs/base/common/codiconsUtil.ts create mode 100644 src/vs/base/common/collections.ts create mode 100644 src/vs/base/common/color.ts create mode 100644 src/vs/base/common/comparers.ts create mode 100644 src/vs/base/common/controlFlow.ts create mode 100644 src/vs/base/common/date.ts create mode 100644 src/vs/base/common/decorators.ts create mode 100644 src/vs/base/common/desktopEnvironmentInfo.ts create mode 100644 src/vs/base/common/equals.ts create mode 100644 src/vs/base/common/errorMessage.ts create mode 100644 src/vs/base/common/errors.ts create mode 100644 src/vs/base/common/event.ts create mode 100644 src/vs/base/common/extpath.ts create mode 100644 src/vs/base/common/functional.ts create mode 100644 src/vs/base/common/glob.ts create mode 100644 src/vs/base/common/hash.ts create mode 100644 src/vs/base/common/hierarchicalKind.ts create mode 100644 src/vs/base/common/history.ts create mode 100644 src/vs/base/common/hotReload.ts create mode 100644 src/vs/base/common/hotReloadHelpers.ts create mode 100644 src/vs/base/common/idGenerator.ts create mode 100644 src/vs/base/common/ime.ts create mode 100644 src/vs/base/common/iterator.ts create mode 100644 src/vs/base/common/json.ts create mode 100644 src/vs/base/common/jsonEdit.ts create mode 100644 src/vs/base/common/jsonErrorMessages.ts create mode 100644 src/vs/base/common/jsonFormatter.ts create mode 100644 src/vs/base/common/jsonSchema.ts create mode 100644 src/vs/base/common/jsonc.d.ts create mode 100644 src/vs/base/common/jsonc.js create mode 100644 src/vs/base/common/keyCodes.ts create mode 100644 src/vs/base/common/keybindingLabels.ts create mode 100644 src/vs/base/common/keybindingParser.ts create mode 100644 src/vs/base/common/keybindings.ts create mode 100644 src/vs/base/common/lazy.ts create mode 100644 src/vs/base/common/lifecycle.ts create mode 100644 src/vs/base/common/linkedList.ts create mode 100644 src/vs/base/common/linkedText.ts create mode 100644 src/vs/base/common/map.ts create mode 100644 src/vs/base/common/mime.ts create mode 100644 src/vs/base/common/navigator.ts create mode 100644 src/vs/base/common/network.ts create mode 100644 src/vs/base/common/numbers.ts create mode 100644 src/vs/base/common/objects.ts create mode 100644 src/vs/base/common/observable.ts create mode 100644 src/vs/base/common/observableInternal/api.ts create mode 100644 src/vs/base/common/observableInternal/autorun.ts create mode 100644 src/vs/base/common/observableInternal/base.ts create mode 100644 src/vs/base/common/observableInternal/debugName.ts create mode 100644 src/vs/base/common/observableInternal/derived.ts create mode 100644 src/vs/base/common/observableInternal/lazyObservableValue.ts create mode 100644 src/vs/base/common/observableInternal/logging.ts create mode 100644 src/vs/base/common/observableInternal/promise.ts create mode 100644 src/vs/base/common/observableInternal/utils.ts create mode 100644 src/vs/base/common/paging.ts create mode 100644 src/vs/base/common/parsers.ts create mode 100644 src/vs/base/common/path.ts create mode 100644 src/vs/base/common/performance.d.ts create mode 100644 src/vs/base/common/performance.js create mode 100644 src/vs/base/common/platform.ts create mode 100644 src/vs/base/common/ports.ts create mode 100644 src/vs/base/common/prefixTree.ts create mode 100644 src/vs/base/common/process.ts create mode 100644 src/vs/base/common/processes.ts create mode 100644 src/vs/base/common/product.ts create mode 100644 src/vs/base/common/range.ts create mode 100644 src/vs/base/common/scrollable.ts create mode 100644 src/vs/base/common/search.ts create mode 100644 src/vs/base/common/sequence.ts create mode 100644 src/vs/base/common/severity.ts create mode 100644 src/vs/base/common/skipList.ts create mode 100644 src/vs/base/common/stopwatch.ts create mode 100644 src/vs/base/common/stream.ts create mode 100644 src/vs/base/common/strings.ts create mode 100644 src/vs/base/common/symbols.ts create mode 100644 src/vs/base/common/tfIdf.ts create mode 100644 src/vs/base/common/types.ts create mode 100644 src/vs/base/common/uint.ts create mode 100644 src/vs/base/common/uuid.ts create mode 100644 src/vs/base/common/verifier.ts diff --git a/.eslintrc.json b/.eslintrc.json index 345188c8eb..e9fdce694c 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -11,6 +11,7 @@ "src/browser/tsconfig.json", "src/common/tsconfig.json", "src/headless/tsconfig.json", + "src/vs/tsconfig.json", "test/benchmark/tsconfig.json", "test/playwright/tsconfig.json", "addons/addon-attach/src/tsconfig.json", diff --git a/.vscode/settings.json b/.vscode/settings.json index b6926a32f1..4eb4e6dc69 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -4,9 +4,9 @@ }, // Hide output files from the file explorer, comment this out to see the build output "files.exclude": { - "**/lib": true, - "**/out": true, - "**/out-*": true, + // "**/lib": true, + // "**/out": true, + // "**/out-*": true, }, "typescript.preferences.importModuleSpecifier": "non-relative", "typescript.preferences.quoteStyle": "single", diff --git a/bin/esbuild.mjs b/bin/esbuild.mjs index 291ad4067a..9aaac06eab 100644 --- a/bin/esbuild.mjs +++ b/bin/esbuild.mjs @@ -29,6 +29,7 @@ const commonOptions = { /** @type {esbuild.BuildOptions} */ const devOptions = { minify: false, + treeShaking: true, }; /** @type {esbuild.BuildOptions} */ @@ -174,9 +175,9 @@ if (config.addon) { entryPoints: [ `src/browser/public/Terminal.ts`, `src/headless/public/Terminal.ts`, - `src/browser/*.test.ts`, - `src/common/*.test.ts`, - `src/headless/*.test.ts` + `src/browser/**/*.test.ts`, + `src/common/**/*.test.ts`, + `src/headless/**/*.test.ts` ], outdir: 'out-esbuild/' }; @@ -187,6 +188,8 @@ if (config.addon) { }; } +console.log('Building bundle with config:', JSON.stringify(bundleConfig, undefined, 2)); + if (config.isWatch) { context(bundleConfig).then(e => e.watch()); if (!skipOut) { diff --git a/src/vs/base/browser/broadcast.ts b/src/vs/base/browser/broadcast.ts new file mode 100644 index 0000000000..53c921fdb2 --- /dev/null +++ b/src/vs/base/browser/broadcast.ts @@ -0,0 +1,70 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { mainWindow } from 'vs/base/browser/window'; +import { getErrorMessage } from 'vs/base/common/errors'; +import { Emitter } from 'vs/base/common/event'; +import { Disposable, toDisposable } from 'vs/base/common/lifecycle'; + +export class BroadcastDataChannel extends Disposable { + + private broadcastChannel: BroadcastChannel | undefined; + + private readonly _onDidReceiveData = this._register(new Emitter()); + readonly onDidReceiveData = this._onDidReceiveData.event; + + constructor(private readonly channelName: string) { + super(); + + // Use BroadcastChannel + if ('BroadcastChannel' in mainWindow) { + try { + this.broadcastChannel = new BroadcastChannel(channelName); + const listener = (event: MessageEvent) => { + this._onDidReceiveData.fire(event.data); + }; + this.broadcastChannel.addEventListener('message', listener); + this._register(toDisposable(() => { + if (this.broadcastChannel) { + this.broadcastChannel.removeEventListener('message', listener); + this.broadcastChannel.close(); + } + })); + } catch (error) { + console.warn('Error while creating broadcast channel. Falling back to localStorage.', getErrorMessage(error)); + } + } + + // BroadcastChannel is not supported. Use storage. + if (!this.broadcastChannel) { + this.channelName = `BroadcastDataChannel.${channelName}`; + this.createBroadcastChannel(); + } + } + + private createBroadcastChannel(): void { + const listener = (event: StorageEvent) => { + if (event.key === this.channelName && event.newValue) { + this._onDidReceiveData.fire(JSON.parse(event.newValue)); + } + }; + mainWindow.addEventListener('storage', listener); + this._register(toDisposable(() => mainWindow.removeEventListener('storage', listener))); + } + + /** + * Sends the data to other BroadcastChannel objects set up for this channel. Data can be structured objects, e.g. nested objects and arrays. + * @param data data to broadcast + */ + postData(data: T): void { + if (this.broadcastChannel) { + this.broadcastChannel.postMessage(data); + } else { + // remove previous changes so that event is triggered even if new changes are same as old changes + localStorage.removeItem(this.channelName); + localStorage.setItem(this.channelName, JSON.stringify(data)); + } + } +} diff --git a/src/vs/base/browser/browser.ts b/src/vs/base/browser/browser.ts new file mode 100644 index 0000000000..87db1c573e --- /dev/null +++ b/src/vs/base/browser/browser.ts @@ -0,0 +1,141 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CodeWindow, mainWindow } from 'vs/base/browser/window'; +import { Emitter } from 'vs/base/common/event'; + +class WindowManager { + + static readonly INSTANCE = new WindowManager(); + + // --- Zoom Level + + private readonly mapWindowIdToZoomLevel = new Map(); + + private readonly _onDidChangeZoomLevel = new Emitter(); + readonly onDidChangeZoomLevel = this._onDidChangeZoomLevel.event; + + getZoomLevel(targetWindow: Window): number { + return this.mapWindowIdToZoomLevel.get(this.getWindowId(targetWindow)) ?? 0; + } + setZoomLevel(zoomLevel: number, targetWindow: Window): void { + if (this.getZoomLevel(targetWindow) === zoomLevel) { + return; + } + + const targetWindowId = this.getWindowId(targetWindow); + this.mapWindowIdToZoomLevel.set(targetWindowId, zoomLevel); + this._onDidChangeZoomLevel.fire(targetWindowId); + } + + // --- Zoom Factor + + private readonly mapWindowIdToZoomFactor = new Map(); + + getZoomFactor(targetWindow: Window): number { + return this.mapWindowIdToZoomFactor.get(this.getWindowId(targetWindow)) ?? 1; + } + setZoomFactor(zoomFactor: number, targetWindow: Window): void { + this.mapWindowIdToZoomFactor.set(this.getWindowId(targetWindow), zoomFactor); + } + + // --- Fullscreen + + private readonly _onDidChangeFullscreen = new Emitter(); + readonly onDidChangeFullscreen = this._onDidChangeFullscreen.event; + + private readonly mapWindowIdToFullScreen = new Map(); + + setFullscreen(fullscreen: boolean, targetWindow: Window): void { + if (this.isFullscreen(targetWindow) === fullscreen) { + return; + } + + const windowId = this.getWindowId(targetWindow); + this.mapWindowIdToFullScreen.set(windowId, fullscreen); + this._onDidChangeFullscreen.fire(windowId); + } + isFullscreen(targetWindow: Window): boolean { + return !!this.mapWindowIdToFullScreen.get(this.getWindowId(targetWindow)); + } + + private getWindowId(targetWindow: Window): number { + return (targetWindow as CodeWindow).vscodeWindowId; + } +} + +export function addMatchMediaChangeListener(targetWindow: Window, query: string | MediaQueryList, callback: (this: MediaQueryList, ev: MediaQueryListEvent) => any): void { + if (typeof query === 'string') { + query = targetWindow.matchMedia(query); + } + query.addEventListener('change', callback); +} + +/** A zoom index, e.g. 1, 2, 3 */ +export function setZoomLevel(zoomLevel: number, targetWindow: Window): void { + WindowManager.INSTANCE.setZoomLevel(zoomLevel, targetWindow); +} +export function getZoomLevel(targetWindow: Window): number { + return WindowManager.INSTANCE.getZoomLevel(targetWindow); +} +export const onDidChangeZoomLevel = WindowManager.INSTANCE.onDidChangeZoomLevel; + +/** The zoom scale for an index, e.g. 1, 1.2, 1.4 */ +export function getZoomFactor(targetWindow: Window): number { + return WindowManager.INSTANCE.getZoomFactor(targetWindow); +} +export function setZoomFactor(zoomFactor: number, targetWindow: Window): void { + WindowManager.INSTANCE.setZoomFactor(zoomFactor, targetWindow); +} + +export function setFullscreen(fullscreen: boolean, targetWindow: Window): void { + WindowManager.INSTANCE.setFullscreen(fullscreen, targetWindow); +} +export function isFullscreen(targetWindow: Window): boolean { + return WindowManager.INSTANCE.isFullscreen(targetWindow); +} +export const onDidChangeFullscreen = WindowManager.INSTANCE.onDidChangeFullscreen; + +const userAgent = navigator.userAgent; + +export const isFirefox = (userAgent.indexOf('Firefox') >= 0); +export const isWebKit = (userAgent.indexOf('AppleWebKit') >= 0); +export const isChrome = (userAgent.indexOf('Chrome') >= 0); +export const isSafari = (!isChrome && (userAgent.indexOf('Safari') >= 0)); +export const isWebkitWebView = (!isChrome && !isSafari && isWebKit); +export const isElectron = (userAgent.indexOf('Electron/') >= 0); +export const isAndroid = (userAgent.indexOf('Android') >= 0); + +let standalone = false; +if (typeof mainWindow.matchMedia === 'function') { + const standaloneMatchMedia = mainWindow.matchMedia('(display-mode: standalone) or (display-mode: window-controls-overlay)'); + const fullScreenMatchMedia = mainWindow.matchMedia('(display-mode: fullscreen)'); + standalone = standaloneMatchMedia.matches; + addMatchMediaChangeListener(mainWindow, standaloneMatchMedia, ({ matches }) => { + // entering fullscreen would change standaloneMatchMedia.matches to false + // if standalone is true (running as PWA) and entering fullscreen, skip this change + if (standalone && fullScreenMatchMedia.matches) { + return; + } + // otherwise update standalone (browser to PWA or PWA to browser) + standalone = matches; + }); +} +export function isStandalone(): boolean { + return standalone; +} + +// Visible means that the feature is enabled, not necessarily being rendered +// e.g. visible is true even in fullscreen mode where the controls are hidden +// See docs at https://developer.mozilla.org/en-US/docs/Web/API/WindowControlsOverlay/visible +export function isWCOEnabled(): boolean { + return (navigator as any)?.windowControlsOverlay?.visible; +} + +// Returns the bounding rect of the titlebar area if it is supported and defined +// See docs at https://developer.mozilla.org/en-US/docs/Web/API/WindowControlsOverlay/getTitlebarAreaRect +export function getWCOBoundingRect(): DOMRect | undefined { + return (navigator as any)?.windowControlsOverlay?.getTitlebarAreaRect(); +} diff --git a/src/vs/base/browser/canIUse.ts b/src/vs/base/browser/canIUse.ts new file mode 100644 index 0000000000..60261a974d --- /dev/null +++ b/src/vs/base/browser/canIUse.ts @@ -0,0 +1,47 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as browser from 'vs/base/browser/browser'; +import { mainWindow } from 'vs/base/browser/window'; +import * as platform from 'vs/base/common/platform'; + +export const enum KeyboardSupport { + Always, + FullScreen, + None +} + +/** + * Browser feature we can support in current platform, browser and environment. + */ +export const BrowserFeatures = { + clipboard: { + writeText: ( + platform.isNative + || (document.queryCommandSupported && document.queryCommandSupported('copy')) + || !!(navigator && navigator.clipboard && navigator.clipboard.writeText) + ), + readText: ( + platform.isNative + || !!(navigator && navigator.clipboard && navigator.clipboard.readText) + ) + }, + keyboard: (() => { + if (platform.isNative || browser.isStandalone()) { + return KeyboardSupport.Always; + } + + if ((navigator).keyboard || browser.isSafari) { + return KeyboardSupport.FullScreen; + } + + return KeyboardSupport.None; + })(), + + // 'ontouchstart' in window always evaluates to true with typescript's modern typings. This causes `window` to be + // `never` later in `window.navigator`. That's why we need the explicit `window as Window` cast + touch: 'ontouchstart' in mainWindow || navigator.maxTouchPoints > 0, + pointerEvents: mainWindow.PointerEvent && ('ontouchstart' in mainWindow || navigator.maxTouchPoints > 0) +}; diff --git a/src/vs/base/browser/defaultWorkerFactory.ts b/src/vs/base/browser/defaultWorkerFactory.ts new file mode 100644 index 0000000000..834fa4d3c1 --- /dev/null +++ b/src/vs/base/browser/defaultWorkerFactory.ts @@ -0,0 +1,174 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { createTrustedTypesPolicy } from 'vs/base/browser/trustedTypes'; +import { onUnexpectedError } from 'vs/base/common/errors'; +import { COI } from 'vs/base/common/network'; +import { IWorker, IWorkerCallback, IWorkerFactory, logOnceWebWorkerWarning } from 'vs/base/common/worker/simpleWorker'; +import { Disposable, toDisposable } from 'vs/base/common/lifecycle'; + +const ttPolicy = createTrustedTypesPolicy('defaultWorkerFactory', { createScriptURL: value => value }); + +export function createBlobWorker(blobUrl: string, options?: WorkerOptions): Worker { + if (!blobUrl.startsWith('blob:')) { + throw new URIError('Not a blob-url: ' + blobUrl); + } + return new Worker(ttPolicy ? ttPolicy.createScriptURL(blobUrl) as unknown as string : blobUrl, options); +} + +function getWorker(label: string): Worker | Promise { + // Option for hosts to overwrite the worker script (used in the standalone editor) + interface IMonacoEnvironment { + getWorker?(moduleId: string, label: string): Worker | Promise; + getWorkerUrl?(moduleId: string, label: string): string; + } + const monacoEnvironment: IMonacoEnvironment | undefined = (globalThis as any).MonacoEnvironment; + if (monacoEnvironment) { + if (typeof monacoEnvironment.getWorker === 'function') { + return monacoEnvironment.getWorker('workerMain.js', label); + } + if (typeof monacoEnvironment.getWorkerUrl === 'function') { + const workerUrl = monacoEnvironment.getWorkerUrl('workerMain.js', label); + return new Worker(ttPolicy ? ttPolicy.createScriptURL(workerUrl) as unknown as string : workerUrl, { name: label }); + } + } + // ESM-comment-begin + if (typeof require === 'function') { + // check if the JS lives on a different origin + const workerMain = require.toUrl('vs/base/worker/workerMain.js'); // explicitly using require.toUrl(), see https://github.com/microsoft/vscode/issues/107440#issuecomment-698982321 + const workerUrl = getWorkerBootstrapUrl(workerMain, label); + return new Worker(ttPolicy ? ttPolicy.createScriptURL(workerUrl) as unknown as string : workerUrl, { name: label }); + } + // ESM-comment-end + throw new Error(`You must define a function MonacoEnvironment.getWorkerUrl or MonacoEnvironment.getWorker`); +} + +// ESM-comment-begin +export function getWorkerBootstrapUrl(scriptPath: string, label: string): string { + if (/^((http:)|(https:)|(file:))/.test(scriptPath) && scriptPath.substring(0, globalThis.origin.length) !== globalThis.origin) { + // this is the cross-origin case + // i.e. the webpage is running at a different origin than where the scripts are loaded from + } else { + const start = scriptPath.lastIndexOf('?'); + const end = scriptPath.lastIndexOf('#', start); + const params = start > 0 + ? new URLSearchParams(scriptPath.substring(start + 1, ~end ? end : undefined)) + : new URLSearchParams(); + + COI.addSearchParam(params, true, true); + const search = params.toString(); + if (!search) { + scriptPath = `${scriptPath}#${label}`; + } else { + scriptPath = `${scriptPath}?${params.toString()}#${label}`; + } + } + + const factoryModuleId = 'vs/base/worker/defaultWorkerFactory.js'; + const workerBaseUrl = require.toUrl(factoryModuleId).slice(0, -factoryModuleId.length); // explicitly using require.toUrl(), see https://github.com/microsoft/vscode/issues/107440#issuecomment-698982321 + const blob = new Blob([[ + `/*${label}*/`, + `globalThis.MonacoEnvironment = { baseUrl: '${workerBaseUrl}' };`, + // VSCODE_GLOBALS: NLS + `globalThis._VSCODE_NLS_MESSAGES = ${JSON.stringify(globalThis._VSCODE_NLS_MESSAGES)};`, + `globalThis._VSCODE_NLS_LANGUAGE = ${JSON.stringify(globalThis._VSCODE_NLS_LANGUAGE)};`, + `const ttPolicy = globalThis.trustedTypes?.createPolicy('defaultWorkerFactory', { createScriptURL: value => value });`, + `importScripts(ttPolicy?.createScriptURL('${scriptPath}') ?? '${scriptPath}');`, + `/*${label}*/` + ].join('')], { type: 'application/javascript' }); + return URL.createObjectURL(blob); +} +// ESM-comment-end + +function isPromiseLike(obj: any): obj is PromiseLike { + if (typeof obj.then === 'function') { + return true; + } + return false; +} + +/** + * A worker that uses HTML5 web workers so that is has + * its own global scope and its own thread. + */ +class WebWorker extends Disposable implements IWorker { + + private readonly id: number; + private readonly label: string; + private worker: Promise | null; + + constructor(moduleId: string, id: number, label: string, onMessageCallback: IWorkerCallback, onErrorCallback: (err: any) => void) { + super(); + this.id = id; + this.label = label; + const workerOrPromise = getWorker(label); + if (isPromiseLike(workerOrPromise)) { + this.worker = workerOrPromise; + } else { + this.worker = Promise.resolve(workerOrPromise); + } + this.postMessage(moduleId, []); + this.worker.then((w) => { + w.onmessage = function (ev) { + onMessageCallback(ev.data); + }; + w.onmessageerror = onErrorCallback; + if (typeof w.addEventListener === 'function') { + w.addEventListener('error', onErrorCallback); + } + }); + this._register(toDisposable(() => { + this.worker?.then(w => { + w.onmessage = null; + w.onmessageerror = null; + w.removeEventListener('error', onErrorCallback); + w.terminate(); + }); + this.worker = null; + })); + } + + public getId(): number { + return this.id; + } + + public postMessage(message: any, transfer: Transferable[]): void { + this.worker?.then(w => { + try { + w.postMessage(message, transfer); + } catch (err) { + onUnexpectedError(err); + onUnexpectedError(new Error(`FAILED to post message to '${this.label}'-worker`, { cause: err })); + } + }); + } +} + +export class DefaultWorkerFactory implements IWorkerFactory { + + private static LAST_WORKER_ID = 0; + + private _label: string | undefined; + private _webWorkerFailedBeforeError: any; + + constructor(label: string | undefined) { + this._label = label; + this._webWorkerFailedBeforeError = false; + } + + public create(moduleId: string, onMessageCallback: IWorkerCallback, onErrorCallback: (err: any) => void): IWorker { + const workerId = (++DefaultWorkerFactory.LAST_WORKER_ID); + + if (this._webWorkerFailedBeforeError) { + throw this._webWorkerFailedBeforeError; + } + + return new WebWorker(moduleId, workerId, this._label || 'anonymous' + workerId, onMessageCallback, (err) => { + logOnceWebWorkerWarning(err); + this._webWorkerFailedBeforeError = err; + onErrorCallback(err); + }); + } +} diff --git a/src/vs/base/browser/deviceAccess.ts b/src/vs/base/browser/deviceAccess.ts new file mode 100644 index 0000000000..17cb1beb02 --- /dev/null +++ b/src/vs/base/browser/deviceAccess.ts @@ -0,0 +1,108 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// https://wicg.github.io/webusb/ + +export interface UsbDeviceData { + readonly deviceClass: number; + readonly deviceProtocol: number; + readonly deviceSubclass: number; + readonly deviceVersionMajor: number; + readonly deviceVersionMinor: number; + readonly deviceVersionSubminor: number; + readonly manufacturerName?: string; + readonly productId: number; + readonly productName?: string; + readonly serialNumber?: string; + readonly usbVersionMajor: number; + readonly usbVersionMinor: number; + readonly usbVersionSubminor: number; + readonly vendorId: number; +} + +export async function requestUsbDevice(options?: { filters?: unknown[] }): Promise { + const usb = (navigator as any).usb; + if (!usb) { + return undefined; + } + + const device = await usb.requestDevice({ filters: options?.filters ?? [] }); + if (!device) { + return undefined; + } + + return { + deviceClass: device.deviceClass, + deviceProtocol: device.deviceProtocol, + deviceSubclass: device.deviceSubclass, + deviceVersionMajor: device.deviceVersionMajor, + deviceVersionMinor: device.deviceVersionMinor, + deviceVersionSubminor: device.deviceVersionSubminor, + manufacturerName: device.manufacturerName, + productId: device.productId, + productName: device.productName, + serialNumber: device.serialNumber, + usbVersionMajor: device.usbVersionMajor, + usbVersionMinor: device.usbVersionMinor, + usbVersionSubminor: device.usbVersionSubminor, + vendorId: device.vendorId, + }; +} + +// https://wicg.github.io/serial/ + +export interface SerialPortData { + readonly usbVendorId?: number | undefined; + readonly usbProductId?: number | undefined; +} + +export async function requestSerialPort(options?: { filters?: unknown[] }): Promise { + const serial = (navigator as any).serial; + if (!serial) { + return undefined; + } + + const port = await serial.requestPort({ filters: options?.filters ?? [] }); + if (!port) { + return undefined; + } + + const info = port.getInfo(); + return { + usbVendorId: info.usbVendorId, + usbProductId: info.usbProductId + }; +} + +// https://wicg.github.io/webhid/ + +export interface HidDeviceData { + readonly opened: boolean; + readonly vendorId: number; + readonly productId: number; + readonly productName: string; + readonly collections: []; +} + +export async function requestHidDevice(options?: { filters?: unknown[] }): Promise { + const hid = (navigator as any).hid; + if (!hid) { + return undefined; + } + + const devices = await hid.requestDevice({ filters: options?.filters ?? [] }); + if (!devices.length) { + return undefined; + } + + const device = devices[0]; + return { + opened: device.opened, + vendorId: device.vendorId, + productId: device.productId, + productName: device.productName, + collections: device.collections + }; +} diff --git a/src/vs/base/browser/dnd.ts b/src/vs/base/browser/dnd.ts new file mode 100644 index 0000000000..96259a4e63 --- /dev/null +++ b/src/vs/base/browser/dnd.ts @@ -0,0 +1,110 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { addDisposableListener, getWindow } from 'vs/base/browser/dom'; +import { Disposable } from 'vs/base/common/lifecycle'; +import { Mimes } from 'vs/base/common/mime'; + +/** + * A helper that will execute a provided function when the provided HTMLElement receives + * dragover event for 800ms. If the drag is aborted before, the callback will not be triggered. + */ +export class DelayedDragHandler extends Disposable { + private timeout: any; + + constructor(container: HTMLElement, callback: () => void) { + super(); + + this._register(addDisposableListener(container, 'dragover', e => { + e.preventDefault(); // needed so that the drop event fires (https://stackoverflow.com/questions/21339924/drop-event-not-firing-in-chrome) + + if (!this.timeout) { + this.timeout = setTimeout(() => { + callback(); + + this.timeout = null; + }, 800); + } + })); + + ['dragleave', 'drop', 'dragend'].forEach(type => { + this._register(addDisposableListener(container, type, () => { + this.clearDragTimeout(); + })); + }); + } + + private clearDragTimeout(): void { + if (this.timeout) { + clearTimeout(this.timeout); + this.timeout = null; + } + } + + override dispose(): void { + super.dispose(); + + this.clearDragTimeout(); + } +} + +// Common data transfers +export const DataTransfers = { + + /** + * Application specific resource transfer type + */ + RESOURCES: 'ResourceURLs', + + /** + * Browser specific transfer type to download + */ + DOWNLOAD_URL: 'DownloadURL', + + /** + * Browser specific transfer type for files + */ + FILES: 'Files', + + /** + * Typically transfer type for copy/paste transfers. + */ + TEXT: Mimes.text, + + /** + * Internal type used to pass around text/uri-list data. + * + * This is needed to work around https://bugs.chromium.org/p/chromium/issues/detail?id=239745. + */ + INTERNAL_URI_LIST: 'application/vnd.code.uri-list', +}; + +export function applyDragImage(event: DragEvent, label: string | null, clazz: string, backgroundColor?: string | null, foregroundColor?: string | null): void { + const dragImage = document.createElement('div'); + dragImage.className = clazz; + dragImage.textContent = label; + + if (foregroundColor) { + dragImage.style.color = foregroundColor; + } + + if (backgroundColor) { + dragImage.style.background = backgroundColor; + } + + if (event.dataTransfer) { + const ownerDocument = getWindow(event).document; + ownerDocument.body.appendChild(dragImage); + event.dataTransfer.setDragImage(dragImage, -10, -10); + + // Removes the element when the DND operation is done + setTimeout(() => dragImage.remove(), 0); + } +} + +export interface IDragAndDropData { + update(dataTransfer: DataTransfer): void; + getData(): unknown; +} diff --git a/src/vs/base/browser/dom.ts b/src/vs/base/browser/dom.ts new file mode 100644 index 0000000000..eb6d73c32c --- /dev/null +++ b/src/vs/base/browser/dom.ts @@ -0,0 +1,2369 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as browser from 'vs/base/browser/browser'; +import { BrowserFeatures } from 'vs/base/browser/canIUse'; +import { IKeyboardEvent, StandardKeyboardEvent } from 'vs/base/browser/keyboardEvent'; +import { IMouseEvent, StandardMouseEvent } from 'vs/base/browser/mouseEvent'; +import { AbstractIdleValue, IntervalTimer, TimeoutTimer, _runWhenIdle, IdleDeadline } from 'vs/base/common/async'; +import { onUnexpectedError } from 'vs/base/common/errors'; +import * as event from 'vs/base/common/event'; +import { KeyCode } from 'vs/base/common/keyCodes'; +import { Disposable, DisposableStore, IDisposable, toDisposable } from 'vs/base/common/lifecycle'; +import * as platform from 'vs/base/common/platform'; +import { hash } from 'vs/base/common/hash'; +import { CodeWindow, ensureCodeWindow, mainWindow } from 'vs/base/browser/window'; +import { isPointWithinTriangle } from 'vs/base/common/numbers'; + +export interface IRegisteredCodeWindow { + readonly window: CodeWindow; + readonly disposables: DisposableStore; +} + +//# region Multi-Window Support Utilities + +export const { + registerWindow, + getWindow, + getDocument, + getWindows, + getWindowsCount, + getWindowId, + getWindowById, + hasWindow, + onDidRegisterWindow, + onWillUnregisterWindow, + onDidUnregisterWindow +} = (function () { + const windows = new Map(); + + ensureCodeWindow(mainWindow, 1); + const mainWindowRegistration = { window: mainWindow, disposables: new DisposableStore() }; + windows.set(mainWindow.vscodeWindowId, mainWindowRegistration); + + const onDidRegisterWindow = new event.Emitter(); + const onDidUnregisterWindow = new event.Emitter(); + const onWillUnregisterWindow = new event.Emitter(); + + function getWindowById(windowId: number): IRegisteredCodeWindow | undefined; + function getWindowById(windowId: number | undefined, fallbackToMain: true): IRegisteredCodeWindow; + function getWindowById(windowId: number | undefined, fallbackToMain?: boolean): IRegisteredCodeWindow | undefined { + const window = typeof windowId === 'number' ? windows.get(windowId) : undefined; + + return window ?? (fallbackToMain ? mainWindowRegistration : undefined); + } + + return { + onDidRegisterWindow: onDidRegisterWindow.event, + onWillUnregisterWindow: onWillUnregisterWindow.event, + onDidUnregisterWindow: onDidUnregisterWindow.event, + registerWindow(window: CodeWindow): IDisposable { + if (windows.has(window.vscodeWindowId)) { + return Disposable.None; + } + + const disposables = new DisposableStore(); + + const registeredWindow = { + window, + disposables: disposables.add(new DisposableStore()) + }; + windows.set(window.vscodeWindowId, registeredWindow); + + disposables.add(toDisposable(() => { + windows.delete(window.vscodeWindowId); + onDidUnregisterWindow.fire(window); + })); + + disposables.add(addDisposableListener(window, EventType.BEFORE_UNLOAD, () => { + onWillUnregisterWindow.fire(window); + })); + + onDidRegisterWindow.fire(registeredWindow); + + return disposables; + }, + getWindows(): Iterable { + return windows.values(); + }, + getWindowsCount(): number { + return windows.size; + }, + getWindowId(targetWindow: Window): number { + return (targetWindow as CodeWindow).vscodeWindowId; + }, + hasWindow(windowId: number): boolean { + return windows.has(windowId); + }, + getWindowById, + getWindow(e: Node | UIEvent | undefined | null): CodeWindow { + const candidateNode = e as Node | undefined | null; + if (candidateNode?.ownerDocument?.defaultView) { + return candidateNode.ownerDocument.defaultView.window as CodeWindow; + } + + const candidateEvent = e as UIEvent | undefined | null; + if (candidateEvent?.view) { + return candidateEvent.view.window as CodeWindow; + } + + return mainWindow; + }, + getDocument(e: Node | UIEvent | undefined | null): Document { + const candidateNode = e as Node | undefined | null; + return getWindow(candidateNode).document; + } + }; +})(); + +//#endregion + +export function clearNode(node: HTMLElement): void { + while (node.firstChild) { + node.firstChild.remove(); + } +} + + +export function clearNodeRecursively(domNode: ChildNode) { + while (domNode.firstChild) { + const element = domNode.firstChild; + element.remove(); + clearNodeRecursively(element); + } +} + + +class DomListener implements IDisposable { + + private _handler: (e: any) => void; + private _node: EventTarget; + private readonly _type: string; + private readonly _options: boolean | AddEventListenerOptions; + + constructor(node: EventTarget, type: string, handler: (e: any) => void, options?: boolean | AddEventListenerOptions) { + this._node = node; + this._type = type; + this._handler = handler; + this._options = (options || false); + this._node.addEventListener(this._type, this._handler, this._options); + } + + dispose(): void { + if (!this._handler) { + // Already disposed + return; + } + + this._node.removeEventListener(this._type, this._handler, this._options); + + // Prevent leakers from holding on to the dom or handler func + this._node = null!; + this._handler = null!; + } +} + +export function addDisposableListener(node: EventTarget, type: K, handler: (event: GlobalEventHandlersEventMap[K]) => void, useCapture?: boolean): IDisposable; +export function addDisposableListener(node: EventTarget, type: string, handler: (event: any) => void, useCapture?: boolean): IDisposable; +export function addDisposableListener(node: EventTarget, type: string, handler: (event: any) => void, options: AddEventListenerOptions): IDisposable; +export function addDisposableListener(node: EventTarget, type: string, handler: (event: any) => void, useCaptureOrOptions?: boolean | AddEventListenerOptions): IDisposable { + return new DomListener(node, type, handler, useCaptureOrOptions); +} + +export interface IAddStandardDisposableListenerSignature { + (node: HTMLElement, type: 'click', handler: (event: IMouseEvent) => void, useCapture?: boolean): IDisposable; + (node: HTMLElement, type: 'mousedown', handler: (event: IMouseEvent) => void, useCapture?: boolean): IDisposable; + (node: HTMLElement, type: 'keydown', handler: (event: IKeyboardEvent) => void, useCapture?: boolean): IDisposable; + (node: HTMLElement, type: 'keypress', handler: (event: IKeyboardEvent) => void, useCapture?: boolean): IDisposable; + (node: HTMLElement, type: 'keyup', handler: (event: IKeyboardEvent) => void, useCapture?: boolean): IDisposable; + (node: HTMLElement, type: 'pointerdown', handler: (event: PointerEvent) => void, useCapture?: boolean): IDisposable; + (node: HTMLElement, type: 'pointermove', handler: (event: PointerEvent) => void, useCapture?: boolean): IDisposable; + (node: HTMLElement, type: 'pointerup', handler: (event: PointerEvent) => void, useCapture?: boolean): IDisposable; + (node: HTMLElement, type: string, handler: (event: any) => void, useCapture?: boolean): IDisposable; +} +function _wrapAsStandardMouseEvent(targetWindow: Window, handler: (e: IMouseEvent) => void): (e: MouseEvent) => void { + return function (e: MouseEvent) { + return handler(new StandardMouseEvent(targetWindow, e)); + }; +} +function _wrapAsStandardKeyboardEvent(handler: (e: IKeyboardEvent) => void): (e: KeyboardEvent) => void { + return function (e: KeyboardEvent) { + return handler(new StandardKeyboardEvent(e)); + }; +} +export const addStandardDisposableListener: IAddStandardDisposableListenerSignature = function addStandardDisposableListener(node: HTMLElement, type: string, handler: (event: any) => void, useCapture?: boolean): IDisposable { + let wrapHandler = handler; + + if (type === 'click' || type === 'mousedown' || type === 'contextmenu') { + wrapHandler = _wrapAsStandardMouseEvent(getWindow(node), handler); + } else if (type === 'keydown' || type === 'keypress' || type === 'keyup') { + wrapHandler = _wrapAsStandardKeyboardEvent(handler); + } + + return addDisposableListener(node, type, wrapHandler, useCapture); +}; + +export const addStandardDisposableGenericMouseDownListener = function addStandardDisposableListener(node: HTMLElement, handler: (event: any) => void, useCapture?: boolean): IDisposable { + const wrapHandler = _wrapAsStandardMouseEvent(getWindow(node), handler); + + return addDisposableGenericMouseDownListener(node, wrapHandler, useCapture); +}; + +export const addStandardDisposableGenericMouseUpListener = function addStandardDisposableListener(node: HTMLElement, handler: (event: any) => void, useCapture?: boolean): IDisposable { + const wrapHandler = _wrapAsStandardMouseEvent(getWindow(node), handler); + + return addDisposableGenericMouseUpListener(node, wrapHandler, useCapture); +}; +export function addDisposableGenericMouseDownListener(node: EventTarget, handler: (event: any) => void, useCapture?: boolean): IDisposable { + return addDisposableListener(node, platform.isIOS && BrowserFeatures.pointerEvents ? EventType.POINTER_DOWN : EventType.MOUSE_DOWN, handler, useCapture); +} + +export function addDisposableGenericMouseMoveListener(node: EventTarget, handler: (event: any) => void, useCapture?: boolean): IDisposable { + return addDisposableListener(node, platform.isIOS && BrowserFeatures.pointerEvents ? EventType.POINTER_MOVE : EventType.MOUSE_MOVE, handler, useCapture); +} + +export function addDisposableGenericMouseUpListener(node: EventTarget, handler: (event: any) => void, useCapture?: boolean): IDisposable { + return addDisposableListener(node, platform.isIOS && BrowserFeatures.pointerEvents ? EventType.POINTER_UP : EventType.MOUSE_UP, handler, useCapture); +} + +/** + * Execute the callback the next time the browser is idle, returning an + * {@link IDisposable} that will cancel the callback when disposed. This wraps + * [requestIdleCallback] so it will fallback to [setTimeout] if the environment + * doesn't support it. + * + * @param targetWindow The window for which to run the idle callback + * @param callback The callback to run when idle, this includes an + * [IdleDeadline] that provides the time alloted for the idle callback by the + * browser. Not respecting this deadline will result in a degraded user + * experience. + * @param timeout A timeout at which point to queue no longer wait for an idle + * callback but queue it on the regular event loop (like setTimeout). Typically + * this should not be used. + * + * [IdleDeadline]: https://developer.mozilla.org/en-US/docs/Web/API/IdleDeadline + * [requestIdleCallback]: https://developer.mozilla.org/en-US/docs/Web/API/Window/requestIdleCallback + * [setTimeout]: https://developer.mozilla.org/en-US/docs/Web/API/Window/setTimeout + */ +export function runWhenWindowIdle(targetWindow: Window | typeof globalThis, callback: (idle: IdleDeadline) => void, timeout?: number): IDisposable { + return _runWhenIdle(targetWindow, callback, timeout); +} + +/** + * An implementation of the "idle-until-urgent"-strategy as introduced + * here: https://philipwalton.com/articles/idle-until-urgent/ + */ +export class WindowIdleValue extends AbstractIdleValue { + constructor(targetWindow: Window | typeof globalThis, executor: () => T) { + super(targetWindow, executor); + } +} + +/** + * Schedule a callback to be run at the next animation frame. + * This allows multiple parties to register callbacks that should run at the next animation frame. + * If currently in an animation frame, `runner` will be executed immediately. + * @return token that can be used to cancel the scheduled runner (only if `runner` was not executed immediately). + */ +export let runAtThisOrScheduleAtNextAnimationFrame: (targetWindow: Window, runner: () => void, priority?: number) => IDisposable; +/** + * Schedule a callback to be run at the next animation frame. + * This allows multiple parties to register callbacks that should run at the next animation frame. + * If currently in an animation frame, `runner` will be executed at the next animation frame. + * @return token that can be used to cancel the scheduled runner. + */ +export let scheduleAtNextAnimationFrame: (targetWindow: Window, runner: () => void, priority?: number) => IDisposable; + +export function disposableWindowInterval(targetWindow: Window, handler: () => void | boolean /* stop interval */ | Promise, interval: number, iterations?: number): IDisposable { + let iteration = 0; + const timer = targetWindow.setInterval(() => { + iteration++; + if ((typeof iterations === 'number' && iteration >= iterations) || handler() === true) { + disposable.dispose(); + } + }, interval); + const disposable = toDisposable(() => { + targetWindow.clearInterval(timer); + }); + return disposable; +} + +export class WindowIntervalTimer extends IntervalTimer { + + private readonly defaultTarget?: Window & typeof globalThis; + + /** + * + * @param node The optional node from which the target window is determined + */ + constructor(node?: Node) { + super(); + this.defaultTarget = node && getWindow(node); + } + + override cancelAndSet(runner: () => void, interval: number, targetWindow?: Window & typeof globalThis): void { + return super.cancelAndSet(runner, interval, targetWindow ?? this.defaultTarget); + } +} + +class AnimationFrameQueueItem implements IDisposable { + + private _runner: () => void; + public priority: number; + private _canceled: boolean; + + constructor(runner: () => void, priority: number = 0) { + this._runner = runner; + this.priority = priority; + this._canceled = false; + } + + dispose(): void { + this._canceled = true; + } + + execute(): void { + if (this._canceled) { + return; + } + + try { + this._runner(); + } catch (e) { + onUnexpectedError(e); + } + } + + // Sort by priority (largest to lowest) + static sort(a: AnimationFrameQueueItem, b: AnimationFrameQueueItem): number { + return b.priority - a.priority; + } +} + +(function () { + /** + * The runners scheduled at the next animation frame + */ + const NEXT_QUEUE = new Map(); + /** + * The runners scheduled at the current animation frame + */ + const CURRENT_QUEUE = new Map(); + /** + * A flag to keep track if the native requestAnimationFrame was already called + */ + const animFrameRequested = new Map(); + /** + * A flag to indicate if currently handling a native requestAnimationFrame callback + */ + const inAnimationFrameRunner = new Map(); + + const animationFrameRunner = (targetWindowId: number) => { + animFrameRequested.set(targetWindowId, false); + + const currentQueue = NEXT_QUEUE.get(targetWindowId) ?? []; + CURRENT_QUEUE.set(targetWindowId, currentQueue); + NEXT_QUEUE.set(targetWindowId, []); + + inAnimationFrameRunner.set(targetWindowId, true); + while (currentQueue.length > 0) { + currentQueue.sort(AnimationFrameQueueItem.sort); + const top = currentQueue.shift()!; + top.execute(); + } + inAnimationFrameRunner.set(targetWindowId, false); + }; + + scheduleAtNextAnimationFrame = (targetWindow: Window, runner: () => void, priority: number = 0) => { + const targetWindowId = getWindowId(targetWindow); + const item = new AnimationFrameQueueItem(runner, priority); + + let nextQueue = NEXT_QUEUE.get(targetWindowId); + if (!nextQueue) { + nextQueue = []; + NEXT_QUEUE.set(targetWindowId, nextQueue); + } + nextQueue.push(item); + + if (!animFrameRequested.get(targetWindowId)) { + animFrameRequested.set(targetWindowId, true); + targetWindow.requestAnimationFrame(() => animationFrameRunner(targetWindowId)); + } + + return item; + }; + + runAtThisOrScheduleAtNextAnimationFrame = (targetWindow: Window, runner: () => void, priority?: number) => { + const targetWindowId = getWindowId(targetWindow); + if (inAnimationFrameRunner.get(targetWindowId)) { + const item = new AnimationFrameQueueItem(runner, priority); + let currentQueue = CURRENT_QUEUE.get(targetWindowId); + if (!currentQueue) { + currentQueue = []; + CURRENT_QUEUE.set(targetWindowId, currentQueue); + } + currentQueue.push(item); + return item; + } else { + return scheduleAtNextAnimationFrame(targetWindow, runner, priority); + } + }; +})(); + +export function measure(targetWindow: Window, callback: () => void): IDisposable { + return scheduleAtNextAnimationFrame(targetWindow, callback, 10000 /* must be early */); +} + +export function modify(targetWindow: Window, callback: () => void): IDisposable { + return scheduleAtNextAnimationFrame(targetWindow, callback, -10000 /* must be late */); +} + +/** + * Add a throttled listener. `handler` is fired at most every 8.33333ms or with the next animation frame (if browser supports it). + */ +export interface IEventMerger { + (lastEvent: R | null, currentEvent: E): R; +} + +const MINIMUM_TIME_MS = 8; +const DEFAULT_EVENT_MERGER: IEventMerger = function (lastEvent: Event | null, currentEvent: Event) { + return currentEvent; +}; + +class TimeoutThrottledDomListener extends Disposable { + + constructor(node: any, type: string, handler: (event: R) => void, eventMerger: IEventMerger = DEFAULT_EVENT_MERGER, minimumTimeMs: number = MINIMUM_TIME_MS) { + super(); + + let lastEvent: R | null = null; + let lastHandlerTime = 0; + const timeout = this._register(new TimeoutTimer()); + + const invokeHandler = () => { + lastHandlerTime = (new Date()).getTime(); + handler(lastEvent); + lastEvent = null; + }; + + this._register(addDisposableListener(node, type, (e) => { + + lastEvent = eventMerger(lastEvent, e); + const elapsedTime = (new Date()).getTime() - lastHandlerTime; + + if (elapsedTime >= minimumTimeMs) { + timeout.cancel(); + invokeHandler(); + } else { + timeout.setIfNotSet(invokeHandler, minimumTimeMs - elapsedTime); + } + })); + } +} + +export function addDisposableThrottledListener(node: any, type: string, handler: (event: R) => void, eventMerger?: IEventMerger, minimumTimeMs?: number): IDisposable { + return new TimeoutThrottledDomListener(node, type, handler, eventMerger, minimumTimeMs); +} + +export function getComputedStyle(el: HTMLElement): CSSStyleDeclaration { + return getWindow(el).getComputedStyle(el, null); +} + +export function getClientArea(element: HTMLElement, fallback?: HTMLElement): Dimension { + const elWindow = getWindow(element); + const elDocument = elWindow.document; + + // Try with DOM clientWidth / clientHeight + if (element !== elDocument.body) { + return new Dimension(element.clientWidth, element.clientHeight); + } + + // If visual view port exits and it's on mobile, it should be used instead of window innerWidth / innerHeight, or document.body.clientWidth / document.body.clientHeight + if (platform.isIOS && elWindow?.visualViewport) { + return new Dimension(elWindow.visualViewport.width, elWindow.visualViewport.height); + } + + // Try innerWidth / innerHeight + if (elWindow?.innerWidth && elWindow.innerHeight) { + return new Dimension(elWindow.innerWidth, elWindow.innerHeight); + } + + // Try with document.body.clientWidth / document.body.clientHeight + if (elDocument.body && elDocument.body.clientWidth && elDocument.body.clientHeight) { + return new Dimension(elDocument.body.clientWidth, elDocument.body.clientHeight); + } + + // Try with document.documentElement.clientWidth / document.documentElement.clientHeight + if (elDocument.documentElement && elDocument.documentElement.clientWidth && elDocument.documentElement.clientHeight) { + return new Dimension(elDocument.documentElement.clientWidth, elDocument.documentElement.clientHeight); + } + + if (fallback) { + return getClientArea(fallback); + } + + throw new Error('Unable to figure out browser width and height'); +} + +class SizeUtils { + // Adapted from WinJS + // Converts a CSS positioning string for the specified element to pixels. + private static convertToPixels(element: HTMLElement, value: string): number { + return parseFloat(value) || 0; + } + + private static getDimension(element: HTMLElement, cssPropertyName: string, jsPropertyName: string): number { + const computedStyle = getComputedStyle(element); + const value = computedStyle ? computedStyle.getPropertyValue(cssPropertyName) : '0'; + return SizeUtils.convertToPixels(element, value); + } + + static getBorderLeftWidth(element: HTMLElement): number { + return SizeUtils.getDimension(element, 'border-left-width', 'borderLeftWidth'); + } + static getBorderRightWidth(element: HTMLElement): number { + return SizeUtils.getDimension(element, 'border-right-width', 'borderRightWidth'); + } + static getBorderTopWidth(element: HTMLElement): number { + return SizeUtils.getDimension(element, 'border-top-width', 'borderTopWidth'); + } + static getBorderBottomWidth(element: HTMLElement): number { + return SizeUtils.getDimension(element, 'border-bottom-width', 'borderBottomWidth'); + } + + static getPaddingLeft(element: HTMLElement): number { + return SizeUtils.getDimension(element, 'padding-left', 'paddingLeft'); + } + static getPaddingRight(element: HTMLElement): number { + return SizeUtils.getDimension(element, 'padding-right', 'paddingRight'); + } + static getPaddingTop(element: HTMLElement): number { + return SizeUtils.getDimension(element, 'padding-top', 'paddingTop'); + } + static getPaddingBottom(element: HTMLElement): number { + return SizeUtils.getDimension(element, 'padding-bottom', 'paddingBottom'); + } + + static getMarginLeft(element: HTMLElement): number { + return SizeUtils.getDimension(element, 'margin-left', 'marginLeft'); + } + static getMarginTop(element: HTMLElement): number { + return SizeUtils.getDimension(element, 'margin-top', 'marginTop'); + } + static getMarginRight(element: HTMLElement): number { + return SizeUtils.getDimension(element, 'margin-right', 'marginRight'); + } + static getMarginBottom(element: HTMLElement): number { + return SizeUtils.getDimension(element, 'margin-bottom', 'marginBottom'); + } +} + +// ---------------------------------------------------------------------------------------- +// Position & Dimension + +export interface IDimension { + readonly width: number; + readonly height: number; +} + +export class Dimension implements IDimension { + + static readonly None = new Dimension(0, 0); + + constructor( + readonly width: number, + readonly height: number, + ) { } + + with(width: number = this.width, height: number = this.height): Dimension { + if (width !== this.width || height !== this.height) { + return new Dimension(width, height); + } else { + return this; + } + } + + static is(obj: unknown): obj is IDimension { + return typeof obj === 'object' && typeof (obj).height === 'number' && typeof (obj).width === 'number'; + } + + static lift(obj: IDimension): Dimension { + if (obj instanceof Dimension) { + return obj; + } else { + return new Dimension(obj.width, obj.height); + } + } + + static equals(a: Dimension | undefined, b: Dimension | undefined): boolean { + if (a === b) { + return true; + } + if (!a || !b) { + return false; + } + return a.width === b.width && a.height === b.height; + } +} + +export interface IDomPosition { + readonly left: number; + readonly top: number; +} + +export function getTopLeftOffset(element: HTMLElement): IDomPosition { + // Adapted from WinJS.Utilities.getPosition + // and added borders to the mix + + let offsetParent = element.offsetParent; + let top = element.offsetTop; + let left = element.offsetLeft; + + while ( + (element = element.parentNode) !== null + && element !== element.ownerDocument.body + && element !== element.ownerDocument.documentElement + ) { + top -= element.scrollTop; + const c = isShadowRoot(element) ? null : getComputedStyle(element); + if (c) { + left -= c.direction !== 'rtl' ? element.scrollLeft : -element.scrollLeft; + } + + if (element === offsetParent) { + left += SizeUtils.getBorderLeftWidth(element); + top += SizeUtils.getBorderTopWidth(element); + top += element.offsetTop; + left += element.offsetLeft; + offsetParent = element.offsetParent; + } + } + + return { + left: left, + top: top + }; +} + +export interface IDomNodePagePosition { + left: number; + top: number; + width: number; + height: number; +} + +export function size(element: HTMLElement, width: number | null, height: number | null): void { + if (typeof width === 'number') { + element.style.width = `${width}px`; + } + + if (typeof height === 'number') { + element.style.height = `${height}px`; + } +} + +export function position(element: HTMLElement, top: number, right?: number, bottom?: number, left?: number, position: string = 'absolute'): void { + if (typeof top === 'number') { + element.style.top = `${top}px`; + } + + if (typeof right === 'number') { + element.style.right = `${right}px`; + } + + if (typeof bottom === 'number') { + element.style.bottom = `${bottom}px`; + } + + if (typeof left === 'number') { + element.style.left = `${left}px`; + } + + element.style.position = position; +} + +/** + * Returns the position of a dom node relative to the entire page. + */ +export function getDomNodePagePosition(domNode: HTMLElement): IDomNodePagePosition { + const bb = domNode.getBoundingClientRect(); + const window = getWindow(domNode); + return { + left: bb.left + window.scrollX, + top: bb.top + window.scrollY, + width: bb.width, + height: bb.height + }; +} + +/** + * Returns the effective zoom on a given element before window zoom level is applied + */ +export function getDomNodeZoomLevel(domNode: HTMLElement): number { + let testElement: HTMLElement | null = domNode; + let zoom = 1.0; + do { + const elementZoomLevel = (getComputedStyle(testElement) as any).zoom; + if (elementZoomLevel !== null && elementZoomLevel !== undefined && elementZoomLevel !== '1') { + zoom *= elementZoomLevel; + } + + testElement = testElement.parentElement; + } while (testElement !== null && testElement !== testElement.ownerDocument.documentElement); + + return zoom; +} + + +// Adapted from WinJS +// Gets the width of the element, including margins. +export function getTotalWidth(element: HTMLElement): number { + const margin = SizeUtils.getMarginLeft(element) + SizeUtils.getMarginRight(element); + return element.offsetWidth + margin; +} + +export function getContentWidth(element: HTMLElement): number { + const border = SizeUtils.getBorderLeftWidth(element) + SizeUtils.getBorderRightWidth(element); + const padding = SizeUtils.getPaddingLeft(element) + SizeUtils.getPaddingRight(element); + return element.offsetWidth - border - padding; +} + +export function getTotalScrollWidth(element: HTMLElement): number { + const margin = SizeUtils.getMarginLeft(element) + SizeUtils.getMarginRight(element); + return element.scrollWidth + margin; +} + +// Adapted from WinJS +// Gets the height of the content of the specified element. The content height does not include borders or padding. +export function getContentHeight(element: HTMLElement): number { + const border = SizeUtils.getBorderTopWidth(element) + SizeUtils.getBorderBottomWidth(element); + const padding = SizeUtils.getPaddingTop(element) + SizeUtils.getPaddingBottom(element); + return element.offsetHeight - border - padding; +} + +// Adapted from WinJS +// Gets the height of the element, including its margins. +export function getTotalHeight(element: HTMLElement): number { + const margin = SizeUtils.getMarginTop(element) + SizeUtils.getMarginBottom(element); + return element.offsetHeight + margin; +} + +// Gets the left coordinate of the specified element relative to the specified parent. +function getRelativeLeft(element: HTMLElement, parent: HTMLElement): number { + if (element === null) { + return 0; + } + + const elementPosition = getTopLeftOffset(element); + const parentPosition = getTopLeftOffset(parent); + return elementPosition.left - parentPosition.left; +} + +export function getLargestChildWidth(parent: HTMLElement, children: HTMLElement[]): number { + const childWidths = children.map((child) => { + return Math.max(getTotalScrollWidth(child), getTotalWidth(child)) + getRelativeLeft(child, parent) || 0; + }); + const maxWidth = Math.max(...childWidths); + return maxWidth; +} + +// ---------------------------------------------------------------------------------------- + +export function isAncestor(testChild: Node | null, testAncestor: Node | null): boolean { + return Boolean(testAncestor?.contains(testChild)); +} + +const parentFlowToDataKey = 'parentFlowToElementId'; + +/** + * Set an explicit parent to use for nodes that are not part of the + * regular dom structure. + */ +export function setParentFlowTo(fromChildElement: HTMLElement, toParentElement: Element): void { + fromChildElement.dataset[parentFlowToDataKey] = toParentElement.id; +} + +function getParentFlowToElement(node: HTMLElement): HTMLElement | null { + const flowToParentId = node.dataset[parentFlowToDataKey]; + if (typeof flowToParentId === 'string') { + return node.ownerDocument.getElementById(flowToParentId); + } + return null; +} + +/** + * Check if `testAncestor` is an ancestor of `testChild`, observing the explicit + * parents set by `setParentFlowTo`. + */ +export function isAncestorUsingFlowTo(testChild: Node, testAncestor: Node): boolean { + let node: Node | null = testChild; + while (node) { + if (node === testAncestor) { + return true; + } + + if (isHTMLElement(node)) { + const flowToParentElement = getParentFlowToElement(node); + if (flowToParentElement) { + node = flowToParentElement; + continue; + } + } + node = node.parentNode; + } + + return false; +} + +export function findParentWithClass(node: HTMLElement, clazz: string, stopAtClazzOrNode?: string | HTMLElement): HTMLElement | null { + while (node && node.nodeType === node.ELEMENT_NODE) { + if (node.classList.contains(clazz)) { + return node; + } + + if (stopAtClazzOrNode) { + if (typeof stopAtClazzOrNode === 'string') { + if (node.classList.contains(stopAtClazzOrNode)) { + return null; + } + } else { + if (node === stopAtClazzOrNode) { + return null; + } + } + } + + node = node.parentNode; + } + + return null; +} + +export function hasParentWithClass(node: HTMLElement, clazz: string, stopAtClazzOrNode?: string | HTMLElement): boolean { + return !!findParentWithClass(node, clazz, stopAtClazzOrNode); +} + +export function isShadowRoot(node: Node): node is ShadowRoot { + return ( + node && !!(node).host && !!(node).mode + ); +} + +export function isInShadowDOM(domNode: Node): boolean { + return !!getShadowRoot(domNode); +} + +export function getShadowRoot(domNode: Node): ShadowRoot | null { + while (domNode.parentNode) { + if (domNode === domNode.ownerDocument?.body) { + // reached the body + return null; + } + domNode = domNode.parentNode; + } + return isShadowRoot(domNode) ? domNode : null; +} + +/** + * Returns the active element across all child windows + * based on document focus. Falls back to the main + * window if no window has focus. + */ +export function getActiveElement(): Element | null { + let result = getActiveDocument().activeElement; + + while (result?.shadowRoot) { + result = result.shadowRoot.activeElement; + } + + return result; +} + +/** + * Returns true if the focused window active element matches + * the provided element. Falls back to the main window if no + * window has focus. + */ +export function isActiveElement(element: Element): boolean { + return getActiveElement() === element; +} + +/** + * Returns true if the focused window active element is contained in + * `ancestor`. Falls back to the main window if no window has focus. + */ +export function isAncestorOfActiveElement(ancestor: Element): boolean { + return isAncestor(getActiveElement(), ancestor); +} + +/** + * Returns whether the element is in the active `document`. The active + * document has focus or will be the main windows document. + */ +export function isActiveDocument(element: Element): boolean { + return element.ownerDocument === getActiveDocument(); +} + +/** + * Returns the active document across main and child windows. + * Prefers the window with focus, otherwise falls back to + * the main windows document. + */ +export function getActiveDocument(): Document { + if (getWindowsCount() <= 1) { + return mainWindow.document; + } + + const documents = Array.from(getWindows()).map(({ window }) => window.document); + return documents.find(doc => doc.hasFocus()) ?? mainWindow.document; +} + +/** + * Returns the active window across main and child windows. + * Prefers the window with focus, otherwise falls back to + * the main window. + */ +export function getActiveWindow(): CodeWindow { + const document = getActiveDocument(); + return (document.defaultView?.window ?? mainWindow) as CodeWindow; +} + +const globalStylesheets = new Map>(); + +export function isGlobalStylesheet(node: Node): boolean { + return globalStylesheets.has(node as HTMLStyleElement); +} + +/** + * A version of createStyleSheet which has a unified API to initialize/set the style content. + */ +export function createStyleSheet2(): WrappedStyleElement { + return new WrappedStyleElement(); +} + +class WrappedStyleElement { + private _currentCssStyle = ''; + private _styleSheet: HTMLStyleElement | undefined = undefined; + + public setStyle(cssStyle: string): void { + if (cssStyle === this._currentCssStyle) { + return; + } + this._currentCssStyle = cssStyle; + + if (!this._styleSheet) { + this._styleSheet = createStyleSheet(mainWindow.document.head, (s) => s.innerText = cssStyle); + } else { + this._styleSheet.innerText = cssStyle; + } + } + + public dispose(): void { + if (this._styleSheet) { + this._styleSheet.remove(); + this._styleSheet = undefined; + } + } +} + +export function createStyleSheet(container: HTMLElement = mainWindow.document.head, beforeAppend?: (style: HTMLStyleElement) => void, disposableStore?: DisposableStore): HTMLStyleElement { + const style = document.createElement('style'); + style.type = 'text/css'; + style.media = 'screen'; + beforeAppend?.(style); + container.appendChild(style); + + if (disposableStore) { + disposableStore.add(toDisposable(() => style.remove())); + } + + // With as container, the stylesheet becomes global and is tracked + // to support auxiliary windows to clone the stylesheet. + if (container === mainWindow.document.head) { + const globalStylesheetClones = new Set(); + globalStylesheets.set(style, globalStylesheetClones); + + for (const { window: targetWindow, disposables } of getWindows()) { + if (targetWindow === mainWindow) { + continue; // main window is already tracked + } + + const cloneDisposable = disposables.add(cloneGlobalStyleSheet(style, globalStylesheetClones, targetWindow)); + disposableStore?.add(cloneDisposable); + } + } + + return style; +} + +export function cloneGlobalStylesheets(targetWindow: Window): IDisposable { + const disposables = new DisposableStore(); + + for (const [globalStylesheet, clonedGlobalStylesheets] of globalStylesheets) { + disposables.add(cloneGlobalStyleSheet(globalStylesheet, clonedGlobalStylesheets, targetWindow)); + } + + return disposables; +} + +function cloneGlobalStyleSheet(globalStylesheet: HTMLStyleElement, globalStylesheetClones: Set, targetWindow: Window): IDisposable { + const disposables = new DisposableStore(); + + const clone = globalStylesheet.cloneNode(true) as HTMLStyleElement; + targetWindow.document.head.appendChild(clone); + disposables.add(toDisposable(() => clone.remove())); + + for (const rule of getDynamicStyleSheetRules(globalStylesheet)) { + clone.sheet?.insertRule(rule.cssText, clone.sheet?.cssRules.length); + } + + disposables.add(sharedMutationObserver.observe(globalStylesheet, disposables, { childList: true })(() => { + clone.textContent = globalStylesheet.textContent; + })); + + globalStylesheetClones.add(clone); + disposables.add(toDisposable(() => globalStylesheetClones.delete(clone))); + + return disposables; +} + +interface IMutationObserver { + users: number; + readonly observer: MutationObserver; + readonly onDidMutate: event.Event; +} + +export const sharedMutationObserver = new class { + + readonly mutationObservers = new Map>(); + + observe(target: Node, disposables: DisposableStore, options?: MutationObserverInit): event.Event { + let mutationObserversPerTarget = this.mutationObservers.get(target); + if (!mutationObserversPerTarget) { + mutationObserversPerTarget = new Map(); + this.mutationObservers.set(target, mutationObserversPerTarget); + } + + const optionsHash = hash(options); + let mutationObserverPerOptions = mutationObserversPerTarget.get(optionsHash); + if (!mutationObserverPerOptions) { + const onDidMutate = new event.Emitter(); + const observer = new MutationObserver(mutations => onDidMutate.fire(mutations)); + observer.observe(target, options); + + const resolvedMutationObserverPerOptions = mutationObserverPerOptions = { + users: 1, + observer, + onDidMutate: onDidMutate.event + }; + + disposables.add(toDisposable(() => { + resolvedMutationObserverPerOptions.users -= 1; + + if (resolvedMutationObserverPerOptions.users === 0) { + onDidMutate.dispose(); + observer.disconnect(); + + mutationObserversPerTarget?.delete(optionsHash); + if (mutationObserversPerTarget?.size === 0) { + this.mutationObservers.delete(target); + } + } + })); + + mutationObserversPerTarget.set(optionsHash, mutationObserverPerOptions); + } else { + mutationObserverPerOptions.users += 1; + } + + return mutationObserverPerOptions.onDidMutate; + } +}; + +export function createMetaElement(container: HTMLElement = mainWindow.document.head): HTMLMetaElement { + return createHeadElement('meta', container) as HTMLMetaElement; +} + +export function createLinkElement(container: HTMLElement = mainWindow.document.head): HTMLLinkElement { + return createHeadElement('link', container) as HTMLLinkElement; +} + +function createHeadElement(tagName: string, container: HTMLElement = mainWindow.document.head): HTMLElement { + const element = document.createElement(tagName); + container.appendChild(element); + return element; +} + +let _sharedStyleSheet: HTMLStyleElement | null = null; +function getSharedStyleSheet(): HTMLStyleElement { + if (!_sharedStyleSheet) { + _sharedStyleSheet = createStyleSheet(); + } + return _sharedStyleSheet; +} + +function getDynamicStyleSheetRules(style: HTMLStyleElement) { + if (style?.sheet?.rules) { + // Chrome, IE + return style.sheet.rules; + } + if (style?.sheet?.cssRules) { + // FF + return style.sheet.cssRules; + } + return []; +} + +export function createCSSRule(selector: string, cssText: string, style = getSharedStyleSheet()): void { + if (!style || !cssText) { + return; + } + + style.sheet?.insertRule(`${selector} {${cssText}}`, 0); + + // Apply rule also to all cloned global stylesheets + for (const clonedGlobalStylesheet of globalStylesheets.get(style) ?? []) { + createCSSRule(selector, cssText, clonedGlobalStylesheet); + } +} + +export function removeCSSRulesContainingSelector(ruleName: string, style = getSharedStyleSheet()): void { + if (!style) { + return; + } + + const rules = getDynamicStyleSheetRules(style); + const toDelete: number[] = []; + for (let i = 0; i < rules.length; i++) { + const rule = rules[i]; + if (isCSSStyleRule(rule) && rule.selectorText.indexOf(ruleName) !== -1) { + toDelete.push(i); + } + } + + for (let i = toDelete.length - 1; i >= 0; i--) { + style.sheet?.deleteRule(toDelete[i]); + } + + // Remove rules also from all cloned global stylesheets + for (const clonedGlobalStylesheet of globalStylesheets.get(style) ?? []) { + removeCSSRulesContainingSelector(ruleName, clonedGlobalStylesheet); + } +} + +function isCSSStyleRule(rule: CSSRule): rule is CSSStyleRule { + return typeof (rule as CSSStyleRule).selectorText === 'string'; +} + +export function isHTMLElement(e: unknown): e is HTMLElement { + // eslint-disable-next-line no-restricted-syntax + return e instanceof HTMLElement || e instanceof getWindow(e as Node).HTMLElement; +} + +export function isHTMLAnchorElement(e: unknown): e is HTMLAnchorElement { + // eslint-disable-next-line no-restricted-syntax + return e instanceof HTMLAnchorElement || e instanceof getWindow(e as Node).HTMLAnchorElement; +} + +export function isHTMLSpanElement(e: unknown): e is HTMLSpanElement { + // eslint-disable-next-line no-restricted-syntax + return e instanceof HTMLSpanElement || e instanceof getWindow(e as Node).HTMLSpanElement; +} + +export function isHTMLTextAreaElement(e: unknown): e is HTMLTextAreaElement { + // eslint-disable-next-line no-restricted-syntax + return e instanceof HTMLTextAreaElement || e instanceof getWindow(e as Node).HTMLTextAreaElement; +} + +export function isHTMLInputElement(e: unknown): e is HTMLInputElement { + // eslint-disable-next-line no-restricted-syntax + return e instanceof HTMLInputElement || e instanceof getWindow(e as Node).HTMLInputElement; +} + +export function isHTMLButtonElement(e: unknown): e is HTMLButtonElement { + // eslint-disable-next-line no-restricted-syntax + return e instanceof HTMLButtonElement || e instanceof getWindow(e as Node).HTMLButtonElement; +} + +export function isHTMLDivElement(e: unknown): e is HTMLDivElement { + // eslint-disable-next-line no-restricted-syntax + return e instanceof HTMLDivElement || e instanceof getWindow(e as Node).HTMLDivElement; +} + +export function isSVGElement(e: unknown): e is SVGElement { + // eslint-disable-next-line no-restricted-syntax + return e instanceof SVGElement || e instanceof getWindow(e as Node).SVGElement; +} + +export function isMouseEvent(e: unknown): e is MouseEvent { + // eslint-disable-next-line no-restricted-syntax + return e instanceof MouseEvent || e instanceof getWindow(e as UIEvent).MouseEvent; +} + +export function isKeyboardEvent(e: unknown): e is KeyboardEvent { + // eslint-disable-next-line no-restricted-syntax + return e instanceof KeyboardEvent || e instanceof getWindow(e as UIEvent).KeyboardEvent; +} + +export function isPointerEvent(e: unknown): e is PointerEvent { + // eslint-disable-next-line no-restricted-syntax + return e instanceof PointerEvent || e instanceof getWindow(e as UIEvent).PointerEvent; +} + +export function isDragEvent(e: unknown): e is DragEvent { + // eslint-disable-next-line no-restricted-syntax + return e instanceof DragEvent || e instanceof getWindow(e as UIEvent).DragEvent; +} + +export const EventType = { + // Mouse + CLICK: 'click', + AUXCLICK: 'auxclick', + DBLCLICK: 'dblclick', + MOUSE_UP: 'mouseup', + MOUSE_DOWN: 'mousedown', + MOUSE_OVER: 'mouseover', + MOUSE_MOVE: 'mousemove', + MOUSE_OUT: 'mouseout', + MOUSE_ENTER: 'mouseenter', + MOUSE_LEAVE: 'mouseleave', + MOUSE_WHEEL: 'wheel', + POINTER_UP: 'pointerup', + POINTER_DOWN: 'pointerdown', + POINTER_MOVE: 'pointermove', + POINTER_LEAVE: 'pointerleave', + CONTEXT_MENU: 'contextmenu', + WHEEL: 'wheel', + // Keyboard + KEY_DOWN: 'keydown', + KEY_PRESS: 'keypress', + KEY_UP: 'keyup', + // HTML Document + LOAD: 'load', + BEFORE_UNLOAD: 'beforeunload', + UNLOAD: 'unload', + PAGE_SHOW: 'pageshow', + PAGE_HIDE: 'pagehide', + PASTE: 'paste', + ABORT: 'abort', + ERROR: 'error', + RESIZE: 'resize', + SCROLL: 'scroll', + FULLSCREEN_CHANGE: 'fullscreenchange', + WK_FULLSCREEN_CHANGE: 'webkitfullscreenchange', + // Form + SELECT: 'select', + CHANGE: 'change', + SUBMIT: 'submit', + RESET: 'reset', + FOCUS: 'focus', + FOCUS_IN: 'focusin', + FOCUS_OUT: 'focusout', + BLUR: 'blur', + INPUT: 'input', + // Local Storage + STORAGE: 'storage', + // Drag + DRAG_START: 'dragstart', + DRAG: 'drag', + DRAG_ENTER: 'dragenter', + DRAG_LEAVE: 'dragleave', + DRAG_OVER: 'dragover', + DROP: 'drop', + DRAG_END: 'dragend', + // Animation + ANIMATION_START: browser.isWebKit ? 'webkitAnimationStart' : 'animationstart', + ANIMATION_END: browser.isWebKit ? 'webkitAnimationEnd' : 'animationend', + ANIMATION_ITERATION: browser.isWebKit ? 'webkitAnimationIteration' : 'animationiteration' +} as const; + +export interface EventLike { + preventDefault(): void; + stopPropagation(): void; +} + +export function isEventLike(obj: unknown): obj is EventLike { + const candidate = obj as EventLike | undefined; + + return !!(candidate && typeof candidate.preventDefault === 'function' && typeof candidate.stopPropagation === 'function'); +} + +export const EventHelper = { + stop: (e: T, cancelBubble?: boolean): T => { + e.preventDefault(); + if (cancelBubble) { + e.stopPropagation(); + } + return e; + } +}; + +export interface IFocusTracker extends Disposable { + readonly onDidFocus: event.Event; + readonly onDidBlur: event.Event; + refreshState(): void; +} + +export function saveParentsScrollTop(node: Element): number[] { + const r: number[] = []; + for (let i = 0; node && node.nodeType === node.ELEMENT_NODE; i++) { + r[i] = node.scrollTop; + node = node.parentNode; + } + return r; +} + +export function restoreParentsScrollTop(node: Element, state: number[]): void { + for (let i = 0; node && node.nodeType === node.ELEMENT_NODE; i++) { + if (node.scrollTop !== state[i]) { + node.scrollTop = state[i]; + } + node = node.parentNode; + } +} + +class FocusTracker extends Disposable implements IFocusTracker { + + private readonly _onDidFocus = this._register(new event.Emitter()); + readonly onDidFocus = this._onDidFocus.event; + + private readonly _onDidBlur = this._register(new event.Emitter()); + readonly onDidBlur = this._onDidBlur.event; + + private _refreshStateHandler: () => void; + + private static hasFocusWithin(element: HTMLElement | Window): boolean { + if (isHTMLElement(element)) { + const shadowRoot = getShadowRoot(element); + const activeElement = (shadowRoot ? shadowRoot.activeElement : element.ownerDocument.activeElement); + return isAncestor(activeElement, element); + } else { + const window = element; + return isAncestor(window.document.activeElement, window.document); + } + } + + constructor(element: HTMLElement | Window) { + super(); + let hasFocus = FocusTracker.hasFocusWithin(element); + let loosingFocus = false; + + const onFocus = () => { + loosingFocus = false; + if (!hasFocus) { + hasFocus = true; + this._onDidFocus.fire(); + } + }; + + const onBlur = () => { + if (hasFocus) { + loosingFocus = true; + (isHTMLElement(element) ? getWindow(element) : element).setTimeout(() => { + if (loosingFocus) { + loosingFocus = false; + hasFocus = false; + this._onDidBlur.fire(); + } + }, 0); + } + }; + + this._refreshStateHandler = () => { + const currentNodeHasFocus = FocusTracker.hasFocusWithin(element); + if (currentNodeHasFocus !== hasFocus) { + if (hasFocus) { + onBlur(); + } else { + onFocus(); + } + } + }; + + this._register(addDisposableListener(element, EventType.FOCUS, onFocus, true)); + this._register(addDisposableListener(element, EventType.BLUR, onBlur, true)); + if (isHTMLElement(element)) { + this._register(addDisposableListener(element, EventType.FOCUS_IN, () => this._refreshStateHandler())); + this._register(addDisposableListener(element, EventType.FOCUS_OUT, () => this._refreshStateHandler())); + } + + } + + refreshState() { + this._refreshStateHandler(); + } +} + +/** + * Creates a new `IFocusTracker` instance that tracks focus changes on the given `element` and its descendants. + * + * @param element The `HTMLElement` or `Window` to track focus changes on. + * @returns An `IFocusTracker` instance. + */ +export function trackFocus(element: HTMLElement | Window): IFocusTracker { + return new FocusTracker(element); +} + +export function after(sibling: HTMLElement, child: T): T { + sibling.after(child); + return child; +} + +export function append(parent: HTMLElement, child: T): T; +export function append(parent: HTMLElement, ...children: (T | string)[]): void; +export function append(parent: HTMLElement, ...children: (T | string)[]): T | void { + parent.append(...children); + if (children.length === 1 && typeof children[0] !== 'string') { + return children[0]; + } +} + +export function prepend(parent: HTMLElement, child: T): T { + parent.insertBefore(child, parent.firstChild); + return child; +} + +/** + * Removes all children from `parent` and appends `children` + */ +export function reset(parent: HTMLElement, ...children: Array): void { + parent.innerText = ''; + append(parent, ...children); +} + +const SELECTOR_REGEX = /([\w\-]+)?(#([\w\-]+))?((\.([\w\-]+))*)/; + +export enum Namespace { + HTML = 'http://www.w3.org/1999/xhtml', + SVG = 'http://www.w3.org/2000/svg' +} + +function _$(namespace: Namespace, description: string, attrs?: { [key: string]: any }, ...children: Array): T { + const match = SELECTOR_REGEX.exec(description); + + if (!match) { + throw new Error('Bad use of emmet'); + } + + const tagName = match[1] || 'div'; + let result: T; + + if (namespace !== Namespace.HTML) { + result = document.createElementNS(namespace as string, tagName) as T; + } else { + result = document.createElement(tagName) as unknown as T; + } + + if (match[3]) { + result.id = match[3]; + } + if (match[4]) { + result.className = match[4].replace(/\./g, ' ').trim(); + } + + if (attrs) { + Object.entries(attrs).forEach(([name, value]) => { + if (typeof value === 'undefined') { + return; + } + + if (/^on\w+$/.test(name)) { + (result)[name] = value; + } else if (name === 'selected') { + if (value) { + result.setAttribute(name, 'true'); + } + + } else { + result.setAttribute(name, value); + } + }); + } + + result.append(...children); + + return result as T; +} + +export function $(description: string, attrs?: { [key: string]: any }, ...children: Array): T { + return _$(Namespace.HTML, description, attrs, ...children); +} + +$.SVG = function (description: string, attrs?: { [key: string]: any }, ...children: Array): T { + return _$(Namespace.SVG, description, attrs, ...children); +}; + +export function join(nodes: Node[], separator: Node | string): Node[] { + const result: Node[] = []; + + nodes.forEach((node, index) => { + if (index > 0) { + if (separator instanceof Node) { + result.push(separator.cloneNode()); + } else { + result.push(document.createTextNode(separator)); + } + } + + result.push(node); + }); + + return result; +} + +export function setVisibility(visible: boolean, ...elements: HTMLElement[]): void { + if (visible) { + show(...elements); + } else { + hide(...elements); + } +} + +export function show(...elements: HTMLElement[]): void { + for (const element of elements) { + element.style.display = ''; + element.removeAttribute('aria-hidden'); + } +} + +export function hide(...elements: HTMLElement[]): void { + for (const element of elements) { + element.style.display = 'none'; + element.setAttribute('aria-hidden', 'true'); + } +} + +function findParentWithAttribute(node: Node | null, attribute: string): HTMLElement | null { + while (node && node.nodeType === node.ELEMENT_NODE) { + if (isHTMLElement(node) && node.hasAttribute(attribute)) { + return node; + } + + node = node.parentNode; + } + + return null; +} + +export function removeTabIndexAndUpdateFocus(node: HTMLElement): void { + if (!node || !node.hasAttribute('tabIndex')) { + return; + } + + // If we are the currently focused element and tabIndex is removed, + // standard DOM behavior is to move focus to the element. We + // typically never want that, rather put focus to the closest element + // in the hierarchy of the parent DOM nodes. + if (node.ownerDocument.activeElement === node) { + const parentFocusable = findParentWithAttribute(node.parentElement, 'tabIndex'); + parentFocusable?.focus(); + } + + node.removeAttribute('tabindex'); +} + +export function finalHandler(fn: (event: T) => any): (event: T) => any { + return e => { + e.preventDefault(); + e.stopPropagation(); + fn(e); + }; +} + +export function domContentLoaded(targetWindow: Window): Promise { + return new Promise(resolve => { + const readyState = targetWindow.document.readyState; + if (readyState === 'complete' || (targetWindow.document && targetWindow.document.body !== null)) { + resolve(undefined); + } else { + const listener = () => { + targetWindow.window.removeEventListener('DOMContentLoaded', listener, false); + resolve(); + }; + + targetWindow.window.addEventListener('DOMContentLoaded', listener, false); + } + }); +} + +/** + * Find a value usable for a dom node size such that the likelihood that it would be + * displayed with constant screen pixels size is as high as possible. + * + * e.g. We would desire for the cursors to be 2px (CSS px) wide. Under a devicePixelRatio + * of 1.25, the cursor will be 2.5 screen pixels wide. Depending on how the dom node aligns/"snaps" + * with the screen pixels, it will sometimes be rendered with 2 screen pixels, and sometimes with 3 screen pixels. + */ +export function computeScreenAwareSize(window: Window, cssPx: number): number { + const screenPx = window.devicePixelRatio * cssPx; + return Math.max(1, Math.floor(screenPx)) / window.devicePixelRatio; +} + +/** + * Open safely a new window. This is the best way to do so, but you cannot tell + * if the window was opened or if it was blocked by the browser's popup blocker. + * If you want to tell if the browser blocked the new window, use {@link windowOpenWithSuccess}. + * + * See https://github.com/microsoft/monaco-editor/issues/601 + * To protect against malicious code in the linked site, particularly phishing attempts, + * the window.opener should be set to null to prevent the linked site from having access + * to change the location of the current page. + * See https://mathiasbynens.github.io/rel-noopener/ + */ +export function windowOpenNoOpener(url: string): void { + // By using 'noopener' in the `windowFeatures` argument, the newly created window will + // not be able to use `window.opener` to reach back to the current page. + // See https://stackoverflow.com/a/46958731 + // See https://developer.mozilla.org/en-US/docs/Web/API/Window/open#noopener + // However, this also doesn't allow us to realize if the browser blocked + // the creation of the window. + mainWindow.open(url, '_blank', 'noopener'); +} + +/** + * Open a new window in a popup. This is the best way to do so, but you cannot tell + * if the window was opened or if it was blocked by the browser's popup blocker. + * If you want to tell if the browser blocked the new window, use {@link windowOpenWithSuccess}. + * + * Note: this does not set {@link window.opener} to null. This is to allow the opened popup to + * be able to use {@link window.close} to close itself. Because of this, you should only use + * this function on urls that you trust. + * + * In otherwords, you should almost always use {@link windowOpenNoOpener} instead of this function. + */ +const popupWidth = 780, popupHeight = 640; +export function windowOpenPopup(url: string): void { + const left = Math.floor(mainWindow.screenLeft + mainWindow.innerWidth / 2 - popupWidth / 2); + const top = Math.floor(mainWindow.screenTop + mainWindow.innerHeight / 2 - popupHeight / 2); + mainWindow.open( + url, + '_blank', + `width=${popupWidth},height=${popupHeight},top=${top},left=${left}` + ); +} + +/** + * Attempts to open a window and returns whether it succeeded. This technique is + * not appropriate in certain contexts, like for example when the JS context is + * executing inside a sandboxed iframe. If it is not necessary to know if the + * browser blocked the new window, use {@link windowOpenNoOpener}. + * + * See https://github.com/microsoft/monaco-editor/issues/601 + * See https://github.com/microsoft/monaco-editor/issues/2474 + * See https://mathiasbynens.github.io/rel-noopener/ + * + * @param url the url to open + * @param noOpener whether or not to set the {@link window.opener} to null. You should leave the default + * (true) unless you trust the url that is being opened. + * @returns boolean indicating if the {@link window.open} call succeeded + */ +export function windowOpenWithSuccess(url: string, noOpener = true): boolean { + const newTab = mainWindow.open(); + if (newTab) { + if (noOpener) { + // see `windowOpenNoOpener` for details on why this is important + (newTab as any).opener = null; + } + newTab.location.href = url; + return true; + } + return false; +} + +export function animate(targetWindow: Window, fn: () => void): IDisposable { + const step = () => { + fn(); + stepDisposable = scheduleAtNextAnimationFrame(targetWindow, step); + }; + + let stepDisposable = scheduleAtNextAnimationFrame(targetWindow, step); + return toDisposable(() => stepDisposable.dispose()); +} + +export function asCSSPropertyValue(value: string) { + return `'${value.replace(/'/g, '%27')}'`; +} + +export function asCssValueWithDefault(cssPropertyValue: string | undefined, dflt: string): string { + if (cssPropertyValue !== undefined) { + const variableMatch = cssPropertyValue.match(/^\s*var\((.+)\)$/); + if (variableMatch) { + const varArguments = variableMatch[1].split(',', 2); + if (varArguments.length === 2) { + dflt = asCssValueWithDefault(varArguments[1].trim(), dflt); + } + return `var(${varArguments[0]}, ${dflt})`; + } + return cssPropertyValue; + } + return dflt; +} + +export enum DetectedFullscreenMode { + + /** + * The document is fullscreen, e.g. because an element + * in the document requested to be fullscreen. + */ + DOCUMENT = 1, + + /** + * The browser is fullscreen, e.g. because the user enabled + * native window fullscreen for it. + */ + BROWSER +} + +export interface IDetectedFullscreen { + + /** + * Figure out if the document is fullscreen or the browser. + */ + mode: DetectedFullscreenMode; + + /** + * Whether we know for sure that we are in fullscreen mode or + * it is a guess. + */ + guess: boolean; +} + +export function detectFullscreen(targetWindow: Window): IDetectedFullscreen | null { + + // Browser fullscreen: use DOM APIs to detect + if (targetWindow.document.fullscreenElement || (targetWindow.document).webkitFullscreenElement || (targetWindow.document).webkitIsFullScreen) { + return { mode: DetectedFullscreenMode.DOCUMENT, guess: false }; + } + + // There is no standard way to figure out if the browser + // is using native fullscreen. Via checking on screen + // height and comparing that to window height, we can guess + // it though. + + if (targetWindow.innerHeight === targetWindow.screen.height) { + // if the height of the window matches the screen height, we can + // safely assume that the browser is fullscreen because no browser + // chrome is taking height away (e.g. like toolbars). + return { mode: DetectedFullscreenMode.BROWSER, guess: false }; + } + + if (platform.isMacintosh || platform.isLinux) { + // macOS and Linux do not properly report `innerHeight`, only Windows does + if (targetWindow.outerHeight === targetWindow.screen.height && targetWindow.outerWidth === targetWindow.screen.width) { + // if the height of the browser matches the screen height, we can + // only guess that we are in fullscreen. It is also possible that + // the user has turned off taskbars in the OS and the browser is + // simply able to span the entire size of the screen. + return { mode: DetectedFullscreenMode.BROWSER, guess: true }; + } + } + + // Not in fullscreen + return null; +} + +/** + * Convert a Unicode string to a string in which each 16-bit unit occupies only one byte + * + * From https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/btoa + */ +function toBinary(str: string): string { + const codeUnits = new Uint16Array(str.length); + for (let i = 0; i < codeUnits.length; i++) { + codeUnits[i] = str.charCodeAt(i); + } + let binary = ''; + const uint8array = new Uint8Array(codeUnits.buffer); + for (let i = 0; i < uint8array.length; i++) { + binary += String.fromCharCode(uint8array[i]); + } + return binary; +} + +/** + * Version of the global `btoa` function that handles multi-byte characters instead + * of throwing an exception. + */ +export function multibyteAwareBtoa(str: string): string { + return btoa(toBinary(str)); +} + +type ModifierKey = 'alt' | 'ctrl' | 'shift' | 'meta'; + +export interface IModifierKeyStatus { + altKey: boolean; + shiftKey: boolean; + ctrlKey: boolean; + metaKey: boolean; + lastKeyPressed?: ModifierKey; + lastKeyReleased?: ModifierKey; + event?: KeyboardEvent; +} + +export class ModifierKeyEmitter extends event.Emitter { + + private readonly _subscriptions = new DisposableStore(); + private _keyStatus: IModifierKeyStatus; + private static instance: ModifierKeyEmitter; + + private constructor() { + super(); + + this._keyStatus = { + altKey: false, + shiftKey: false, + ctrlKey: false, + metaKey: false + }; + + this._subscriptions.add(event.Event.runAndSubscribe(onDidRegisterWindow, ({ window, disposables }) => this.registerListeners(window, disposables), { window: mainWindow, disposables: this._subscriptions })); + } + + private registerListeners(window: Window, disposables: DisposableStore): void { + disposables.add(addDisposableListener(window, 'keydown', e => { + if (e.defaultPrevented) { + return; + } + + const event = new StandardKeyboardEvent(e); + // If Alt-key keydown event is repeated, ignore it #112347 + // Only known to be necessary for Alt-Key at the moment #115810 + if (event.keyCode === KeyCode.Alt && e.repeat) { + return; + } + + if (e.altKey && !this._keyStatus.altKey) { + this._keyStatus.lastKeyPressed = 'alt'; + } else if (e.ctrlKey && !this._keyStatus.ctrlKey) { + this._keyStatus.lastKeyPressed = 'ctrl'; + } else if (e.metaKey && !this._keyStatus.metaKey) { + this._keyStatus.lastKeyPressed = 'meta'; + } else if (e.shiftKey && !this._keyStatus.shiftKey) { + this._keyStatus.lastKeyPressed = 'shift'; + } else if (event.keyCode !== KeyCode.Alt) { + this._keyStatus.lastKeyPressed = undefined; + } else { + return; + } + + this._keyStatus.altKey = e.altKey; + this._keyStatus.ctrlKey = e.ctrlKey; + this._keyStatus.metaKey = e.metaKey; + this._keyStatus.shiftKey = e.shiftKey; + + if (this._keyStatus.lastKeyPressed) { + this._keyStatus.event = e; + this.fire(this._keyStatus); + } + }, true)); + + disposables.add(addDisposableListener(window, 'keyup', e => { + if (e.defaultPrevented) { + return; + } + + if (!e.altKey && this._keyStatus.altKey) { + this._keyStatus.lastKeyReleased = 'alt'; + } else if (!e.ctrlKey && this._keyStatus.ctrlKey) { + this._keyStatus.lastKeyReleased = 'ctrl'; + } else if (!e.metaKey && this._keyStatus.metaKey) { + this._keyStatus.lastKeyReleased = 'meta'; + } else if (!e.shiftKey && this._keyStatus.shiftKey) { + this._keyStatus.lastKeyReleased = 'shift'; + } else { + this._keyStatus.lastKeyReleased = undefined; + } + + if (this._keyStatus.lastKeyPressed !== this._keyStatus.lastKeyReleased) { + this._keyStatus.lastKeyPressed = undefined; + } + + this._keyStatus.altKey = e.altKey; + this._keyStatus.ctrlKey = e.ctrlKey; + this._keyStatus.metaKey = e.metaKey; + this._keyStatus.shiftKey = e.shiftKey; + + if (this._keyStatus.lastKeyReleased) { + this._keyStatus.event = e; + this.fire(this._keyStatus); + } + }, true)); + + disposables.add(addDisposableListener(window.document.body, 'mousedown', () => { + this._keyStatus.lastKeyPressed = undefined; + }, true)); + + disposables.add(addDisposableListener(window.document.body, 'mouseup', () => { + this._keyStatus.lastKeyPressed = undefined; + }, true)); + + disposables.add(addDisposableListener(window.document.body, 'mousemove', e => { + if (e.buttons) { + this._keyStatus.lastKeyPressed = undefined; + } + }, true)); + + disposables.add(addDisposableListener(window, 'blur', () => { + this.resetKeyStatus(); + })); + } + + get keyStatus(): IModifierKeyStatus { + return this._keyStatus; + } + + get isModifierPressed(): boolean { + return this._keyStatus.altKey || this._keyStatus.ctrlKey || this._keyStatus.metaKey || this._keyStatus.shiftKey; + } + + /** + * Allows to explicitly reset the key status based on more knowledge (#109062) + */ + resetKeyStatus(): void { + this.doResetKeyStatus(); + this.fire(this._keyStatus); + } + + private doResetKeyStatus(): void { + this._keyStatus = { + altKey: false, + shiftKey: false, + ctrlKey: false, + metaKey: false + }; + } + + static getInstance() { + if (!ModifierKeyEmitter.instance) { + ModifierKeyEmitter.instance = new ModifierKeyEmitter(); + } + + return ModifierKeyEmitter.instance; + } + + override dispose() { + super.dispose(); + this._subscriptions.dispose(); + } +} + +export function getCookieValue(name: string): string | undefined { + const match = document.cookie.match('(^|[^;]+)\\s*' + name + '\\s*=\\s*([^;]+)'); // See https://stackoverflow.com/a/25490531 + + return match ? match.pop() : undefined; +} + +export interface IDragAndDropObserverCallbacks { + readonly onDragEnter?: (e: DragEvent) => void; + readonly onDragLeave?: (e: DragEvent) => void; + readonly onDrop?: (e: DragEvent) => void; + readonly onDragEnd?: (e: DragEvent) => void; + readonly onDragStart?: (e: DragEvent) => void; + readonly onDrag?: (e: DragEvent) => void; + readonly onDragOver?: (e: DragEvent, dragDuration: number) => void; +} + +export class DragAndDropObserver extends Disposable { + + // A helper to fix issues with repeated DRAG_ENTER / DRAG_LEAVE + // calls see https://github.com/microsoft/vscode/issues/14470 + // when the element has child elements where the events are fired + // repeadedly. + private counter: number = 0; + + // Allows to measure the duration of the drag operation. + private dragStartTime = 0; + + constructor(private readonly element: HTMLElement, private readonly callbacks: IDragAndDropObserverCallbacks) { + super(); + + this.registerListeners(); + } + + private registerListeners(): void { + if (this.callbacks.onDragStart) { + this._register(addDisposableListener(this.element, EventType.DRAG_START, (e: DragEvent) => { + this.callbacks.onDragStart?.(e); + })); + } + + if (this.callbacks.onDrag) { + this._register(addDisposableListener(this.element, EventType.DRAG, (e: DragEvent) => { + this.callbacks.onDrag?.(e); + })); + } + + this._register(addDisposableListener(this.element, EventType.DRAG_ENTER, (e: DragEvent) => { + this.counter++; + this.dragStartTime = e.timeStamp; + + this.callbacks.onDragEnter?.(e); + })); + + this._register(addDisposableListener(this.element, EventType.DRAG_OVER, (e: DragEvent) => { + e.preventDefault(); // needed so that the drop event fires (https://stackoverflow.com/questions/21339924/drop-event-not-firing-in-chrome) + + this.callbacks.onDragOver?.(e, e.timeStamp - this.dragStartTime); + })); + + this._register(addDisposableListener(this.element, EventType.DRAG_LEAVE, (e: DragEvent) => { + this.counter--; + + if (this.counter === 0) { + this.dragStartTime = 0; + + this.callbacks.onDragLeave?.(e); + } + })); + + this._register(addDisposableListener(this.element, EventType.DRAG_END, (e: DragEvent) => { + this.counter = 0; + this.dragStartTime = 0; + + this.callbacks.onDragEnd?.(e); + })); + + this._register(addDisposableListener(this.element, EventType.DROP, (e: DragEvent) => { + this.counter = 0; + this.dragStartTime = 0; + + this.callbacks.onDrop?.(e); + })); + } +} + +type HTMLElementAttributeKeys = Partial<{ [K in keyof T]: T[K] extends Function ? never : T[K] extends object ? HTMLElementAttributeKeys : T[K] }>; +type ElementAttributes = HTMLElementAttributeKeys & Record; +type RemoveHTMLElement = T extends HTMLElement ? never : T; +type UnionToIntersection = (U extends any ? (k: U) => void : never) extends ((k: infer I) => void) ? I : never; +type ArrayToObj = UnionToIntersection>; +type HHTMLElementTagNameMap = HTMLElementTagNameMap & { '': HTMLDivElement }; + +type TagToElement = T extends `${infer TStart}#${string}` + ? TStart extends keyof HHTMLElementTagNameMap + ? HHTMLElementTagNameMap[TStart] + : HTMLElement + : T extends `${infer TStart}.${string}` + ? TStart extends keyof HHTMLElementTagNameMap + ? HHTMLElementTagNameMap[TStart] + : HTMLElement + : T extends keyof HTMLElementTagNameMap + ? HTMLElementTagNameMap[T] + : HTMLElement; + +type TagToElementAndId = TTag extends `${infer TTag}@${infer TId}` + ? { element: TagToElement; id: TId } + : { element: TagToElement; id: 'root' }; + +type TagToRecord = TagToElementAndId extends { element: infer TElement; id: infer TId } + ? Record<(TId extends string ? TId : never) | 'root', TElement> + : never; + +type Child = HTMLElement | string | Record; + +const H_REGEX = /(?[\w\-]+)?(?:#(?[\w\-]+))?(?(?:\.(?:[\w\-]+))*)(?:@(?(?:[\w\_])+))?/; + +/** + * A helper function to create nested dom nodes. + * + * + * ```ts + * const elements = h('div.code-view', [ + * h('div.title@title'), + * h('div.container', [ + * h('div.gutter@gutterDiv'), + * h('div@editor'), + * ]), + * ]); + * const editor = createEditor(elements.editor); + * ``` +*/ +export function h + (tag: TTag): + TagToRecord extends infer Y ? { [TKey in keyof Y]: Y[TKey] } : never; + +export function h + (tag: TTag, children: [...T]): + (ArrayToObj & TagToRecord) extends infer Y ? { [TKey in keyof Y]: Y[TKey] } : never; + +export function h + (tag: TTag, attributes: Partial>>): + TagToRecord extends infer Y ? { [TKey in keyof Y]: Y[TKey] } : never; + +export function h + (tag: TTag, attributes: Partial>>, children: [...T]): + (ArrayToObj & TagToRecord) extends infer Y ? { [TKey in keyof Y]: Y[TKey] } : never; + +export function h(tag: string, ...args: [] | [attributes: { $: string } & Partial> | Record, children?: any[]] | [children: any[]]): Record { + let attributes: { $?: string } & Partial>; + let children: (Record | HTMLElement)[] | undefined; + + if (Array.isArray(args[0])) { + attributes = {}; + children = args[0]; + } else { + attributes = args[0] as any || {}; + children = args[1]; + } + + const match = H_REGEX.exec(tag); + + if (!match || !match.groups) { + throw new Error('Bad use of h'); + } + + const tagName = match.groups['tag'] || 'div'; + const el = document.createElement(tagName); + + if (match.groups['id']) { + el.id = match.groups['id']; + } + + const classNames = []; + if (match.groups['class']) { + for (const className of match.groups['class'].split('.')) { + if (className !== '') { + classNames.push(className); + } + } + } + if (attributes.className !== undefined) { + for (const className of attributes.className.split('.')) { + if (className !== '') { + classNames.push(className); + } + } + } + if (classNames.length > 0) { + el.className = classNames.join(' '); + } + + const result: Record = {}; + + if (match.groups['name']) { + result[match.groups['name']] = el; + } + + if (children) { + for (const c of children) { + if (isHTMLElement(c)) { + el.appendChild(c); + } else if (typeof c === 'string') { + el.append(c); + } else if ('root' in c) { + Object.assign(result, c); + el.appendChild(c.root); + } + } + } + + for (const [key, value] of Object.entries(attributes)) { + if (key === 'className') { + continue; + } else if (key === 'style') { + for (const [cssKey, cssValue] of Object.entries(value)) { + el.style.setProperty( + camelCaseToHyphenCase(cssKey), + typeof cssValue === 'number' ? cssValue + 'px' : '' + cssValue + ); + } + } else if (key === 'tabIndex') { + el.tabIndex = value; + } else { + el.setAttribute(camelCaseToHyphenCase(key), value.toString()); + } + } + + result['root'] = el; + + return result; +} + +export function svgElem + (tag: TTag): + TagToRecord extends infer Y ? { [TKey in keyof Y]: Y[TKey] } : never; + +export function svgElem + (tag: TTag, children: [...T]): + (ArrayToObj & TagToRecord) extends infer Y ? { [TKey in keyof Y]: Y[TKey] } : never; + +export function svgElem + (tag: TTag, attributes: Partial>>): + TagToRecord extends infer Y ? { [TKey in keyof Y]: Y[TKey] } : never; + +export function svgElem + (tag: TTag, attributes: Partial>>, children: [...T]): + (ArrayToObj & TagToRecord) extends infer Y ? { [TKey in keyof Y]: Y[TKey] } : never; + +export function svgElem(tag: string, ...args: [] | [attributes: { $: string } & Partial> | Record, children?: any[]] | [children: any[]]): Record { + let attributes: { $?: string } & Partial>; + let children: (Record | HTMLElement)[] | undefined; + + if (Array.isArray(args[0])) { + attributes = {}; + children = args[0]; + } else { + attributes = args[0] as any || {}; + children = args[1]; + } + + const match = H_REGEX.exec(tag); + + if (!match || !match.groups) { + throw new Error('Bad use of h'); + } + + const tagName = match.groups['tag'] || 'div'; + const el = document.createElementNS('http://www.w3.org/2000/svg', tagName) as any as HTMLElement; + + if (match.groups['id']) { + el.id = match.groups['id']; + } + + const classNames = []; + if (match.groups['class']) { + for (const className of match.groups['class'].split('.')) { + if (className !== '') { + classNames.push(className); + } + } + } + if (attributes.className !== undefined) { + for (const className of attributes.className.split('.')) { + if (className !== '') { + classNames.push(className); + } + } + } + if (classNames.length > 0) { + el.className = classNames.join(' '); + } + + const result: Record = {}; + + if (match.groups['name']) { + result[match.groups['name']] = el; + } + + if (children) { + for (const c of children) { + if (isHTMLElement(c)) { + el.appendChild(c); + } else if (typeof c === 'string') { + el.append(c); + } else if ('root' in c) { + Object.assign(result, c); + el.appendChild(c.root); + } + } + } + + for (const [key, value] of Object.entries(attributes)) { + if (key === 'className') { + continue; + } else if (key === 'style') { + for (const [cssKey, cssValue] of Object.entries(value)) { + el.style.setProperty( + camelCaseToHyphenCase(cssKey), + typeof cssValue === 'number' ? cssValue + 'px' : '' + cssValue + ); + } + } else if (key === 'tabIndex') { + el.tabIndex = value; + } else { + el.setAttribute(camelCaseToHyphenCase(key), value.toString()); + } + } + + result['root'] = el; + + return result; +} + +function camelCaseToHyphenCase(str: string) { + return str.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase(); +} + +export function copyAttributes(from: Element, to: Element, filter?: string[]): void { + for (const { name, value } of from.attributes) { + if (!filter || filter.includes(name)) { + to.setAttribute(name, value); + } + } +} + +function copyAttribute(from: Element, to: Element, name: string): void { + const value = from.getAttribute(name); + if (value) { + to.setAttribute(name, value); + } else { + to.removeAttribute(name); + } +} + +export function trackAttributes(from: Element, to: Element, filter?: string[]): IDisposable { + copyAttributes(from, to, filter); + + const disposables = new DisposableStore(); + + disposables.add(sharedMutationObserver.observe(from, disposables, { attributes: true, attributeFilter: filter })(mutations => { + for (const mutation of mutations) { + if (mutation.type === 'attributes' && mutation.attributeName) { + copyAttribute(from, to, mutation.attributeName); + } + } + })); + + return disposables; +} + +/** + * Helper for calculating the "safe triangle" occluded by hovers to avoid early dismissal. + * @see https://www.smashingmagazine.com/2023/08/better-context-menus-safe-triangles/ for example + */ +export class SafeTriangle { + // 4 triangles, 2 points (x, y) stored for each + private triangles: number[] = []; + + constructor( + private readonly originX: number, + private readonly originY: number, + target: HTMLElement + ) { + const { top, left, right, bottom } = target.getBoundingClientRect(); + const t = this.triangles; + let i = 0; + + t[i++] = left; + t[i++] = top; + t[i++] = right; + t[i++] = top; + + t[i++] = left; + t[i++] = top; + t[i++] = left; + t[i++] = bottom; + + t[i++] = right; + t[i++] = top; + t[i++] = right; + t[i++] = bottom; + + t[i++] = left; + t[i++] = bottom; + t[i++] = right; + t[i++] = bottom; + } + + public contains(x: number, y: number) { + const { triangles, originX, originY } = this; + for (let i = 0; i < 4; i++) { + if (isPointWithinTriangle(x, y, originX, originY, triangles[2 * i], triangles[2 * i + 1], triangles[2 * i + 2], triangles[2 * i + 3])) { + return true; + } + } + + return false; + } +} diff --git a/src/vs/base/browser/domObservable.ts b/src/vs/base/browser/domObservable.ts new file mode 100644 index 0000000000..dd20637727 --- /dev/null +++ b/src/vs/base/browser/domObservable.ts @@ -0,0 +1,17 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { createStyleSheet2 } from 'vs/base/browser/dom'; +import { DisposableStore, IDisposable } from 'vs/base/common/lifecycle'; +import { autorun, IObservable } from 'vs/base/common/observable'; + +export function createStyleSheetFromObservable(css: IObservable): IDisposable { + const store = new DisposableStore(); + const w = store.add(createStyleSheet2()); + store.add(autorun(reader => { + w.setStyle(css.read(reader)); + })); + return store; +} diff --git a/src/vs/base/browser/event.ts b/src/vs/base/browser/event.ts new file mode 100644 index 0000000000..760f8646b0 --- /dev/null +++ b/src/vs/base/browser/event.ts @@ -0,0 +1,50 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { GestureEvent } from 'vs/base/browser/touch'; +import { Emitter, Event as BaseEvent } from 'vs/base/common/event'; +import { IDisposable } from 'vs/base/common/lifecycle'; + +export type EventHandler = HTMLElement | HTMLDocument | Window; + +export interface IDomEvent { + (element: EventHandler, type: K, useCapture?: boolean): BaseEvent; + (element: EventHandler, type: string, useCapture?: boolean): BaseEvent; +} + +export interface DOMEventMap extends HTMLElementEventMap, DocumentEventMap, WindowEventMap { + '-monaco-gesturetap': GestureEvent; + '-monaco-gesturechange': GestureEvent; + '-monaco-gesturestart': GestureEvent; + '-monaco-gesturesend': GestureEvent; + '-monaco-gesturecontextmenu': GestureEvent; + 'compositionstart': CompositionEvent; + 'compositionupdate': CompositionEvent; + 'compositionend': CompositionEvent; +} + +export class DomEmitter implements IDisposable { + + private emitter: Emitter; + + get event(): BaseEvent { + return this.emitter.event; + } + + constructor(element: Window & typeof globalThis, type: WindowEventMap, useCapture?: boolean); + constructor(element: Document, type: DocumentEventMap, useCapture?: boolean); + constructor(element: EventHandler, type: K, useCapture?: boolean); + constructor(element: EventHandler, type: K, useCapture?: boolean) { + const fn = (e: Event) => this.emitter.fire(e as DOMEventMap[K]); + this.emitter = new Emitter({ + onWillAddFirstListener: () => element.addEventListener(type, fn, useCapture), + onDidRemoveLastListener: () => element.removeEventListener(type, fn, useCapture) + }); + } + + dispose(): void { + this.emitter.dispose(); + } +} diff --git a/src/vs/base/browser/fastDomNode.ts b/src/vs/base/browser/fastDomNode.ts new file mode 100644 index 0000000000..1ef17e48cd --- /dev/null +++ b/src/vs/base/browser/fastDomNode.ts @@ -0,0 +1,316 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export class FastDomNode { + + private _maxWidth: string = ''; + private _width: string = ''; + private _height: string = ''; + private _top: string = ''; + private _left: string = ''; + private _bottom: string = ''; + private _right: string = ''; + private _paddingTop: string = ''; + private _paddingLeft: string = ''; + private _paddingBottom: string = ''; + private _paddingRight: string = ''; + private _fontFamily: string = ''; + private _fontWeight: string = ''; + private _fontSize: string = ''; + private _fontStyle: string = ''; + private _fontFeatureSettings: string = ''; + private _fontVariationSettings: string = ''; + private _textDecoration: string = ''; + private _lineHeight: string = ''; + private _letterSpacing: string = ''; + private _className: string = ''; + private _display: string = ''; + private _position: string = ''; + private _visibility: string = ''; + private _color: string = ''; + private _backgroundColor: string = ''; + private _layerHint: boolean = false; + private _contain: 'none' | 'strict' | 'content' | 'size' | 'layout' | 'style' | 'paint' = 'none'; + private _boxShadow: string = ''; + + constructor( + public readonly domNode: T + ) { } + + public setMaxWidth(_maxWidth: number | string): void { + const maxWidth = numberAsPixels(_maxWidth); + if (this._maxWidth === maxWidth) { + return; + } + this._maxWidth = maxWidth; + this.domNode.style.maxWidth = this._maxWidth; + } + + public setWidth(_width: number | string): void { + const width = numberAsPixels(_width); + if (this._width === width) { + return; + } + this._width = width; + this.domNode.style.width = this._width; + } + + public setHeight(_height: number | string): void { + const height = numberAsPixels(_height); + if (this._height === height) { + return; + } + this._height = height; + this.domNode.style.height = this._height; + } + + public setTop(_top: number | string): void { + const top = numberAsPixels(_top); + if (this._top === top) { + return; + } + this._top = top; + this.domNode.style.top = this._top; + } + + public setLeft(_left: number | string): void { + const left = numberAsPixels(_left); + if (this._left === left) { + return; + } + this._left = left; + this.domNode.style.left = this._left; + } + + public setBottom(_bottom: number | string): void { + const bottom = numberAsPixels(_bottom); + if (this._bottom === bottom) { + return; + } + this._bottom = bottom; + this.domNode.style.bottom = this._bottom; + } + + public setRight(_right: number | string): void { + const right = numberAsPixels(_right); + if (this._right === right) { + return; + } + this._right = right; + this.domNode.style.right = this._right; + } + + public setPaddingTop(_paddingTop: number | string): void { + const paddingTop = numberAsPixels(_paddingTop); + if (this._paddingTop === paddingTop) { + return; + } + this._paddingTop = paddingTop; + this.domNode.style.paddingTop = this._paddingTop; + } + + public setPaddingLeft(_paddingLeft: number | string): void { + const paddingLeft = numberAsPixels(_paddingLeft); + if (this._paddingLeft === paddingLeft) { + return; + } + this._paddingLeft = paddingLeft; + this.domNode.style.paddingLeft = this._paddingLeft; + } + + public setPaddingBottom(_paddingBottom: number | string): void { + const paddingBottom = numberAsPixels(_paddingBottom); + if (this._paddingBottom === paddingBottom) { + return; + } + this._paddingBottom = paddingBottom; + this.domNode.style.paddingBottom = this._paddingBottom; + } + + public setPaddingRight(_paddingRight: number | string): void { + const paddingRight = numberAsPixels(_paddingRight); + if (this._paddingRight === paddingRight) { + return; + } + this._paddingRight = paddingRight; + this.domNode.style.paddingRight = this._paddingRight; + } + + public setFontFamily(fontFamily: string): void { + if (this._fontFamily === fontFamily) { + return; + } + this._fontFamily = fontFamily; + this.domNode.style.fontFamily = this._fontFamily; + } + + public setFontWeight(fontWeight: string): void { + if (this._fontWeight === fontWeight) { + return; + } + this._fontWeight = fontWeight; + this.domNode.style.fontWeight = this._fontWeight; + } + + public setFontSize(_fontSize: number | string): void { + const fontSize = numberAsPixels(_fontSize); + if (this._fontSize === fontSize) { + return; + } + this._fontSize = fontSize; + this.domNode.style.fontSize = this._fontSize; + } + + public setFontStyle(fontStyle: string): void { + if (this._fontStyle === fontStyle) { + return; + } + this._fontStyle = fontStyle; + this.domNode.style.fontStyle = this._fontStyle; + } + + public setFontFeatureSettings(fontFeatureSettings: string): void { + if (this._fontFeatureSettings === fontFeatureSettings) { + return; + } + this._fontFeatureSettings = fontFeatureSettings; + this.domNode.style.fontFeatureSettings = this._fontFeatureSettings; + } + + public setFontVariationSettings(fontVariationSettings: string): void { + if (this._fontVariationSettings === fontVariationSettings) { + return; + } + this._fontVariationSettings = fontVariationSettings; + this.domNode.style.fontVariationSettings = this._fontVariationSettings; + } + + public setTextDecoration(textDecoration: string): void { + if (this._textDecoration === textDecoration) { + return; + } + this._textDecoration = textDecoration; + this.domNode.style.textDecoration = this._textDecoration; + } + + public setLineHeight(_lineHeight: number | string): void { + const lineHeight = numberAsPixels(_lineHeight); + if (this._lineHeight === lineHeight) { + return; + } + this._lineHeight = lineHeight; + this.domNode.style.lineHeight = this._lineHeight; + } + + public setLetterSpacing(_letterSpacing: number | string): void { + const letterSpacing = numberAsPixels(_letterSpacing); + if (this._letterSpacing === letterSpacing) { + return; + } + this._letterSpacing = letterSpacing; + this.domNode.style.letterSpacing = this._letterSpacing; + } + + public setClassName(className: string): void { + if (this._className === className) { + return; + } + this._className = className; + this.domNode.className = this._className; + } + + public toggleClassName(className: string, shouldHaveIt?: boolean): void { + this.domNode.classList.toggle(className, shouldHaveIt); + this._className = this.domNode.className; + } + + public setDisplay(display: string): void { + if (this._display === display) { + return; + } + this._display = display; + this.domNode.style.display = this._display; + } + + public setPosition(position: string): void { + if (this._position === position) { + return; + } + this._position = position; + this.domNode.style.position = this._position; + } + + public setVisibility(visibility: string): void { + if (this._visibility === visibility) { + return; + } + this._visibility = visibility; + this.domNode.style.visibility = this._visibility; + } + + public setColor(color: string): void { + if (this._color === color) { + return; + } + this._color = color; + this.domNode.style.color = this._color; + } + + public setBackgroundColor(backgroundColor: string): void { + if (this._backgroundColor === backgroundColor) { + return; + } + this._backgroundColor = backgroundColor; + this.domNode.style.backgroundColor = this._backgroundColor; + } + + public setLayerHinting(layerHint: boolean): void { + if (this._layerHint === layerHint) { + return; + } + this._layerHint = layerHint; + this.domNode.style.transform = this._layerHint ? 'translate3d(0px, 0px, 0px)' : ''; + } + + public setBoxShadow(boxShadow: string): void { + if (this._boxShadow === boxShadow) { + return; + } + this._boxShadow = boxShadow; + this.domNode.style.boxShadow = boxShadow; + } + + public setContain(contain: 'none' | 'strict' | 'content' | 'size' | 'layout' | 'style' | 'paint'): void { + if (this._contain === contain) { + return; + } + this._contain = contain; + (this.domNode.style).contain = this._contain; + } + + public setAttribute(name: string, value: string): void { + this.domNode.setAttribute(name, value); + } + + public removeAttribute(name: string): void { + this.domNode.removeAttribute(name); + } + + public appendChild(child: FastDomNode): void { + this.domNode.appendChild(child.domNode); + } + + public removeChild(child: FastDomNode): void { + this.domNode.removeChild(child.domNode); + } +} + +function numberAsPixels(value: number | string): string { + return (typeof value === 'number' ? `${value}px` : value); +} + +export function createFastDomNode(domNode: T): FastDomNode { + return new FastDomNode(domNode); +} diff --git a/src/vs/base/browser/fonts.ts b/src/vs/base/browser/fonts.ts new file mode 100644 index 0000000000..a5e78d00be --- /dev/null +++ b/src/vs/base/browser/fonts.ts @@ -0,0 +1,16 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { isMacintosh, isWindows } from 'vs/base/common/platform'; + +/** + * The best font-family to be used in CSS based on the platform: + * - Windows: Segoe preferred, fallback to sans-serif + * - macOS: standard system font, fallback to sans-serif + * - Linux: standard system font preferred, fallback to Ubuntu fonts + * + * Note: this currently does not adjust for different locales. + */ +export const DEFAULT_FONT_FAMILY = isWindows ? '"Segoe WPC", "Segoe UI", sans-serif' : isMacintosh ? '-apple-system, BlinkMacSystemFont, sans-serif' : 'system-ui, "Ubuntu", "Droid Sans", sans-serif'; diff --git a/src/vs/base/browser/formattedTextRenderer.ts b/src/vs/base/browser/formattedTextRenderer.ts new file mode 100644 index 0000000000..12371671c7 --- /dev/null +++ b/src/vs/base/browser/formattedTextRenderer.ts @@ -0,0 +1,226 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as DOM from 'vs/base/browser/dom'; +import { IKeyboardEvent } from 'vs/base/browser/keyboardEvent'; +import { IMouseEvent } from 'vs/base/browser/mouseEvent'; +import { DisposableStore } from 'vs/base/common/lifecycle'; + +export interface IContentActionHandler { + callback: (content: string, event: IMouseEvent | IKeyboardEvent) => void; + readonly disposables: DisposableStore; +} + +export interface FormattedTextRenderOptions { + readonly className?: string; + readonly inline?: boolean; + readonly actionHandler?: IContentActionHandler; + readonly renderCodeSegments?: boolean; +} + +export function renderText(text: string, options: FormattedTextRenderOptions = {}): HTMLElement { + const element = createElement(options); + element.textContent = text; + return element; +} + +export function renderFormattedText(formattedText: string, options: FormattedTextRenderOptions = {}): HTMLElement { + const element = createElement(options); + _renderFormattedText(element, parseFormattedText(formattedText, !!options.renderCodeSegments), options.actionHandler, options.renderCodeSegments); + return element; +} + +export function createElement(options: FormattedTextRenderOptions): HTMLElement { + const tagName = options.inline ? 'span' : 'div'; + const element = document.createElement(tagName); + if (options.className) { + element.className = options.className; + } + return element; +} + +class StringStream { + private source: string; + private index: number; + + constructor(source: string) { + this.source = source; + this.index = 0; + } + + public eos(): boolean { + return this.index >= this.source.length; + } + + public next(): string { + const next = this.peek(); + this.advance(); + return next; + } + + public peek(): string { + return this.source[this.index]; + } + + public advance(): void { + this.index++; + } +} + +const enum FormatType { + Invalid, + Root, + Text, + Bold, + Italics, + Action, + ActionClose, + Code, + NewLine +} + +interface IFormatParseTree { + type: FormatType; + content?: string; + index?: number; + children?: IFormatParseTree[]; +} + +function _renderFormattedText(element: Node, treeNode: IFormatParseTree, actionHandler?: IContentActionHandler, renderCodeSegments?: boolean) { + let child: Node | undefined; + + if (treeNode.type === FormatType.Text) { + child = document.createTextNode(treeNode.content || ''); + } else if (treeNode.type === FormatType.Bold) { + child = document.createElement('b'); + } else if (treeNode.type === FormatType.Italics) { + child = document.createElement('i'); + } else if (treeNode.type === FormatType.Code && renderCodeSegments) { + child = document.createElement('code'); + } else if (treeNode.type === FormatType.Action && actionHandler) { + const a = document.createElement('a'); + actionHandler.disposables.add(DOM.addStandardDisposableListener(a, 'click', (event) => { + actionHandler.callback(String(treeNode.index), event); + })); + + child = a; + } else if (treeNode.type === FormatType.NewLine) { + child = document.createElement('br'); + } else if (treeNode.type === FormatType.Root) { + child = element; + } + + if (child && element !== child) { + element.appendChild(child); + } + + if (child && Array.isArray(treeNode.children)) { + treeNode.children.forEach((nodeChild) => { + _renderFormattedText(child, nodeChild, actionHandler, renderCodeSegments); + }); + } +} + +function parseFormattedText(content: string, parseCodeSegments: boolean): IFormatParseTree { + + const root: IFormatParseTree = { + type: FormatType.Root, + children: [] + }; + + let actionViewItemIndex = 0; + let current = root; + const stack: IFormatParseTree[] = []; + const stream = new StringStream(content); + + while (!stream.eos()) { + let next = stream.next(); + + const isEscapedFormatType = (next === '\\' && formatTagType(stream.peek(), parseCodeSegments) !== FormatType.Invalid); + if (isEscapedFormatType) { + next = stream.next(); // unread the backslash if it escapes a format tag type + } + + if (!isEscapedFormatType && isFormatTag(next, parseCodeSegments) && next === stream.peek()) { + stream.advance(); + + if (current.type === FormatType.Text) { + current = stack.pop()!; + } + + const type = formatTagType(next, parseCodeSegments); + if (current.type === type || (current.type === FormatType.Action && type === FormatType.ActionClose)) { + current = stack.pop()!; + } else { + const newCurrent: IFormatParseTree = { + type: type, + children: [] + }; + + if (type === FormatType.Action) { + newCurrent.index = actionViewItemIndex; + actionViewItemIndex++; + } + + current.children!.push(newCurrent); + stack.push(current); + current = newCurrent; + } + } else if (next === '\n') { + if (current.type === FormatType.Text) { + current = stack.pop()!; + } + + current.children!.push({ + type: FormatType.NewLine + }); + + } else { + if (current.type !== FormatType.Text) { + const textCurrent: IFormatParseTree = { + type: FormatType.Text, + content: next + }; + current.children!.push(textCurrent); + stack.push(current); + current = textCurrent; + + } else { + current.content += next; + } + } + } + + if (current.type === FormatType.Text) { + current = stack.pop()!; + } + + if (stack.length) { + // incorrectly formatted string literal + } + + return root; +} + +function isFormatTag(char: string, supportCodeSegments: boolean): boolean { + return formatTagType(char, supportCodeSegments) !== FormatType.Invalid; +} + +function formatTagType(char: string, supportCodeSegments: boolean): FormatType { + switch (char) { + case '*': + return FormatType.Bold; + case '_': + return FormatType.Italics; + case '[': + return FormatType.Action; + case ']': + return FormatType.ActionClose; + case '`': + return supportCodeSegments ? FormatType.Code : FormatType.Invalid; + default: + return FormatType.Invalid; + } +} diff --git a/src/vs/base/browser/globalPointerMoveMonitor.ts b/src/vs/base/browser/globalPointerMoveMonitor.ts new file mode 100644 index 0000000000..9841596cdd --- /dev/null +++ b/src/vs/base/browser/globalPointerMoveMonitor.ts @@ -0,0 +1,112 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as dom from 'vs/base/browser/dom'; +import { DisposableStore, IDisposable, toDisposable } from 'vs/base/common/lifecycle'; + +export interface IPointerMoveCallback { + (event: PointerEvent): void; +} + +export interface IOnStopCallback { + (browserEvent?: PointerEvent | KeyboardEvent): void; +} + +export class GlobalPointerMoveMonitor implements IDisposable { + + private readonly _hooks = new DisposableStore(); + private _pointerMoveCallback: IPointerMoveCallback | null = null; + private _onStopCallback: IOnStopCallback | null = null; + + public dispose(): void { + this.stopMonitoring(false); + this._hooks.dispose(); + } + + public stopMonitoring(invokeStopCallback: boolean, browserEvent?: PointerEvent | KeyboardEvent): void { + if (!this.isMonitoring()) { + // Not monitoring + return; + } + + // Unhook + this._hooks.clear(); + this._pointerMoveCallback = null; + const onStopCallback = this._onStopCallback; + this._onStopCallback = null; + + if (invokeStopCallback && onStopCallback) { + onStopCallback(browserEvent); + } + } + + public isMonitoring(): boolean { + return !!this._pointerMoveCallback; + } + + public startMonitoring( + initialElement: Element, + pointerId: number, + initialButtons: number, + pointerMoveCallback: IPointerMoveCallback, + onStopCallback: IOnStopCallback + ): void { + if (this.isMonitoring()) { + this.stopMonitoring(false); + } + this._pointerMoveCallback = pointerMoveCallback; + this._onStopCallback = onStopCallback; + + let eventSource: Element | Window = initialElement; + + try { + initialElement.setPointerCapture(pointerId); + this._hooks.add(toDisposable(() => { + try { + initialElement.releasePointerCapture(pointerId); + } catch (err) { + // See https://github.com/microsoft/vscode/issues/161731 + // + // `releasePointerCapture` sometimes fails when being invoked with the exception: + // DOMException: Failed to execute 'releasePointerCapture' on 'Element': + // No active pointer with the given id is found. + // + // There's no need to do anything in case of failure + } + })); + } catch (err) { + // See https://github.com/microsoft/vscode/issues/144584 + // See https://github.com/microsoft/vscode/issues/146947 + // `setPointerCapture` sometimes fails when being invoked + // from a `mousedown` listener on macOS and Windows + // and it always fails on Linux with the exception: + // DOMException: Failed to execute 'setPointerCapture' on 'Element': + // No active pointer with the given id is found. + // In case of failure, we bind the listeners on the window + eventSource = dom.getWindow(initialElement); + } + + this._hooks.add(dom.addDisposableListener( + eventSource, + dom.EventType.POINTER_MOVE, + (e) => { + if (e.buttons !== initialButtons) { + // Buttons state has changed in the meantime + this.stopMonitoring(true); + return; + } + + e.preventDefault(); + this._pointerMoveCallback!(e); + } + )); + + this._hooks.add(dom.addDisposableListener( + eventSource, + dom.EventType.POINTER_UP, + (e: PointerEvent) => this.stopMonitoring(true) + )); + } +} diff --git a/src/vs/base/browser/hash.ts b/src/vs/base/browser/hash.ts new file mode 100644 index 0000000000..a9a5b66900 --- /dev/null +++ b/src/vs/base/browser/hash.ts @@ -0,0 +1,32 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { VSBuffer } from 'vs/base/common/buffer'; +import { StringSHA1, toHexString } from 'vs/base/common/hash'; + +export async function sha1Hex(str: string): Promise { + + // Prefer to use browser's crypto module + if (globalThis?.crypto?.subtle) { + + // Careful to use `dontUseNodeBuffer` when passing the + // buffer to the browser `crypto` API. Users reported + // native crashes in certain cases that we could trace + // back to passing node.js `Buffer` around + // (https://github.com/microsoft/vscode/issues/114227) + const buffer = VSBuffer.fromString(str, { dontUseNodeBuffer: true }).buffer; + const hash = await globalThis.crypto.subtle.digest({ name: 'sha-1' }, buffer); + + return toHexString(hash); + } + + // Otherwise fallback to `StringSHA1` + else { + const computer = new StringSHA1(); + computer.update(str); + + return computer.digest(); + } +} diff --git a/src/vs/base/browser/history.ts b/src/vs/base/browser/history.ts new file mode 100644 index 0000000000..e31b68ed56 --- /dev/null +++ b/src/vs/base/browser/history.ts @@ -0,0 +1,20 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Event } from 'vs/base/common/event'; + +export interface IHistoryNavigationWidget { + + readonly element: HTMLElement; + + showPreviousValue(): void; + + showNextValue(): void; + + onDidFocus: Event; + + onDidBlur: Event; + +} diff --git a/src/vs/base/browser/iframe.ts b/src/vs/base/browser/iframe.ts new file mode 100644 index 0000000000..e8522e0311 --- /dev/null +++ b/src/vs/base/browser/iframe.ts @@ -0,0 +1,135 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Represents a window in a possible chain of iframes + */ +interface IWindowChainElement { + /** + * The window object for it + */ + readonly window: WeakRef; + /** + * The iframe element inside the window.parent corresponding to window + */ + readonly iframeElement: Element | null; +} + +const sameOriginWindowChainCache = new WeakMap(); + +function getParentWindowIfSameOrigin(w: Window): Window | null { + if (!w.parent || w.parent === w) { + return null; + } + + // Cannot really tell if we have access to the parent window unless we try to access something in it + try { + const location = w.location; + const parentLocation = w.parent.location; + if (location.origin !== 'null' && parentLocation.origin !== 'null' && location.origin !== parentLocation.origin) { + return null; + } + } catch (e) { + return null; + } + + return w.parent; +} + +export class IframeUtils { + + /** + * Returns a chain of embedded windows with the same origin (which can be accessed programmatically). + * Having a chain of length 1 might mean that the current execution environment is running outside of an iframe or inside an iframe embedded in a window with a different origin. + */ + private static getSameOriginWindowChain(targetWindow: Window): IWindowChainElement[] { + let windowChainCache = sameOriginWindowChainCache.get(targetWindow); + if (!windowChainCache) { + windowChainCache = []; + sameOriginWindowChainCache.set(targetWindow, windowChainCache); + let w: Window | null = targetWindow; + let parent: Window | null; + do { + parent = getParentWindowIfSameOrigin(w); + if (parent) { + windowChainCache.push({ + window: new WeakRef(w), + iframeElement: w.frameElement || null + }); + } else { + windowChainCache.push({ + window: new WeakRef(w), + iframeElement: null + }); + } + w = parent; + } while (w); + } + return windowChainCache.slice(0); + } + + /** + * Returns the position of `childWindow` relative to `ancestorWindow` + */ + public static getPositionOfChildWindowRelativeToAncestorWindow(childWindow: Window, ancestorWindow: Window | null) { + + if (!ancestorWindow || childWindow === ancestorWindow) { + return { + top: 0, + left: 0 + }; + } + + let top = 0, left = 0; + + const windowChain = this.getSameOriginWindowChain(childWindow); + + for (const windowChainEl of windowChain) { + const windowInChain = windowChainEl.window.deref(); + top += windowInChain?.scrollY ?? 0; + left += windowInChain?.scrollX ?? 0; + + if (windowInChain === ancestorWindow) { + break; + } + + if (!windowChainEl.iframeElement) { + break; + } + + const boundingRect = windowChainEl.iframeElement.getBoundingClientRect(); + top += boundingRect.top; + left += boundingRect.left; + } + + return { + top: top, + left: left + }; + } +} + +/** + * Returns a sha-256 composed of `parentOrigin` and `salt` converted to base 32 + */ +export async function parentOriginHash(parentOrigin: string, salt: string): Promise { + // This same code is also inlined at `src/vs/workbench/services/extensions/worker/webWorkerExtensionHostIframe.html` + if (!crypto.subtle) { + throw new Error(`'crypto.subtle' is not available so webviews will not work. This is likely because the editor is not running in a secure context (https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts).`); + } + + const strData = JSON.stringify({ parentOrigin, salt }); + const encoder = new TextEncoder(); + const arrData = encoder.encode(strData); + const hash = await crypto.subtle.digest('sha-256', arrData); + return sha256AsBase32(hash); +} + +function sha256AsBase32(bytes: ArrayBuffer): string { + const array = Array.from(new Uint8Array(bytes)); + const hexArray = array.map(b => b.toString(16).padStart(2, '0')).join(''); + // sha256 has 256 bits, so we need at most ceil(lg(2^256-1)/lg(32)) = 52 chars to represent it in base 32 + return BigInt(`0x${hexArray}`).toString(32).padStart(52, '0'); +} diff --git a/src/vs/base/browser/indexedDB.ts b/src/vs/base/browser/indexedDB.ts new file mode 100644 index 0000000000..6d56022c98 --- /dev/null +++ b/src/vs/base/browser/indexedDB.ts @@ -0,0 +1,177 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { toErrorMessage } from 'vs/base/common/errorMessage'; +import { ErrorNoTelemetry, getErrorMessage } from 'vs/base/common/errors'; +import { mark } from 'vs/base/common/performance'; + +class MissingStoresError extends Error { + constructor(readonly db: IDBDatabase) { + super('Missing stores'); + } +} + +export class DBClosedError extends Error { + readonly code = 'DBClosed'; + constructor(dbName: string) { + super(`IndexedDB database '${dbName}' is closed.`); + } +} + +export class IndexedDB { + + static async create(name: string, version: number | undefined, stores: string[]): Promise { + const database = await IndexedDB.openDatabase(name, version, stores); + return new IndexedDB(database, name); + } + + private static async openDatabase(name: string, version: number | undefined, stores: string[]): Promise { + mark(`code/willOpenDatabase/${name}`); + try { + return await IndexedDB.doOpenDatabase(name, version, stores); + } catch (err) { + if (err instanceof MissingStoresError) { + console.info(`Attempting to recreate the IndexedDB once.`, name); + + try { + // Try to delete the db + await IndexedDB.deleteDatabase(err.db); + } catch (error) { + console.error(`Error while deleting the IndexedDB`, getErrorMessage(error)); + throw error; + } + + return await IndexedDB.doOpenDatabase(name, version, stores); + } + + throw err; + } finally { + mark(`code/didOpenDatabase/${name}`); + } + } + + private static doOpenDatabase(name: string, version: number | undefined, stores: string[]): Promise { + return new Promise((c, e) => { + const request = indexedDB.open(name, version); + request.onerror = () => e(request.error); + request.onsuccess = () => { + const db = request.result; + for (const store of stores) { + if (!db.objectStoreNames.contains(store)) { + console.error(`Error while opening IndexedDB. Could not find '${store}'' object store`); + e(new MissingStoresError(db)); + return; + } + } + c(db); + }; + request.onupgradeneeded = () => { + const db = request.result; + for (const store of stores) { + if (!db.objectStoreNames.contains(store)) { + db.createObjectStore(store); + } + } + }; + }); + } + + private static deleteDatabase(database: IDBDatabase): Promise { + return new Promise((c, e) => { + // Close any opened connections + database.close(); + + // Delete the db + const deleteRequest = indexedDB.deleteDatabase(database.name); + deleteRequest.onerror = (err) => e(deleteRequest.error); + deleteRequest.onsuccess = () => c(); + }); + } + + private database: IDBDatabase | null = null; + private readonly pendingTransactions: IDBTransaction[] = []; + + constructor(database: IDBDatabase, private readonly name: string) { + this.database = database; + } + + hasPendingTransactions(): boolean { + return this.pendingTransactions.length > 0; + } + + close(): void { + if (this.pendingTransactions.length) { + this.pendingTransactions.splice(0, this.pendingTransactions.length).forEach(transaction => transaction.abort()); + } + this.database?.close(); + this.database = null; + } + + runInTransaction(store: string, transactionMode: IDBTransactionMode, dbRequestFn: (store: IDBObjectStore) => IDBRequest[]): Promise; + runInTransaction(store: string, transactionMode: IDBTransactionMode, dbRequestFn: (store: IDBObjectStore) => IDBRequest): Promise; + async runInTransaction(store: string, transactionMode: IDBTransactionMode, dbRequestFn: (store: IDBObjectStore) => IDBRequest | IDBRequest[]): Promise { + if (!this.database) { + throw new DBClosedError(this.name); + } + const transaction = this.database.transaction(store, transactionMode); + this.pendingTransactions.push(transaction); + return new Promise((c, e) => { + transaction.oncomplete = () => { + if (Array.isArray(request)) { + c(request.map(r => r.result)); + } else { + c(request.result); + } + }; + transaction.onerror = () => e(transaction.error ? ErrorNoTelemetry.fromError(transaction.error) : new ErrorNoTelemetry('unknown error')); + transaction.onabort = () => e(transaction.error ? ErrorNoTelemetry.fromError(transaction.error) : new ErrorNoTelemetry('unknown error')); + const request = dbRequestFn(transaction.objectStore(store)); + }).finally(() => this.pendingTransactions.splice(this.pendingTransactions.indexOf(transaction), 1)); + } + + async getKeyValues(store: string, isValid: (value: unknown) => value is V): Promise> { + if (!this.database) { + throw new DBClosedError(this.name); + } + const transaction = this.database.transaction(store, 'readonly'); + this.pendingTransactions.push(transaction); + return new Promise>(resolve => { + const items = new Map(); + + const objectStore = transaction.objectStore(store); + + // Open a IndexedDB Cursor to iterate over key/values + const cursor = objectStore.openCursor(); + if (!cursor) { + return resolve(items); // this means the `ItemTable` was empty + } + + // Iterate over rows of `ItemTable` until the end + cursor.onsuccess = () => { + if (cursor.result) { + + // Keep cursor key/value in our map + if (isValid(cursor.result.value)) { + items.set(cursor.result.key.toString(), cursor.result.value); + } + + // Advance cursor to next row + cursor.result.continue(); + } else { + resolve(items); // reached end of table + } + }; + + // Error handlers + const onError = (error: Error | null) => { + console.error(`IndexedDB getKeyValues(): ${toErrorMessage(error, true)}`); + + resolve(items); + }; + cursor.onerror = () => onError(cursor.error); + transaction.onerror = () => onError(transaction.error); + }).finally(() => this.pendingTransactions.splice(this.pendingTransactions.indexOf(transaction), 1)); + } +} diff --git a/src/vs/base/browser/keyboardEvent.ts b/src/vs/base/browser/keyboardEvent.ts new file mode 100644 index 0000000000..6aa5bf530f --- /dev/null +++ b/src/vs/base/browser/keyboardEvent.ts @@ -0,0 +1,213 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as browser from 'vs/base/browser/browser'; +import { EVENT_KEY_CODE_MAP, KeyCode, KeyCodeUtils, KeyMod } from 'vs/base/common/keyCodes'; +import { KeyCodeChord } from 'vs/base/common/keybindings'; +import * as platform from 'vs/base/common/platform'; + + + +function extractKeyCode(e: KeyboardEvent): KeyCode { + if (e.charCode) { + // "keypress" events mostly + const char = String.fromCharCode(e.charCode).toUpperCase(); + return KeyCodeUtils.fromString(char); + } + + const keyCode = e.keyCode; + + // browser quirks + if (keyCode === 3) { + return KeyCode.PauseBreak; + } else if (browser.isFirefox) { + switch (keyCode) { + case 59: return KeyCode.Semicolon; + case 60: + if (platform.isLinux) { return KeyCode.IntlBackslash; } + break; + case 61: return KeyCode.Equal; + // based on: https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/keyCode#numpad_keys + case 107: return KeyCode.NumpadAdd; + case 109: return KeyCode.NumpadSubtract; + case 173: return KeyCode.Minus; + case 224: + if (platform.isMacintosh) { return KeyCode.Meta; } + break; + } + } else if (browser.isWebKit) { + if (platform.isMacintosh && keyCode === 93) { + // the two meta keys in the Mac have different key codes (91 and 93) + return KeyCode.Meta; + } else if (!platform.isMacintosh && keyCode === 92) { + return KeyCode.Meta; + } + } + + // cross browser keycodes: + return EVENT_KEY_CODE_MAP[keyCode] || KeyCode.Unknown; +} + +export interface IKeyboardEvent { + + readonly _standardKeyboardEventBrand: true; + + readonly browserEvent: KeyboardEvent; + readonly target: HTMLElement; + + readonly ctrlKey: boolean; + readonly shiftKey: boolean; + readonly altKey: boolean; + readonly metaKey: boolean; + readonly altGraphKey: boolean; + readonly keyCode: KeyCode; + readonly code: string; + + /** + * @internal + */ + toKeyCodeChord(): KeyCodeChord; + equals(keybinding: number): boolean; + + preventDefault(): void; + stopPropagation(): void; +} + +const ctrlKeyMod = (platform.isMacintosh ? KeyMod.WinCtrl : KeyMod.CtrlCmd); +const altKeyMod = KeyMod.Alt; +const shiftKeyMod = KeyMod.Shift; +const metaKeyMod = (platform.isMacintosh ? KeyMod.CtrlCmd : KeyMod.WinCtrl); + +export function printKeyboardEvent(e: KeyboardEvent): string { + const modifiers: string[] = []; + if (e.ctrlKey) { + modifiers.push(`ctrl`); + } + if (e.shiftKey) { + modifiers.push(`shift`); + } + if (e.altKey) { + modifiers.push(`alt`); + } + if (e.metaKey) { + modifiers.push(`meta`); + } + return `modifiers: [${modifiers.join(',')}], code: ${e.code}, keyCode: ${e.keyCode}, key: ${e.key}`; +} + +export function printStandardKeyboardEvent(e: StandardKeyboardEvent): string { + const modifiers: string[] = []; + if (e.ctrlKey) { + modifiers.push(`ctrl`); + } + if (e.shiftKey) { + modifiers.push(`shift`); + } + if (e.altKey) { + modifiers.push(`alt`); + } + if (e.metaKey) { + modifiers.push(`meta`); + } + return `modifiers: [${modifiers.join(',')}], code: ${e.code}, keyCode: ${e.keyCode} ('${KeyCodeUtils.toString(e.keyCode)}')`; +} + +export class StandardKeyboardEvent implements IKeyboardEvent { + + readonly _standardKeyboardEventBrand = true; + + public readonly browserEvent: KeyboardEvent; + public readonly target: HTMLElement; + + public readonly ctrlKey: boolean; + public readonly shiftKey: boolean; + public readonly altKey: boolean; + public readonly metaKey: boolean; + public readonly altGraphKey: boolean; + public readonly keyCode: KeyCode; + public readonly code: string; + + private _asKeybinding: number; + private _asKeyCodeChord: KeyCodeChord; + + constructor(source: KeyboardEvent) { + const e = source; + + this.browserEvent = e; + this.target = e.target; + + this.ctrlKey = e.ctrlKey; + this.shiftKey = e.shiftKey; + this.altKey = e.altKey; + this.metaKey = e.metaKey; + this.altGraphKey = e.getModifierState?.('AltGraph'); + this.keyCode = extractKeyCode(e); + this.code = e.code; + + // console.info(e.type + ": keyCode: " + e.keyCode + ", which: " + e.which + ", charCode: " + e.charCode + ", detail: " + e.detail + " ====> " + this.keyCode + ' -- ' + KeyCode[this.keyCode]); + + this.ctrlKey = this.ctrlKey || this.keyCode === KeyCode.Ctrl; + this.altKey = this.altKey || this.keyCode === KeyCode.Alt; + this.shiftKey = this.shiftKey || this.keyCode === KeyCode.Shift; + this.metaKey = this.metaKey || this.keyCode === KeyCode.Meta; + + this._asKeybinding = this._computeKeybinding(); + this._asKeyCodeChord = this._computeKeyCodeChord(); + + // console.log(`code: ${e.code}, keyCode: ${e.keyCode}, key: ${e.key}`); + } + + public preventDefault(): void { + if (this.browserEvent && this.browserEvent.preventDefault) { + this.browserEvent.preventDefault(); + } + } + + public stopPropagation(): void { + if (this.browserEvent && this.browserEvent.stopPropagation) { + this.browserEvent.stopPropagation(); + } + } + + public toKeyCodeChord(): KeyCodeChord { + return this._asKeyCodeChord; + } + + public equals(other: number): boolean { + return this._asKeybinding === other; + } + + private _computeKeybinding(): number { + let key = KeyCode.Unknown; + if (this.keyCode !== KeyCode.Ctrl && this.keyCode !== KeyCode.Shift && this.keyCode !== KeyCode.Alt && this.keyCode !== KeyCode.Meta) { + key = this.keyCode; + } + + let result = 0; + if (this.ctrlKey) { + result |= ctrlKeyMod; + } + if (this.altKey) { + result |= altKeyMod; + } + if (this.shiftKey) { + result |= shiftKeyMod; + } + if (this.metaKey) { + result |= metaKeyMod; + } + result |= key; + + return result; + } + + private _computeKeyCodeChord(): KeyCodeChord { + let key = KeyCode.Unknown; + if (this.keyCode !== KeyCode.Ctrl && this.keyCode !== KeyCode.Shift && this.keyCode !== KeyCode.Alt && this.keyCode !== KeyCode.Meta) { + key = this.keyCode; + } + return new KeyCodeChord(this.ctrlKey, this.shiftKey, this.altKey, this.metaKey, key); + } +} diff --git a/src/vs/base/browser/mouseEvent.ts b/src/vs/base/browser/mouseEvent.ts new file mode 100644 index 0000000000..51de0152e2 --- /dev/null +++ b/src/vs/base/browser/mouseEvent.ts @@ -0,0 +1,229 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as browser from 'vs/base/browser/browser'; +import { IframeUtils } from 'vs/base/browser/iframe'; +import * as platform from 'vs/base/common/platform'; + +export interface IMouseEvent { + readonly browserEvent: MouseEvent; + readonly leftButton: boolean; + readonly middleButton: boolean; + readonly rightButton: boolean; + readonly buttons: number; + readonly target: HTMLElement; + readonly detail: number; + readonly posx: number; + readonly posy: number; + readonly ctrlKey: boolean; + readonly shiftKey: boolean; + readonly altKey: boolean; + readonly metaKey: boolean; + readonly timestamp: number; + + preventDefault(): void; + stopPropagation(): void; +} + +export class StandardMouseEvent implements IMouseEvent { + + public readonly browserEvent: MouseEvent; + + public readonly leftButton: boolean; + public readonly middleButton: boolean; + public readonly rightButton: boolean; + public readonly buttons: number; + public readonly target: HTMLElement; + public detail: number; + public readonly posx: number; + public readonly posy: number; + public readonly ctrlKey: boolean; + public readonly shiftKey: boolean; + public readonly altKey: boolean; + public readonly metaKey: boolean; + public readonly timestamp: number; + + constructor(targetWindow: Window, e: MouseEvent) { + this.timestamp = Date.now(); + this.browserEvent = e; + this.leftButton = e.button === 0; + this.middleButton = e.button === 1; + this.rightButton = e.button === 2; + this.buttons = e.buttons; + + this.target = e.target; + + this.detail = e.detail || 1; + if (e.type === 'dblclick') { + this.detail = 2; + } + this.ctrlKey = e.ctrlKey; + this.shiftKey = e.shiftKey; + this.altKey = e.altKey; + this.metaKey = e.metaKey; + + if (typeof e.pageX === 'number') { + this.posx = e.pageX; + this.posy = e.pageY; + } else { + // Probably hit by MSGestureEvent + this.posx = e.clientX + this.target.ownerDocument.body.scrollLeft + this.target.ownerDocument.documentElement.scrollLeft; + this.posy = e.clientY + this.target.ownerDocument.body.scrollTop + this.target.ownerDocument.documentElement.scrollTop; + } + + // Find the position of the iframe this code is executing in relative to the iframe where the event was captured. + const iframeOffsets = IframeUtils.getPositionOfChildWindowRelativeToAncestorWindow(targetWindow, e.view); + this.posx -= iframeOffsets.left; + this.posy -= iframeOffsets.top; + } + + public preventDefault(): void { + this.browserEvent.preventDefault(); + } + + public stopPropagation(): void { + this.browserEvent.stopPropagation(); + } +} + +export class DragMouseEvent extends StandardMouseEvent { + + public readonly dataTransfer: DataTransfer; + + constructor(targetWindow: Window, e: MouseEvent) { + super(targetWindow, e); + this.dataTransfer = (e).dataTransfer; + } +} + +export interface IMouseWheelEvent extends MouseEvent { + readonly wheelDelta: number; + readonly wheelDeltaX: number; + readonly wheelDeltaY: number; + + readonly deltaX: number; + readonly deltaY: number; + readonly deltaZ: number; + readonly deltaMode: number; +} + +interface IWebKitMouseWheelEvent { + wheelDeltaY: number; + wheelDeltaX: number; +} + +interface IGeckoMouseWheelEvent { + HORIZONTAL_AXIS: number; + VERTICAL_AXIS: number; + axis: number; + detail: number; +} + +export class StandardWheelEvent { + + public readonly browserEvent: IMouseWheelEvent | null; + public readonly deltaY: number; + public readonly deltaX: number; + public readonly target: Node; + + constructor(e: IMouseWheelEvent | null, deltaX: number = 0, deltaY: number = 0) { + + this.browserEvent = e || null; + this.target = e ? (e.target || (e).targetNode || e.srcElement) : null; + + this.deltaY = deltaY; + this.deltaX = deltaX; + + let shouldFactorDPR: boolean = false; + if (browser.isChrome) { + // Chrome version >= 123 contains the fix to factor devicePixelRatio into the wheel event. + // See https://chromium.googlesource.com/chromium/src.git/+/be51b448441ff0c9d1f17e0f25c4bf1ab3f11f61 + const chromeVersionMatch = navigator.userAgent.match(/Chrome\/(\d+)/); + const chromeMajorVersion = chromeVersionMatch ? parseInt(chromeVersionMatch[1]) : 123; + shouldFactorDPR = chromeMajorVersion <= 122; + } + + if (e) { + // Old (deprecated) wheel events + const e1 = e; + const e2 = e; + const devicePixelRatio = e.view?.devicePixelRatio || 1; + + // vertical delta scroll + if (typeof e1.wheelDeltaY !== 'undefined') { + if (shouldFactorDPR) { + // Refs https://github.com/microsoft/vscode/issues/146403#issuecomment-1854538928 + this.deltaY = e1.wheelDeltaY / (120 * devicePixelRatio); + } else { + this.deltaY = e1.wheelDeltaY / 120; + } + } else if (typeof e2.VERTICAL_AXIS !== 'undefined' && e2.axis === e2.VERTICAL_AXIS) { + this.deltaY = -e2.detail / 3; + } else if (e.type === 'wheel') { + // Modern wheel event + // https://developer.mozilla.org/en-US/docs/Web/API/WheelEvent + const ev = e; + + if (ev.deltaMode === ev.DOM_DELTA_LINE) { + // the deltas are expressed in lines + if (browser.isFirefox && !platform.isMacintosh) { + this.deltaY = -e.deltaY / 3; + } else { + this.deltaY = -e.deltaY; + } + } else { + this.deltaY = -e.deltaY / 40; + } + } + + // horizontal delta scroll + if (typeof e1.wheelDeltaX !== 'undefined') { + if (browser.isSafari && platform.isWindows) { + this.deltaX = - (e1.wheelDeltaX / 120); + } else if (shouldFactorDPR) { + // Refs https://github.com/microsoft/vscode/issues/146403#issuecomment-1854538928 + this.deltaX = e1.wheelDeltaX / (120 * devicePixelRatio); + } else { + this.deltaX = e1.wheelDeltaX / 120; + } + } else if (typeof e2.HORIZONTAL_AXIS !== 'undefined' && e2.axis === e2.HORIZONTAL_AXIS) { + this.deltaX = -e.detail / 3; + } else if (e.type === 'wheel') { + // Modern wheel event + // https://developer.mozilla.org/en-US/docs/Web/API/WheelEvent + const ev = e; + + if (ev.deltaMode === ev.DOM_DELTA_LINE) { + // the deltas are expressed in lines + if (browser.isFirefox && !platform.isMacintosh) { + this.deltaX = -e.deltaX / 3; + } else { + this.deltaX = -e.deltaX; + } + } else { + this.deltaX = -e.deltaX / 40; + } + } + + // Assume a vertical scroll if nothing else worked + if (this.deltaY === 0 && this.deltaX === 0 && e.wheelDelta) { + if (shouldFactorDPR) { + // Refs https://github.com/microsoft/vscode/issues/146403#issuecomment-1854538928 + this.deltaY = e.wheelDelta / (120 * devicePixelRatio); + } else { + this.deltaY = e.wheelDelta / 120; + } + } + } + } + + public preventDefault(): void { + this.browserEvent?.preventDefault(); + } + + public stopPropagation(): void { + this.browserEvent?.stopPropagation(); + } +} diff --git a/src/vs/base/browser/performance.ts b/src/vs/base/browser/performance.ts new file mode 100644 index 0000000000..dab46447a7 --- /dev/null +++ b/src/vs/base/browser/performance.ts @@ -0,0 +1,272 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export namespace inputLatency { + + // Measurements are recorded as totals, the average is calculated when the final measurements + // are created. + interface ICumulativeMeasurement { + total: number; + min: number; + max: number; + } + const totalKeydownTime: ICumulativeMeasurement = { total: 0, min: Number.MAX_VALUE, max: 0 }; + const totalInputTime: ICumulativeMeasurement = { ...totalKeydownTime }; + const totalRenderTime: ICumulativeMeasurement = { ...totalKeydownTime }; + const totalInputLatencyTime: ICumulativeMeasurement = { ...totalKeydownTime }; + let measurementsCount = 0; + + + + // The state of each event, this helps ensure the integrity of the measurement and that + // something unexpected didn't happen that could skew the measurement. + const enum EventPhase { + Before = 0, + InProgress = 1, + Finished = 2 + } + const state = { + keydown: EventPhase.Before, + input: EventPhase.Before, + render: EventPhase.Before, + }; + + /** + * Record the start of the keydown event. + */ + export function onKeyDown() { + /** Direct Check C. See explanation in {@link recordIfFinished} */ + recordIfFinished(); + performance.mark('inputlatency/start'); + performance.mark('keydown/start'); + state.keydown = EventPhase.InProgress; + queueMicrotask(markKeyDownEnd); + } + + /** + * Mark the end of the keydown event. + */ + function markKeyDownEnd() { + if (state.keydown === EventPhase.InProgress) { + performance.mark('keydown/end'); + state.keydown = EventPhase.Finished; + } + } + + /** + * Record the start of the beforeinput event. + */ + export function onBeforeInput() { + performance.mark('input/start'); + state.input = EventPhase.InProgress; + /** Schedule Task A. See explanation in {@link recordIfFinished} */ + scheduleRecordIfFinishedTask(); + } + + /** + * Record the start of the input event. + */ + export function onInput() { + if (state.input === EventPhase.Before) { + // it looks like we didn't receive a `beforeinput` + onBeforeInput(); + } + queueMicrotask(markInputEnd); + } + + function markInputEnd() { + if (state.input === EventPhase.InProgress) { + performance.mark('input/end'); + state.input = EventPhase.Finished; + } + } + + /** + * Record the start of the keyup event. + */ + export function onKeyUp() { + /** Direct Check D. See explanation in {@link recordIfFinished} */ + recordIfFinished(); + } + + /** + * Record the start of the selectionchange event. + */ + export function onSelectionChange() { + /** Direct Check E. See explanation in {@link recordIfFinished} */ + recordIfFinished(); + } + + /** + * Record the start of the animation frame performing the rendering. + */ + export function onRenderStart() { + // Render may be triggered during input, but we only measure the following animation frame + if (state.keydown === EventPhase.Finished && state.input === EventPhase.Finished && state.render === EventPhase.Before) { + // Only measure the first render after keyboard input + performance.mark('render/start'); + state.render = EventPhase.InProgress; + queueMicrotask(markRenderEnd); + /** Schedule Task B. See explanation in {@link recordIfFinished} */ + scheduleRecordIfFinishedTask(); + } + } + + /** + * Mark the end of the animation frame performing the rendering. + */ + function markRenderEnd() { + if (state.render === EventPhase.InProgress) { + performance.mark('render/end'); + state.render = EventPhase.Finished; + } + } + + function scheduleRecordIfFinishedTask() { + // Here we can safely assume that the `setTimeout` will not be + // artificially delayed by 4ms because we schedule it from + // event handlers + setTimeout(recordIfFinished); + } + + /** + * Record the input latency sample if input handling and rendering are finished. + * + * The challenge here is that we want to record the latency in such a way that it includes + * also the layout and painting work the browser does during the animation frame task. + * + * Simply scheduling a new task (via `setTimeout`) from the animation frame task would + * schedule the new task at the end of the task queue (after other code that uses `setTimeout`), + * so we need to use multiple strategies to make sure our task runs before others: + * + * We schedule tasks (A and B): + * - we schedule a task A (via a `setTimeout` call) when the input starts in `markInputStart`. + * If the animation frame task is scheduled quickly by the browser, then task A has a very good + * chance of being the very first task after the animation frame and thus will record the input latency. + * - however, if the animation frame task is scheduled a bit later, then task A might execute + * before the animation frame task. We therefore schedule another task B from `markRenderStart`. + * + * We do direct checks in browser event handlers (C, D, E): + * - if the browser has multiple keydown events queued up, they will be scheduled before the `setTimeout` tasks, + * so we do a direct check in the keydown event handler (C). + * - depending on timing, sometimes the animation frame is scheduled even before the `keyup` event, so we + * do a direct check there too (E). + * - the browser oftentimes emits a `selectionchange` event after an `input`, so we do a direct check there (D). + */ + function recordIfFinished() { + if (state.keydown === EventPhase.Finished && state.input === EventPhase.Finished && state.render === EventPhase.Finished) { + performance.mark('inputlatency/end'); + + performance.measure('keydown', 'keydown/start', 'keydown/end'); + performance.measure('input', 'input/start', 'input/end'); + performance.measure('render', 'render/start', 'render/end'); + performance.measure('inputlatency', 'inputlatency/start', 'inputlatency/end'); + + addMeasure('keydown', totalKeydownTime); + addMeasure('input', totalInputTime); + addMeasure('render', totalRenderTime); + addMeasure('inputlatency', totalInputLatencyTime); + + // console.info( + // `input latency=${performance.getEntriesByName('inputlatency')[0].duration.toFixed(1)} [` + + // `keydown=${performance.getEntriesByName('keydown')[0].duration.toFixed(1)}, ` + + // `input=${performance.getEntriesByName('input')[0].duration.toFixed(1)}, ` + + // `render=${performance.getEntriesByName('render')[0].duration.toFixed(1)}` + + // `]` + // ); + + measurementsCount++; + + reset(); + } + } + + function addMeasure(entryName: string, cumulativeMeasurement: ICumulativeMeasurement): void { + const duration = performance.getEntriesByName(entryName)[0].duration; + cumulativeMeasurement.total += duration; + cumulativeMeasurement.min = Math.min(cumulativeMeasurement.min, duration); + cumulativeMeasurement.max = Math.max(cumulativeMeasurement.max, duration); + } + + /** + * Clear the current sample. + */ + function reset() { + performance.clearMarks('keydown/start'); + performance.clearMarks('keydown/end'); + performance.clearMarks('input/start'); + performance.clearMarks('input/end'); + performance.clearMarks('render/start'); + performance.clearMarks('render/end'); + performance.clearMarks('inputlatency/start'); + performance.clearMarks('inputlatency/end'); + + performance.clearMeasures('keydown'); + performance.clearMeasures('input'); + performance.clearMeasures('render'); + performance.clearMeasures('inputlatency'); + + state.keydown = EventPhase.Before; + state.input = EventPhase.Before; + state.render = EventPhase.Before; + } + + export interface IInputLatencyMeasurements { + keydown: IInputLatencySingleMeasurement; + input: IInputLatencySingleMeasurement; + render: IInputLatencySingleMeasurement; + total: IInputLatencySingleMeasurement; + sampleCount: number; + } + + export interface IInputLatencySingleMeasurement { + average: number; + min: number; + max: number; + } + + /** + * Gets all input latency samples and clears the internal buffers to start recording a new set + * of samples. + */ + export function getAndClearMeasurements(): IInputLatencyMeasurements | undefined { + if (measurementsCount === 0) { + return undefined; + } + + // Assemble the result + const result = { + keydown: cumulativeToFinalMeasurement(totalKeydownTime), + input: cumulativeToFinalMeasurement(totalInputTime), + render: cumulativeToFinalMeasurement(totalRenderTime), + total: cumulativeToFinalMeasurement(totalInputLatencyTime), + sampleCount: measurementsCount + }; + + // Clear the cumulative measurements + clearCumulativeMeasurement(totalKeydownTime); + clearCumulativeMeasurement(totalInputTime); + clearCumulativeMeasurement(totalRenderTime); + clearCumulativeMeasurement(totalInputLatencyTime); + measurementsCount = 0; + + return result; + } + + function cumulativeToFinalMeasurement(cumulative: ICumulativeMeasurement): IInputLatencySingleMeasurement { + return { + average: cumulative.total / measurementsCount, + max: cumulative.max, + min: cumulative.min, + }; + } + + function clearCumulativeMeasurement(cumulative: ICumulativeMeasurement): void { + cumulative.total = 0; + cumulative.min = Number.MAX_VALUE; + cumulative.max = 0; + } + +} diff --git a/src/vs/base/browser/pixelRatio.ts b/src/vs/base/browser/pixelRatio.ts new file mode 100644 index 0000000000..197a802ff7 --- /dev/null +++ b/src/vs/base/browser/pixelRatio.ts @@ -0,0 +1,114 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { getWindowId, onDidUnregisterWindow } from 'vs/base/browser/dom'; +import { Emitter, Event } from 'vs/base/common/event'; +import { Disposable, markAsSingleton } from 'vs/base/common/lifecycle'; + +/** + * See https://developer.mozilla.org/en-US/docs/Web/API/Window/devicePixelRatio#monitoring_screen_resolution_or_zoom_level_changes + */ +class DevicePixelRatioMonitor extends Disposable { + + private readonly _onDidChange = this._register(new Emitter()); + readonly onDidChange = this._onDidChange.event; + + private readonly _listener: () => void; + private _mediaQueryList: MediaQueryList | null; + + constructor(targetWindow: Window) { + super(); + + this._listener = () => this._handleChange(targetWindow, true); + this._mediaQueryList = null; + this._handleChange(targetWindow, false); + } + + private _handleChange(targetWindow: Window, fireEvent: boolean): void { + this._mediaQueryList?.removeEventListener('change', this._listener); + + this._mediaQueryList = targetWindow.matchMedia(`(resolution: ${targetWindow.devicePixelRatio}dppx)`); + this._mediaQueryList.addEventListener('change', this._listener); + + if (fireEvent) { + this._onDidChange.fire(); + } + } +} + +export interface IPixelRatioMonitor { + readonly value: number; + readonly onDidChange: Event; +} + +class PixelRatioMonitorImpl extends Disposable implements IPixelRatioMonitor { + + private readonly _onDidChange = this._register(new Emitter()); + readonly onDidChange = this._onDidChange.event; + + private _value: number; + + get value(): number { + return this._value; + } + + constructor(targetWindow: Window) { + super(); + + this._value = this._getPixelRatio(targetWindow); + + const dprMonitor = this._register(new DevicePixelRatioMonitor(targetWindow)); + this._register(dprMonitor.onDidChange(() => { + this._value = this._getPixelRatio(targetWindow); + this._onDidChange.fire(this._value); + })); + } + + private _getPixelRatio(targetWindow: Window): number { + const ctx: any = document.createElement('canvas').getContext('2d'); + const dpr = targetWindow.devicePixelRatio || 1; + const bsr = ctx.webkitBackingStorePixelRatio || + ctx.mozBackingStorePixelRatio || + ctx.msBackingStorePixelRatio || + ctx.oBackingStorePixelRatio || + ctx.backingStorePixelRatio || 1; + return dpr / bsr; + } +} + +class PixelRatioMonitorFacade { + + private readonly mapWindowIdToPixelRatioMonitor = new Map(); + + private _getOrCreatePixelRatioMonitor(targetWindow: Window): PixelRatioMonitorImpl { + const targetWindowId = getWindowId(targetWindow); + let pixelRatioMonitor = this.mapWindowIdToPixelRatioMonitor.get(targetWindowId); + if (!pixelRatioMonitor) { + pixelRatioMonitor = markAsSingleton(new PixelRatioMonitorImpl(targetWindow)); + this.mapWindowIdToPixelRatioMonitor.set(targetWindowId, pixelRatioMonitor); + + markAsSingleton(Event.once(onDidUnregisterWindow)(({ vscodeWindowId }) => { + if (vscodeWindowId === targetWindowId) { + pixelRatioMonitor?.dispose(); + this.mapWindowIdToPixelRatioMonitor.delete(targetWindowId); + } + })); + } + return pixelRatioMonitor; + } + + getInstance(targetWindow: Window): IPixelRatioMonitor { + return this._getOrCreatePixelRatioMonitor(targetWindow); + } +} + +/** + * Returns the pixel ratio. + * + * This is useful for rendering elements at native screen resolution or for being used as + * a cache key when storing font measurements. Fonts might render differently depending on resolution + * and any measurements need to be discarded for example when a window is moved from a monitor to another. + */ +export const PixelRatio = new PixelRatioMonitorFacade(); diff --git a/src/vs/base/browser/touch.ts b/src/vs/base/browser/touch.ts new file mode 100644 index 0000000000..c85416054f --- /dev/null +++ b/src/vs/base/browser/touch.ts @@ -0,0 +1,372 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as DomUtils from 'vs/base/browser/dom'; +import { mainWindow } from 'vs/base/browser/window'; +import * as arrays from 'vs/base/common/arrays'; +import { memoize } from 'vs/base/common/decorators'; +import { Event as EventUtils } from 'vs/base/common/event'; +import { Disposable, IDisposable, markAsSingleton, toDisposable } from 'vs/base/common/lifecycle'; +import { LinkedList } from 'vs/base/common/linkedList'; + +export namespace EventType { + export const Tap = '-monaco-gesturetap'; + export const Change = '-monaco-gesturechange'; + export const Start = '-monaco-gesturestart'; + export const End = '-monaco-gesturesend'; + export const Contextmenu = '-monaco-gesturecontextmenu'; +} + +interface TouchData { + id: number; + initialTarget: EventTarget; + initialTimeStamp: number; + initialPageX: number; + initialPageY: number; + rollingTimestamps: number[]; + rollingPageX: number[]; + rollingPageY: number[]; +} + +export interface GestureEvent extends MouseEvent { + initialTarget: EventTarget | undefined; + translationX: number; + translationY: number; + pageX: number; + pageY: number; + tapCount: number; +} + +interface Touch { + identifier: number; + screenX: number; + screenY: number; + clientX: number; + clientY: number; + pageX: number; + pageY: number; + radiusX: number; + radiusY: number; + rotationAngle: number; + force: number; + target: Element; +} + +interface TouchList { + [i: number]: Touch; + length: number; + item(index: number): Touch; + identifiedTouch(id: number): Touch; +} + +interface TouchEvent extends Event { + touches: TouchList; + targetTouches: TouchList; + changedTouches: TouchList; +} + +export class Gesture extends Disposable { + + private static readonly SCROLL_FRICTION = -0.005; + private static INSTANCE: Gesture; + private static readonly HOLD_DELAY = 700; + + private dispatched = false; + private readonly targets = new LinkedList(); + private readonly ignoreTargets = new LinkedList(); + private handle: IDisposable | null; + + private readonly activeTouches: { [id: number]: TouchData }; + + private _lastSetTapCountTime: number; + + private static readonly CLEAR_TAP_COUNT_TIME = 400; // ms + + + private constructor() { + super(); + + this.activeTouches = {}; + this.handle = null; + this._lastSetTapCountTime = 0; + + this._register(EventUtils.runAndSubscribe(DomUtils.onDidRegisterWindow, ({ window, disposables }) => { + disposables.add(DomUtils.addDisposableListener(window.document, 'touchstart', (e: TouchEvent) => this.onTouchStart(e), { passive: false })); + disposables.add(DomUtils.addDisposableListener(window.document, 'touchend', (e: TouchEvent) => this.onTouchEnd(window, e))); + disposables.add(DomUtils.addDisposableListener(window.document, 'touchmove', (e: TouchEvent) => this.onTouchMove(e), { passive: false })); + }, { window: mainWindow, disposables: this._store })); + } + + public static addTarget(element: HTMLElement): IDisposable { + if (!Gesture.isTouchDevice()) { + return Disposable.None; + } + if (!Gesture.INSTANCE) { + Gesture.INSTANCE = markAsSingleton(new Gesture()); + } + + const remove = Gesture.INSTANCE.targets.push(element); + return toDisposable(remove); + } + + public static ignoreTarget(element: HTMLElement): IDisposable { + if (!Gesture.isTouchDevice()) { + return Disposable.None; + } + if (!Gesture.INSTANCE) { + Gesture.INSTANCE = markAsSingleton(new Gesture()); + } + + const remove = Gesture.INSTANCE.ignoreTargets.push(element); + return toDisposable(remove); + } + + @memoize + static isTouchDevice(): boolean { + // `'ontouchstart' in window` always evaluates to true with typescript's modern typings. This causes `window` to be + // `never` later in `window.navigator`. That's why we need the explicit `window as Window` cast + return 'ontouchstart' in mainWindow || navigator.maxTouchPoints > 0; + } + + public override dispose(): void { + if (this.handle) { + this.handle.dispose(); + this.handle = null; + } + + super.dispose(); + } + + private onTouchStart(e: TouchEvent): void { + const timestamp = Date.now(); // use Date.now() because on FF e.timeStamp is not epoch based. + + if (this.handle) { + this.handle.dispose(); + this.handle = null; + } + + for (let i = 0, len = e.targetTouches.length; i < len; i++) { + const touch = e.targetTouches.item(i); + + this.activeTouches[touch.identifier] = { + id: touch.identifier, + initialTarget: touch.target, + initialTimeStamp: timestamp, + initialPageX: touch.pageX, + initialPageY: touch.pageY, + rollingTimestamps: [timestamp], + rollingPageX: [touch.pageX], + rollingPageY: [touch.pageY] + }; + + const evt = this.newGestureEvent(EventType.Start, touch.target); + evt.pageX = touch.pageX; + evt.pageY = touch.pageY; + this.dispatchEvent(evt); + } + + if (this.dispatched) { + e.preventDefault(); + e.stopPropagation(); + this.dispatched = false; + } + } + + private onTouchEnd(targetWindow: Window, e: TouchEvent): void { + const timestamp = Date.now(); // use Date.now() because on FF e.timeStamp is not epoch based. + + const activeTouchCount = Object.keys(this.activeTouches).length; + + for (let i = 0, len = e.changedTouches.length; i < len; i++) { + + const touch = e.changedTouches.item(i); + + if (!this.activeTouches.hasOwnProperty(String(touch.identifier))) { + console.warn('move of an UNKNOWN touch', touch); + continue; + } + + const data = this.activeTouches[touch.identifier], + holdTime = Date.now() - data.initialTimeStamp; + + if (holdTime < Gesture.HOLD_DELAY + && Math.abs(data.initialPageX - arrays.tail(data.rollingPageX)!) < 30 + && Math.abs(data.initialPageY - arrays.tail(data.rollingPageY)!) < 30) { + + const evt = this.newGestureEvent(EventType.Tap, data.initialTarget); + evt.pageX = arrays.tail(data.rollingPageX)!; + evt.pageY = arrays.tail(data.rollingPageY)!; + this.dispatchEvent(evt); + + } else if (holdTime >= Gesture.HOLD_DELAY + && Math.abs(data.initialPageX - arrays.tail(data.rollingPageX)!) < 30 + && Math.abs(data.initialPageY - arrays.tail(data.rollingPageY)!) < 30) { + + const evt = this.newGestureEvent(EventType.Contextmenu, data.initialTarget); + evt.pageX = arrays.tail(data.rollingPageX)!; + evt.pageY = arrays.tail(data.rollingPageY)!; + this.dispatchEvent(evt); + + } else if (activeTouchCount === 1) { + const finalX = arrays.tail(data.rollingPageX)!; + const finalY = arrays.tail(data.rollingPageY)!; + + const deltaT = arrays.tail(data.rollingTimestamps)! - data.rollingTimestamps[0]; + const deltaX = finalX - data.rollingPageX[0]; + const deltaY = finalY - data.rollingPageY[0]; + + // We need to get all the dispatch targets on the start of the inertia event + const dispatchTo = [...this.targets].filter(t => data.initialTarget instanceof Node && t.contains(data.initialTarget)); + this.inertia(targetWindow, dispatchTo, timestamp, // time now + Math.abs(deltaX) / deltaT, // speed + deltaX > 0 ? 1 : -1, // x direction + finalX, // x now + Math.abs(deltaY) / deltaT, // y speed + deltaY > 0 ? 1 : -1, // y direction + finalY // y now + ); + } + + + this.dispatchEvent(this.newGestureEvent(EventType.End, data.initialTarget)); + // forget about this touch + delete this.activeTouches[touch.identifier]; + } + + if (this.dispatched) { + e.preventDefault(); + e.stopPropagation(); + this.dispatched = false; + } + } + + private newGestureEvent(type: string, initialTarget?: EventTarget): GestureEvent { + const event = document.createEvent('CustomEvent') as unknown as GestureEvent; + event.initEvent(type, false, true); + event.initialTarget = initialTarget; + event.tapCount = 0; + return event; + } + + private dispatchEvent(event: GestureEvent): void { + if (event.type === EventType.Tap) { + const currentTime = (new Date()).getTime(); + let setTapCount = 0; + if (currentTime - this._lastSetTapCountTime > Gesture.CLEAR_TAP_COUNT_TIME) { + setTapCount = 1; + } else { + setTapCount = 2; + } + + this._lastSetTapCountTime = currentTime; + event.tapCount = setTapCount; + } else if (event.type === EventType.Change || event.type === EventType.Contextmenu) { + // tap is canceled by scrolling or context menu + this._lastSetTapCountTime = 0; + } + + if (event.initialTarget instanceof Node) { + for (const ignoreTarget of this.ignoreTargets) { + if (ignoreTarget.contains(event.initialTarget)) { + return; + } + } + + const targets: [number, HTMLElement][] = []; + for (const target of this.targets) { + if (target.contains(event.initialTarget)) { + let depth = 0; + let now: Node | null = event.initialTarget; + while (now && now !== target) { + depth++; + now = now.parentElement; + } + targets.push([depth, target]); + } + } + + targets.sort((a, b) => a[0] - b[0]); + + for (const [_, target] of targets) { + target.dispatchEvent(event); + this.dispatched = true; + } + } + } + + private inertia(targetWindow: Window, dispatchTo: readonly EventTarget[], t1: number, vX: number, dirX: number, x: number, vY: number, dirY: number, y: number): void { + this.handle = DomUtils.scheduleAtNextAnimationFrame(targetWindow, () => { + const now = Date.now(); + + // velocity: old speed + accel_over_time + const deltaT = now - t1; + let delta_pos_x = 0, delta_pos_y = 0; + let stopped = true; + + vX += Gesture.SCROLL_FRICTION * deltaT; + vY += Gesture.SCROLL_FRICTION * deltaT; + + if (vX > 0) { + stopped = false; + delta_pos_x = dirX * vX * deltaT; + } + + if (vY > 0) { + stopped = false; + delta_pos_y = dirY * vY * deltaT; + } + + // dispatch translation event + const evt = this.newGestureEvent(EventType.Change); + evt.translationX = delta_pos_x; + evt.translationY = delta_pos_y; + dispatchTo.forEach(d => d.dispatchEvent(evt)); + + if (!stopped) { + this.inertia(targetWindow, dispatchTo, now, vX, dirX, x + delta_pos_x, vY, dirY, y + delta_pos_y); + } + }); + } + + private onTouchMove(e: TouchEvent): void { + const timestamp = Date.now(); // use Date.now() because on FF e.timeStamp is not epoch based. + + for (let i = 0, len = e.changedTouches.length; i < len; i++) { + + const touch = e.changedTouches.item(i); + + if (!this.activeTouches.hasOwnProperty(String(touch.identifier))) { + console.warn('end of an UNKNOWN touch', touch); + continue; + } + + const data = this.activeTouches[touch.identifier]; + + const evt = this.newGestureEvent(EventType.Change, data.initialTarget); + evt.translationX = touch.pageX - arrays.tail(data.rollingPageX)!; + evt.translationY = touch.pageY - arrays.tail(data.rollingPageY)!; + evt.pageX = touch.pageX; + evt.pageY = touch.pageY; + this.dispatchEvent(evt); + + // only keep a few data points, to average the final speed + if (data.rollingPageX.length > 3) { + data.rollingPageX.shift(); + data.rollingPageY.shift(); + data.rollingTimestamps.shift(); + } + + data.rollingPageX.push(touch.pageX); + data.rollingPageY.push(touch.pageY); + data.rollingTimestamps.push(timestamp); + } + + if (this.dispatched) { + e.preventDefault(); + e.stopPropagation(); + this.dispatched = false; + } + } +} diff --git a/src/vs/base/browser/trustedTypes.ts b/src/vs/base/browser/trustedTypes.ts new file mode 100644 index 0000000000..0ef4b08452 --- /dev/null +++ b/src/vs/base/browser/trustedTypes.ts @@ -0,0 +1,35 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { onUnexpectedError } from 'vs/base/common/errors'; + +export function createTrustedTypesPolicy( + policyName: string, + policyOptions?: Options, +): undefined | Pick, 'name' | Extract> { + + interface IMonacoEnvironment { + createTrustedTypesPolicy( + policyName: string, + policyOptions?: Options, + ): undefined | Pick, 'name' | Extract>; + } + const monacoEnvironment: IMonacoEnvironment | undefined = (globalThis as any).MonacoEnvironment; + + if (monacoEnvironment?.createTrustedTypesPolicy) { + try { + return monacoEnvironment.createTrustedTypesPolicy(policyName, policyOptions); + } catch (err) { + onUnexpectedError(err); + return undefined; + } + } + try { + return (globalThis as any).trustedTypes?.createPolicy(policyName, policyOptions); + } catch (err) { + onUnexpectedError(err); + return undefined; + } +} diff --git a/src/vs/base/browser/ui/aria/aria.css b/src/vs/base/browser/ui/aria/aria.css new file mode 100644 index 0000000000..c04b878444 --- /dev/null +++ b/src/vs/base/browser/ui/aria/aria.css @@ -0,0 +1,9 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.monaco-aria-container { + position: absolute; /* try to hide from window but not from screen readers */ + left:-999em; +} diff --git a/src/vs/base/browser/ui/aria/aria.ts b/src/vs/base/browser/ui/aria/aria.ts new file mode 100644 index 0000000000..52c0f91e5a --- /dev/null +++ b/src/vs/base/browser/ui/aria/aria.ts @@ -0,0 +1,163 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as dom from 'vs/base/browser/dom'; +// import 'vs/css!./aria'; + +// Use a max length since we are inserting the whole msg in the DOM and that can cause browsers to freeze for long messages #94233 +const MAX_MESSAGE_LENGTH = 20000; +let ariaContainer: HTMLElement; +let alertContainer: HTMLElement; +let alertContainer2: HTMLElement; +let statusContainer: HTMLElement; +let statusContainer2: HTMLElement; +export function setARIAContainer(parent: HTMLElement) { + ariaContainer = document.createElement('div'); + ariaContainer.className = 'monaco-aria-container'; + + const createAlertContainer = () => { + const element = document.createElement('div'); + element.className = 'monaco-alert'; + element.setAttribute('role', 'alert'); + element.setAttribute('aria-atomic', 'true'); + ariaContainer.appendChild(element); + return element; + }; + alertContainer = createAlertContainer(); + alertContainer2 = createAlertContainer(); + + const createStatusContainer = () => { + const element = document.createElement('div'); + element.className = 'monaco-status'; + element.setAttribute('aria-live', 'polite'); + element.setAttribute('aria-atomic', 'true'); + ariaContainer.appendChild(element); + return element; + }; + statusContainer = createStatusContainer(); + statusContainer2 = createStatusContainer(); + + parent.appendChild(ariaContainer); +} +/** + * Given the provided message, will make sure that it is read as alert to screen readers. + */ +export function alert(msg: string): void { + if (!ariaContainer) { + return; + } + + // Use alternate containers such that duplicated messages get read out by screen readers #99466 + if (alertContainer.textContent !== msg) { + dom.clearNode(alertContainer2); + insertMessage(alertContainer, msg); + } else { + dom.clearNode(alertContainer); + insertMessage(alertContainer2, msg); + } +} + +/** + * Given the provided message, will make sure that it is read as status to screen readers. + */ +export function status(msg: string): void { + if (!ariaContainer) { + return; + } + + if (statusContainer.textContent !== msg) { + dom.clearNode(statusContainer2); + insertMessage(statusContainer, msg); + } else { + dom.clearNode(statusContainer); + insertMessage(statusContainer2, msg); + } +} + +function insertMessage(target: HTMLElement, msg: string): void { + dom.clearNode(target); + if (msg.length > MAX_MESSAGE_LENGTH) { + msg = msg.substr(0, MAX_MESSAGE_LENGTH); + } + target.textContent = msg; + + // See https://www.paciellogroup.com/blog/2012/06/html5-accessibility-chops-aria-rolealert-browser-support/ + target.style.visibility = 'hidden'; + target.style.visibility = 'visible'; +} + +// Copied from @types/react which original came from https://www.w3.org/TR/wai-aria-1.1/#role_definitions +export type AriaRole = + | 'alert' + | 'alertdialog' + | 'application' + | 'article' + | 'banner' + | 'button' + | 'cell' + | 'checkbox' + | 'columnheader' + | 'combobox' + | 'complementary' + | 'contentinfo' + | 'definition' + | 'dialog' + | 'directory' + | 'document' + | 'feed' + | 'figure' + | 'form' + | 'grid' + | 'gridcell' + | 'group' + | 'heading' + | 'img' + | 'link' + | 'list' + | 'listbox' + | 'listitem' + | 'log' + | 'main' + | 'marquee' + | 'math' + | 'menu' + | 'menubar' + | 'menuitem' + | 'menuitemcheckbox' + | 'menuitemradio' + | 'navigation' + | 'none' + | 'note' + | 'option' + | 'presentation' + | 'progressbar' + | 'radio' + | 'radiogroup' + | 'region' + | 'row' + | 'rowgroup' + | 'rowheader' + | 'scrollbar' + | 'search' + | 'searchbox' + | 'separator' + | 'slider' + | 'spinbutton' + | 'status' + | 'switch' + | 'tab' + | 'table' + | 'tablist' + | 'tabpanel' + | 'term' + | 'textbox' + | 'timer' + | 'toolbar' + | 'tooltip' + | 'tree' + | 'treegrid' + | 'treeitem' + | (string & {}) // Prevent type collapsing to `string` + ; diff --git a/src/vs/base/browser/ui/breadcrumbs/breadcrumbsWidget.css b/src/vs/base/browser/ui/breadcrumbs/breadcrumbsWidget.css new file mode 100644 index 0000000000..4a3cf07479 --- /dev/null +++ b/src/vs/base/browser/ui/breadcrumbs/breadcrumbsWidget.css @@ -0,0 +1,36 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.monaco-breadcrumbs { + user-select: none; + -webkit-user-select: none; + display: flex; + flex-direction: row; + flex-wrap: nowrap; + justify-content: flex-start; + outline-style: none; +} + +.monaco-breadcrumbs .monaco-breadcrumb-item { + display: flex; + align-items: center; + flex: 0 1 auto; + white-space: nowrap; + cursor: pointer; + align-self: center; + height: 100%; + outline: none; +} +.monaco-breadcrumbs.disabled .monaco-breadcrumb-item { + cursor: default; +} + +.monaco-breadcrumbs .monaco-breadcrumb-item .codicon-breadcrumb-separator { + color: inherit; +} + +.monaco-breadcrumbs .monaco-breadcrumb-item:first-of-type::before { + content: ' '; +} diff --git a/src/vs/base/browser/ui/breadcrumbs/breadcrumbsWidget.ts b/src/vs/base/browser/ui/breadcrumbs/breadcrumbsWidget.ts new file mode 100644 index 0000000000..ea33ce5c51 --- /dev/null +++ b/src/vs/base/browser/ui/breadcrumbs/breadcrumbsWidget.ts @@ -0,0 +1,356 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as dom from 'vs/base/browser/dom'; +import { IMouseEvent } from 'vs/base/browser/mouseEvent'; +import { DomScrollableElement } from 'vs/base/browser/ui/scrollbar/scrollableElement'; +import { commonPrefixLength } from 'vs/base/common/arrays'; +import { ThemeIcon } from 'vs/base/common/themables'; +import { Emitter, Event } from 'vs/base/common/event'; +import { DisposableStore, dispose, IDisposable } from 'vs/base/common/lifecycle'; +import { ScrollbarVisibility } from 'vs/base/common/scrollable'; +// import 'vs/css!./breadcrumbsWidget'; + +export abstract class BreadcrumbsItem { + abstract dispose(): void; + abstract equals(other: BreadcrumbsItem): boolean; + abstract render(container: HTMLElement): void; +} + +export interface IBreadcrumbsWidgetStyles { + readonly breadcrumbsBackground: string | undefined; + readonly breadcrumbsForeground: string | undefined; + readonly breadcrumbsHoverForeground: string | undefined; + readonly breadcrumbsFocusForeground: string | undefined; + readonly breadcrumbsFocusAndSelectionForeground: string | undefined; +} + +export interface IBreadcrumbsItemEvent { + type: 'select' | 'focus'; + item: BreadcrumbsItem; + node: HTMLElement; + payload: any; +} + +export class BreadcrumbsWidget { + + private readonly _disposables = new DisposableStore(); + private readonly _domNode: HTMLDivElement; + private readonly _scrollable: DomScrollableElement; + + private readonly _onDidSelectItem = new Emitter(); + private readonly _onDidFocusItem = new Emitter(); + private readonly _onDidChangeFocus = new Emitter(); + + readonly onDidSelectItem: Event = this._onDidSelectItem.event; + readonly onDidFocusItem: Event = this._onDidFocusItem.event; + readonly onDidChangeFocus: Event = this._onDidChangeFocus.event; + + private readonly _items = new Array(); + private readonly _nodes = new Array(); + private readonly _freeNodes = new Array(); + private readonly _separatorIcon: ThemeIcon; + + private _enabled: boolean = true; + private _focusedItemIdx: number = -1; + private _selectedItemIdx: number = -1; + + private _pendingDimLayout: IDisposable | undefined; + private _pendingLayout: IDisposable | undefined; + private _dimension: dom.Dimension | undefined; + + constructor( + container: HTMLElement, + horizontalScrollbarSize: number, + separatorIcon: ThemeIcon, + styles: IBreadcrumbsWidgetStyles + ) { + this._domNode = document.createElement('div'); + this._domNode.className = 'monaco-breadcrumbs'; + this._domNode.tabIndex = 0; + this._domNode.setAttribute('role', 'list'); + this._scrollable = new DomScrollableElement(this._domNode, { + vertical: ScrollbarVisibility.Hidden, + horizontal: ScrollbarVisibility.Auto, + horizontalScrollbarSize, + useShadows: false, + scrollYToX: true + }); + this._separatorIcon = separatorIcon; + this._disposables.add(this._scrollable); + this._disposables.add(dom.addStandardDisposableListener(this._domNode, 'click', e => this._onClick(e))); + container.appendChild(this._scrollable.getDomNode()); + + const styleElement = dom.createStyleSheet(this._domNode); + this._style(styleElement, styles); + + const focusTracker = dom.trackFocus(this._domNode); + this._disposables.add(focusTracker); + this._disposables.add(focusTracker.onDidBlur(_ => this._onDidChangeFocus.fire(false))); + this._disposables.add(focusTracker.onDidFocus(_ => this._onDidChangeFocus.fire(true))); + } + + setHorizontalScrollbarSize(size: number) { + this._scrollable.updateOptions({ + horizontalScrollbarSize: size + }); + } + + dispose(): void { + this._disposables.dispose(); + this._pendingLayout?.dispose(); + this._pendingDimLayout?.dispose(); + this._onDidSelectItem.dispose(); + this._onDidFocusItem.dispose(); + this._onDidChangeFocus.dispose(); + this._domNode.remove(); + this._nodes.length = 0; + this._freeNodes.length = 0; + } + + layout(dim: dom.Dimension | undefined): void { + if (dim && dom.Dimension.equals(dim, this._dimension)) { + return; + } + if (dim) { + // only measure + this._pendingDimLayout?.dispose(); + this._pendingDimLayout = this._updateDimensions(dim); + } else { + this._pendingLayout?.dispose(); + this._pendingLayout = this._updateScrollbar(); + } + } + + private _updateDimensions(dim: dom.Dimension): IDisposable { + const disposables = new DisposableStore(); + disposables.add(dom.modify(dom.getWindow(this._domNode), () => { + this._dimension = dim; + this._domNode.style.width = `${dim.width}px`; + this._domNode.style.height = `${dim.height}px`; + disposables.add(this._updateScrollbar()); + })); + return disposables; + } + + private _updateScrollbar(): IDisposable { + return dom.measure(dom.getWindow(this._domNode), () => { + dom.measure(dom.getWindow(this._domNode), () => { // double RAF + this._scrollable.setRevealOnScroll(false); + this._scrollable.scanDomNode(); + this._scrollable.setRevealOnScroll(true); + }); + }); + } + + private _style(styleElement: HTMLStyleElement, style: IBreadcrumbsWidgetStyles): void { + let content = ''; + if (style.breadcrumbsBackground) { + content += `.monaco-breadcrumbs { background-color: ${style.breadcrumbsBackground}}`; + } + if (style.breadcrumbsForeground) { + content += `.monaco-breadcrumbs .monaco-breadcrumb-item { color: ${style.breadcrumbsForeground}}\n`; + } + if (style.breadcrumbsFocusForeground) { + content += `.monaco-breadcrumbs .monaco-breadcrumb-item.focused { color: ${style.breadcrumbsFocusForeground}}\n`; + } + if (style.breadcrumbsFocusAndSelectionForeground) { + content += `.monaco-breadcrumbs .monaco-breadcrumb-item.focused.selected { color: ${style.breadcrumbsFocusAndSelectionForeground}}\n`; + } + if (style.breadcrumbsHoverForeground) { + content += `.monaco-breadcrumbs:not(.disabled ) .monaco-breadcrumb-item:hover:not(.focused):not(.selected) { color: ${style.breadcrumbsHoverForeground}}\n`; + } + styleElement.innerText = content; + } + + setEnabled(value: boolean) { + this._enabled = value; + this._domNode.classList.toggle('disabled', !this._enabled); + } + + domFocus(): void { + const idx = this._focusedItemIdx >= 0 ? this._focusedItemIdx : this._items.length - 1; + if (idx >= 0 && idx < this._items.length) { + this._focus(idx, undefined); + } else { + this._domNode.focus(); + } + } + + isDOMFocused(): boolean { + return dom.isAncestorOfActiveElement(this._domNode); + } + + getFocused(): BreadcrumbsItem { + return this._items[this._focusedItemIdx]; + } + + setFocused(item: BreadcrumbsItem | undefined, payload?: any): void { + this._focus(this._items.indexOf(item!), payload); + } + + focusPrev(payload?: any): any { + if (this._focusedItemIdx > 0) { + this._focus(this._focusedItemIdx - 1, payload); + } + } + + focusNext(payload?: any): any { + if (this._focusedItemIdx + 1 < this._nodes.length) { + this._focus(this._focusedItemIdx + 1, payload); + } + } + + private _focus(nth: number, payload: any): void { + this._focusedItemIdx = -1; + for (let i = 0; i < this._nodes.length; i++) { + const node = this._nodes[i]; + if (i !== nth) { + node.classList.remove('focused'); + } else { + this._focusedItemIdx = i; + node.classList.add('focused'); + node.focus(); + } + } + this._reveal(this._focusedItemIdx, true); + this._onDidFocusItem.fire({ type: 'focus', item: this._items[this._focusedItemIdx], node: this._nodes[this._focusedItemIdx], payload }); + } + + reveal(item: BreadcrumbsItem): void { + const idx = this._items.indexOf(item); + if (idx >= 0) { + this._reveal(idx, false); + } + } + + revealLast(): void { + this._reveal(this._items.length - 1, false); + } + + private _reveal(nth: number, minimal: boolean): void { + if (nth < 0 || nth >= this._nodes.length) { + return; + } + const node = this._nodes[nth]; + if (!node) { + return; + } + const { width } = this._scrollable.getScrollDimensions(); + const { scrollLeft } = this._scrollable.getScrollPosition(); + if (!minimal || node.offsetLeft > scrollLeft + width || node.offsetLeft < scrollLeft) { + this._scrollable.setRevealOnScroll(false); + this._scrollable.setScrollPosition({ scrollLeft: node.offsetLeft }); + this._scrollable.setRevealOnScroll(true); + } + } + + getSelection(): BreadcrumbsItem { + return this._items[this._selectedItemIdx]; + } + + setSelection(item: BreadcrumbsItem | undefined, payload?: any): void { + this._select(this._items.indexOf(item!), payload); + } + + private _select(nth: number, payload: any): void { + this._selectedItemIdx = -1; + for (let i = 0; i < this._nodes.length; i++) { + const node = this._nodes[i]; + if (i !== nth) { + node.classList.remove('selected'); + } else { + this._selectedItemIdx = i; + node.classList.add('selected'); + } + } + this._onDidSelectItem.fire({ type: 'select', item: this._items[this._selectedItemIdx], node: this._nodes[this._selectedItemIdx], payload }); + } + + getItems(): readonly BreadcrumbsItem[] { + return this._items; + } + + setItems(items: BreadcrumbsItem[]): void { + let prefix: number | undefined; + let removed: BreadcrumbsItem[] = []; + try { + prefix = commonPrefixLength(this._items, items, (a, b) => a.equals(b)); + removed = this._items.splice(prefix, this._items.length - prefix, ...items.slice(prefix)); + this._render(prefix); + dispose(removed); + this._focus(-1, undefined); + } catch (e) { + const newError = new Error(`BreadcrumbsItem#setItems: newItems: ${items.length}, prefix: ${prefix}, removed: ${removed.length}`); + newError.name = e.name; + newError.stack = e.stack; + throw newError; + } + } + + private _render(start: number): void { + let didChange = false; + for (; start < this._items.length && start < this._nodes.length; start++) { + const item = this._items[start]; + const node = this._nodes[start]; + this._renderItem(item, node); + didChange = true; + } + // case a: more nodes -> remove them + while (start < this._nodes.length) { + const free = this._nodes.pop(); + if (free) { + this._freeNodes.push(free); + free.remove(); + didChange = true; + } + } + + // case b: more items -> render them + for (; start < this._items.length; start++) { + const item = this._items[start]; + const node = this._freeNodes.length > 0 ? this._freeNodes.pop() : document.createElement('div'); + if (node) { + this._renderItem(item, node); + this._domNode.appendChild(node); + this._nodes.push(node); + didChange = true; + } + } + if (didChange) { + this.layout(undefined); + } + } + + private _renderItem(item: BreadcrumbsItem, container: HTMLDivElement): void { + dom.clearNode(container); + container.className = ''; + try { + item.render(container); + } catch (err) { + container.innerText = '<>'; + console.error(err); + } + container.tabIndex = -1; + container.setAttribute('role', 'listitem'); + container.classList.add('monaco-breadcrumb-item'); + const iconContainer = dom.$(ThemeIcon.asCSSSelector(this._separatorIcon)); + container.appendChild(iconContainer); + } + + private _onClick(event: IMouseEvent): void { + if (!this._enabled) { + return; + } + for (let el: HTMLElement | null = event.target; el; el = el.parentElement) { + const idx = this._nodes.indexOf(el as HTMLDivElement); + if (idx >= 0) { + this._focus(idx, event); + this._select(idx, event); + break; + } + } + } +} diff --git a/src/vs/base/browser/ui/centered/centeredViewLayout.ts b/src/vs/base/browser/ui/centered/centeredViewLayout.ts new file mode 100644 index 0000000000..b6bc80b1d9 --- /dev/null +++ b/src/vs/base/browser/ui/centered/centeredViewLayout.ts @@ -0,0 +1,225 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { $, IDomNodePagePosition } from 'vs/base/browser/dom'; +import { IView, IViewSize } from 'vs/base/browser/ui/grid/grid'; +import { IBoundarySashes } from 'vs/base/browser/ui/sash/sash'; +import { DistributeSizing, ISplitViewStyles, IView as ISplitViewView, Orientation, SplitView } from 'vs/base/browser/ui/splitview/splitview'; +import { Color } from 'vs/base/common/color'; +import { Event } from 'vs/base/common/event'; +import { DisposableStore, IDisposable } from 'vs/base/common/lifecycle'; + +export interface CenteredViewState { + // width of the fixed centered layout + targetWidth: number; + // proportional size of left margin + leftMarginRatio: number; + // proportional size of right margin + rightMarginRatio: number; +} + +const defaultState: CenteredViewState = { + targetWidth: 900, + leftMarginRatio: 0.1909, + rightMarginRatio: 0.1909, +}; + +const distributeSizing: DistributeSizing = { type: 'distribute' }; + +function createEmptyView(background: Color | undefined): ISplitViewView<{ top: number; left: number }> { + const element = $('.centered-layout-margin'); + element.style.height = '100%'; + if (background) { + element.style.backgroundColor = background.toString(); + } + + return { + element, + layout: () => undefined, + minimumSize: 60, + maximumSize: Number.POSITIVE_INFINITY, + onDidChange: Event.None + }; +} + +function toSplitViewView(view: IView, getHeight: () => number): ISplitViewView<{ top: number; left: number }> { + return { + element: view.element, + get maximumSize() { return view.maximumWidth; }, + get minimumSize() { return view.minimumWidth; }, + onDidChange: Event.map(view.onDidChange, e => e && e.width), + layout: (size, offset, ctx) => view.layout(size, getHeight(), ctx?.top ?? 0, (ctx?.left ?? 0) + offset) + }; +} + +export interface ICenteredViewStyles extends ISplitViewStyles { + background: Color; +} + +export class CenteredViewLayout implements IDisposable { + + private splitView?: SplitView<{ top: number; left: number }>; + private lastLayoutPosition: IDomNodePagePosition = { width: 0, height: 0, left: 0, top: 0 }; + private style!: ICenteredViewStyles; + private didLayout = false; + private emptyViews: ISplitViewView<{ top: number; left: number }>[] | undefined; + private readonly splitViewDisposables = new DisposableStore(); + + constructor( + private container: HTMLElement, + private view: IView, + public state: CenteredViewState = { ...defaultState }, + private centeredLayoutFixedWidth: boolean = false + ) { + this.container.appendChild(this.view.element); + // Make sure to hide the split view overflow like sashes #52892 + this.container.style.overflow = 'hidden'; + } + + get minimumWidth(): number { return this.splitView ? this.splitView.minimumSize : this.view.minimumWidth; } + get maximumWidth(): number { return this.splitView ? this.splitView.maximumSize : this.view.maximumWidth; } + get minimumHeight(): number { return this.view.minimumHeight; } + get maximumHeight(): number { return this.view.maximumHeight; } + get onDidChange(): Event { return this.view.onDidChange; } + + private _boundarySashes: IBoundarySashes = {}; + get boundarySashes(): IBoundarySashes { return this._boundarySashes; } + set boundarySashes(boundarySashes: IBoundarySashes) { + this._boundarySashes = boundarySashes; + + if (!this.splitView) { + return; + } + + this.splitView.orthogonalStartSash = boundarySashes.top; + this.splitView.orthogonalEndSash = boundarySashes.bottom; + } + + layout(width: number, height: number, top: number, left: number): void { + this.lastLayoutPosition = { width, height, top, left }; + if (this.splitView) { + this.splitView.layout(width, this.lastLayoutPosition); + if (!this.didLayout || this.centeredLayoutFixedWidth) { + this.resizeSplitViews(); + } + } else { + this.view.layout(width, height, top, left); + } + + this.didLayout = true; + } + + private resizeSplitViews(): void { + if (!this.splitView) { + return; + } + if (this.centeredLayoutFixedWidth) { + const centerViewWidth = Math.min(this.lastLayoutPosition.width, this.state.targetWidth); + const marginWidthFloat = (this.lastLayoutPosition.width - centerViewWidth) / 2; + this.splitView.resizeView(0, Math.floor(marginWidthFloat)); + this.splitView.resizeView(1, centerViewWidth); + this.splitView.resizeView(2, Math.ceil(marginWidthFloat)); + } else { + const leftMargin = this.state.leftMarginRatio * this.lastLayoutPosition.width; + const rightMargin = this.state.rightMarginRatio * this.lastLayoutPosition.width; + const center = this.lastLayoutPosition.width - leftMargin - rightMargin; + this.splitView.resizeView(0, leftMargin); + this.splitView.resizeView(1, center); + this.splitView.resizeView(2, rightMargin); + } + } + + setFixedWidth(option: boolean) { + this.centeredLayoutFixedWidth = option; + if (!!this.splitView) { + this.updateState(); + this.resizeSplitViews(); + } + } + + private updateState() { + if (!!this.splitView) { + this.state.targetWidth = this.splitView.getViewSize(1); + this.state.leftMarginRatio = this.splitView.getViewSize(0) / this.lastLayoutPosition.width; + this.state.rightMarginRatio = this.splitView.getViewSize(2) / this.lastLayoutPosition.width; + } + } + + isActive(): boolean { + return !!this.splitView; + } + + styles(style: ICenteredViewStyles): void { + this.style = style; + if (this.splitView && this.emptyViews) { + this.splitView.style(this.style); + this.emptyViews[0].element.style.backgroundColor = this.style.background.toString(); + this.emptyViews[1].element.style.backgroundColor = this.style.background.toString(); + } + } + + activate(active: boolean): void { + if (active === this.isActive()) { + return; + } + + if (active) { + this.view.element.remove(); + this.splitView = new SplitView(this.container, { + inverseAltBehavior: true, + orientation: Orientation.HORIZONTAL, + styles: this.style + }); + this.splitView.orthogonalStartSash = this.boundarySashes.top; + this.splitView.orthogonalEndSash = this.boundarySashes.bottom; + + this.splitViewDisposables.add(this.splitView.onDidSashChange(() => { + if (!!this.splitView) { + this.updateState(); + } + })); + this.splitViewDisposables.add(this.splitView.onDidSashReset(() => { + this.state = { ...defaultState }; + this.resizeSplitViews(); + })); + + this.splitView.layout(this.lastLayoutPosition.width, this.lastLayoutPosition); + const backgroundColor = this.style ? this.style.background : undefined; + this.emptyViews = [createEmptyView(backgroundColor), createEmptyView(backgroundColor)]; + + this.splitView.addView(this.emptyViews[0], distributeSizing, 0); + this.splitView.addView(toSplitViewView(this.view, () => this.lastLayoutPosition.height), distributeSizing, 1); + this.splitView.addView(this.emptyViews[1], distributeSizing, 2); + + this.resizeSplitViews(); + } else { + this.splitView?.el.remove(); + this.splitViewDisposables.clear(); + this.splitView?.dispose(); + this.splitView = undefined; + this.emptyViews = undefined; + this.container.appendChild(this.view.element); + this.view.layout(this.lastLayoutPosition.width, this.lastLayoutPosition.height, this.lastLayoutPosition.top, this.lastLayoutPosition.left); + } + } + + isDefault(state: CenteredViewState): boolean { + if (this.centeredLayoutFixedWidth) { + return state.targetWidth === defaultState.targetWidth; + } else { + return state.leftMarginRatio === defaultState.leftMarginRatio + && state.rightMarginRatio === defaultState.rightMarginRatio; + } + } + + dispose(): void { + this.splitViewDisposables.dispose(); + + if (this.splitView) { + this.splitView.dispose(); + this.splitView = undefined; + } + } +} diff --git a/src/vs/base/browser/ui/codicons/codicon/codicon-modifiers.css b/src/vs/base/browser/ui/codicons/codicon/codicon-modifiers.css new file mode 100644 index 0000000000..9666216f6a --- /dev/null +++ b/src/vs/base/browser/ui/codicons/codicon/codicon-modifiers.css @@ -0,0 +1,33 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.codicon-wrench-subaction { + opacity: 0.5; +} + +@keyframes codicon-spin { + 100% { + transform:rotate(360deg); + } +} + +.codicon-sync.codicon-modifier-spin, +.codicon-loading.codicon-modifier-spin, +.codicon-gear.codicon-modifier-spin, +.codicon-notebook-state-executing.codicon-modifier-spin { + /* Use steps to throttle FPS to reduce CPU usage */ + animation: codicon-spin 1.5s steps(30) infinite; +} + +.codicon-modifier-disabled { + opacity: 0.4; +} + +/* custom speed & easing for loading icon */ +.codicon-loading, +.codicon-tree-item-loading::before { + animation-duration: 1s !important; + animation-timing-function: cubic-bezier(0.53, 0.21, 0.29, 0.67) !important; +} diff --git a/src/vs/base/browser/ui/codicons/codicon/codicon.css b/src/vs/base/browser/ui/codicons/codicon/codicon.css new file mode 100644 index 0000000000..02154e77b6 --- /dev/null +++ b/src/vs/base/browser/ui/codicons/codicon/codicon.css @@ -0,0 +1,25 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +@font-face { + font-family: "codicon"; + font-display: block; + src: url("./codicon.ttf?5d4d76ab2ce5108968ad644d591a16a6") format("truetype"); +} + +.codicon[class*='codicon-'] { + font: normal normal normal 16px/1 codicon; + display: inline-block; + text-decoration: none; + text-rendering: auto; + text-align: center; + text-transform: none; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + user-select: none; + -webkit-user-select: none; +} + +/* icon rules are dynamically created by the platform theme service (see iconsStyleSheet.ts) */ diff --git a/src/vs/base/browser/ui/codicons/codiconStyles.ts b/src/vs/base/browser/ui/codicons/codiconStyles.ts new file mode 100644 index 0000000000..a88fd3ca53 --- /dev/null +++ b/src/vs/base/browser/ui/codicons/codiconStyles.ts @@ -0,0 +1,7 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// import 'vs/css!./codicon/codicon'; +// import 'vs/css!./codicon/codicon-modifiers'; diff --git a/src/vs/base/browser/ui/countBadge/countBadge.css b/src/vs/base/browser/ui/countBadge/countBadge.css new file mode 100644 index 0000000000..eb0c0837ee --- /dev/null +++ b/src/vs/base/browser/ui/countBadge/countBadge.css @@ -0,0 +1,24 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.monaco-count-badge { + padding: 3px 6px; + border-radius: 11px; + font-size: 11px; + min-width: 18px; + min-height: 18px; + line-height: 11px; + font-weight: normal; + text-align: center; + display: inline-block; + box-sizing: border-box; +} + +.monaco-count-badge.long { + padding: 2px 3px; + border-radius: 2px; + min-height: auto; + line-height: normal; +} diff --git a/src/vs/base/browser/ui/countBadge/countBadge.ts b/src/vs/base/browser/ui/countBadge/countBadge.ts new file mode 100644 index 0000000000..fe2f762a96 --- /dev/null +++ b/src/vs/base/browser/ui/countBadge/countBadge.ts @@ -0,0 +1,69 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { $, append } from 'vs/base/browser/dom'; +import { format } from 'vs/base/common/strings'; +// import 'vs/css!./countBadge'; + +export interface ICountBadgeOptions { + readonly count?: number; + readonly countFormat?: string; + readonly titleFormat?: string; +} + +export interface ICountBadgeStyles { + readonly badgeBackground: string | undefined; + readonly badgeForeground: string | undefined; + readonly badgeBorder: string | undefined; +} + +export const unthemedCountStyles: ICountBadgeStyles = { + badgeBackground: '#4D4D4D', + badgeForeground: '#FFFFFF', + badgeBorder: undefined +}; + +export class CountBadge { + + private element: HTMLElement; + private count: number = 0; + private countFormat: string; + private titleFormat: string; + + constructor(container: HTMLElement, private readonly options: ICountBadgeOptions, private readonly styles: ICountBadgeStyles) { + + this.element = append(container, $('.monaco-count-badge')); + this.countFormat = this.options.countFormat || '{0}'; + this.titleFormat = this.options.titleFormat || ''; + this.setCount(this.options.count || 0); + } + + setCount(count: number) { + this.count = count; + this.render(); + } + + setCountFormat(countFormat: string) { + this.countFormat = countFormat; + this.render(); + } + + setTitleFormat(titleFormat: string) { + this.titleFormat = titleFormat; + this.render(); + } + + private render() { + this.element.textContent = format(this.countFormat, this.count); + this.element.title = format(this.titleFormat, this.count); + + this.element.style.backgroundColor = this.styles.badgeBackground ?? ''; + this.element.style.color = this.styles.badgeForeground ?? ''; + + if (this.styles.badgeBorder) { + this.element.style.border = `1px solid ${this.styles.badgeBorder}`; + } + } +} diff --git a/src/vs/base/browser/ui/grid/grid.ts b/src/vs/base/browser/ui/grid/grid.ts new file mode 100644 index 0000000000..cf7533e59f --- /dev/null +++ b/src/vs/base/browser/ui/grid/grid.ts @@ -0,0 +1,947 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IBoundarySashes, Orientation } from 'vs/base/browser/ui/sash/sash'; +import { equals, tail2 as tail } from 'vs/base/common/arrays'; +import { Event } from 'vs/base/common/event'; +import { Disposable } from 'vs/base/common/lifecycle'; +// import 'vs/css!./gridview'; +import { Box, GridView, IGridViewOptions, IGridViewStyles, IView as IGridViewView, IViewSize, orthogonal, Sizing as GridViewSizing, GridLocation } from './gridview'; +import type { SplitView, AutoSizing as SplitViewAutoSizing } from 'vs/base/browser/ui/splitview/splitview'; + +export type { IViewSize }; +export { LayoutPriority, Orientation, orthogonal } from './gridview'; + +export const enum Direction { + Up, + Down, + Left, + Right +} + +function oppositeDirection(direction: Direction): Direction { + switch (direction) { + case Direction.Up: return Direction.Down; + case Direction.Down: return Direction.Up; + case Direction.Left: return Direction.Right; + case Direction.Right: return Direction.Left; + } +} + +/** + * The interface to implement for views within a {@link Grid}. + */ +export interface IView extends IGridViewView { + + /** + * The preferred width for when the user double clicks a sash + * adjacent to this view. + */ + readonly preferredWidth?: number; + + /** + * The preferred height for when the user double clicks a sash + * adjacent to this view. + */ + readonly preferredHeight?: number; +} + +export interface GridLeafNode { + readonly view: T; + readonly box: Box; + readonly cachedVisibleSize: number | undefined; + readonly maximized: boolean; +} + +export interface GridBranchNode { + readonly children: GridNode[]; + readonly box: Box; +} + +export type GridNode = GridLeafNode | GridBranchNode; + +export function isGridBranchNode(node: GridNode): node is GridBranchNode { + return !!(node as any).children; +} + +function getGridNode(node: GridNode, location: GridLocation): GridNode { + if (location.length === 0) { + return node; + } + + if (!isGridBranchNode(node)) { + throw new Error('Invalid location'); + } + + const [index, ...rest] = location; + return getGridNode(node.children[index], rest); +} + +interface Range { + readonly start: number; + readonly end: number; +} + +function intersects(one: Range, other: Range): boolean { + return !(one.start >= other.end || other.start >= one.end); +} + +interface Boundary { + readonly offset: number; + readonly range: Range; +} + +function getBoxBoundary(box: Box, direction: Direction): Boundary { + const orientation = getDirectionOrientation(direction); + const offset = direction === Direction.Up ? box.top : + direction === Direction.Right ? box.left + box.width : + direction === Direction.Down ? box.top + box.height : + box.left; + + const range = { + start: orientation === Orientation.HORIZONTAL ? box.top : box.left, + end: orientation === Orientation.HORIZONTAL ? box.top + box.height : box.left + box.width + }; + + return { offset, range }; +} + +function findAdjacentBoxLeafNodes(boxNode: GridNode, direction: Direction, boundary: Boundary): GridLeafNode[] { + const result: GridLeafNode[] = []; + + function _(boxNode: GridNode, direction: Direction, boundary: Boundary): void { + if (isGridBranchNode(boxNode)) { + for (const child of boxNode.children) { + _(child, direction, boundary); + } + } else { + const { offset, range } = getBoxBoundary(boxNode.box, direction); + + if (offset === boundary.offset && intersects(range, boundary.range)) { + result.push(boxNode); + } + } + } + + _(boxNode, direction, boundary); + return result; +} + +function getLocationOrientation(rootOrientation: Orientation, location: GridLocation): Orientation { + return location.length % 2 === 0 ? orthogonal(rootOrientation) : rootOrientation; +} + +function getDirectionOrientation(direction: Direction): Orientation { + return direction === Direction.Up || direction === Direction.Down ? Orientation.VERTICAL : Orientation.HORIZONTAL; +} + +export function getRelativeLocation(rootOrientation: Orientation, location: GridLocation, direction: Direction): GridLocation { + const orientation = getLocationOrientation(rootOrientation, location); + const directionOrientation = getDirectionOrientation(direction); + + if (orientation === directionOrientation) { + let [rest, index] = tail(location); + + if (direction === Direction.Right || direction === Direction.Down) { + index += 1; + } + + return [...rest, index]; + } else { + const index = (direction === Direction.Right || direction === Direction.Down) ? 1 : 0; + return [...location, index]; + } +} + +function indexInParent(element: HTMLElement): number { + const parentElement = element.parentElement; + + if (!parentElement) { + throw new Error('Invalid grid element'); + } + + let el = parentElement.firstElementChild; + let index = 0; + + while (el !== element && el !== parentElement.lastElementChild && el) { + el = el.nextElementSibling; + index++; + } + + return index; +} + +/** + * Find the grid location of a specific DOM element by traversing the parent + * chain and finding each child index on the way. + * + * This will break as soon as DOM structures of the Splitview or Gridview change. + */ +function getGridLocation(element: HTMLElement): GridLocation { + const parentElement = element.parentElement; + + if (!parentElement) { + throw new Error('Invalid grid element'); + } + + if (/\bmonaco-grid-view\b/.test(parentElement.className)) { + return []; + } + + const index = indexInParent(parentElement); + const ancestor = parentElement.parentElement!.parentElement!.parentElement!.parentElement!; + return [...getGridLocation(ancestor), index]; +} + +export type DistributeSizing = { type: 'distribute' }; +export type SplitSizing = { type: 'split' }; +export type AutoSizing = { type: 'auto' }; +export type InvisibleSizing = { type: 'invisible'; cachedVisibleSize: number }; +export type Sizing = DistributeSizing | SplitSizing | AutoSizing | InvisibleSizing; + +export namespace Sizing { + export const Distribute: DistributeSizing = { type: 'distribute' }; + export const Split: SplitSizing = { type: 'split' }; + export const Auto: AutoSizing = { type: 'auto' }; + export function Invisible(cachedVisibleSize: number): InvisibleSizing { return { type: 'invisible', cachedVisibleSize }; } +} + +export interface IGridStyles extends IGridViewStyles { } +export interface IGridOptions extends IGridViewOptions { } + +/** + * The {@link Grid} exposes a Grid widget in a friendlier API than the underlying + * {@link GridView} widget. Namely, all mutation operations are addressed by the + * model elements, rather than indexes. + * + * It support the same features as the {@link GridView}. + */ +export class Grid extends Disposable { + + protected gridview: GridView; + private views = new Map(); + + /** + * The orientation of the grid. Matches the orientation of the root + * {@link SplitView} in the grid's {@link GridLocation} model. + */ + get orientation(): Orientation { return this.gridview.orientation; } + set orientation(orientation: Orientation) { this.gridview.orientation = orientation; } + + /** + * The width of the grid. + */ + get width(): number { return this.gridview.width; } + + /** + * The height of the grid. + */ + get height(): number { return this.gridview.height; } + + /** + * The minimum width of the grid. + */ + get minimumWidth(): number { return this.gridview.minimumWidth; } + + /** + * The minimum height of the grid. + */ + get minimumHeight(): number { return this.gridview.minimumHeight; } + + /** + * The maximum width of the grid. + */ + get maximumWidth(): number { return this.gridview.maximumWidth; } + + /** + * The maximum height of the grid. + */ + get maximumHeight(): number { return this.gridview.maximumHeight; } + + /** + * Fires whenever a view within the grid changes its size constraints. + */ + readonly onDidChange: Event<{ width: number; height: number } | undefined>; + + /** + * Fires whenever the user scrolls a {@link SplitView} within + * the grid. + */ + readonly onDidScroll: Event; + + /** + * A collection of sashes perpendicular to each edge of the grid. + * Corner sashes will be created for each intersection. + */ + get boundarySashes(): IBoundarySashes { return this.gridview.boundarySashes; } + set boundarySashes(boundarySashes: IBoundarySashes) { this.gridview.boundarySashes = boundarySashes; } + + /** + * Enable/disable edge snapping across all grid views. + */ + set edgeSnapping(edgeSnapping: boolean) { this.gridview.edgeSnapping = edgeSnapping; } + + /** + * The DOM element for this view. + */ + get element(): HTMLElement { return this.gridview.element; } + + private didLayout = false; + + readonly onDidChangeViewMaximized: Event; + /** + * Create a new {@link Grid}. A grid must *always* have a view + * inside. + * + * @param view An initial view for this Grid. + */ + constructor(view: T | GridView, options: IGridOptions = {}) { + super(); + + if (view instanceof GridView) { + this.gridview = view; + this.gridview.getViewMap(this.views); + } else { + this.gridview = new GridView(options); + } + + this._register(this.gridview); + this._register(this.gridview.onDidSashReset(this.onDidSashReset, this)); + + if (!(view instanceof GridView)) { + this._addView(view, 0, [0]); + } + + this.onDidChange = this.gridview.onDidChange; + this.onDidScroll = this.gridview.onDidScroll; + this.onDidChangeViewMaximized = this.gridview.onDidChangeViewMaximized; + } + + style(styles: IGridStyles): void { + this.gridview.style(styles); + } + + /** + * Layout the {@link Grid}. + * + * Optionally provide a `top` and `left` positions, those will propagate + * as an origin for positions passed to {@link IView.layout}. + * + * @param width The width of the {@link Grid}. + * @param height The height of the {@link Grid}. + * @param top Optional, the top location of the {@link Grid}. + * @param left Optional, the left location of the {@link Grid}. + */ + layout(width: number, height: number, top: number = 0, left: number = 0): void { + this.gridview.layout(width, height, top, left); + this.didLayout = true; + } + + /** + * Add a {@link IView view} to this {@link Grid}, based on another reference view. + * + * Take this grid as an example: + * + * ``` + * +-----+---------------+ + * | A | B | + * +-----+---------+-----+ + * | C | | + * +---------------+ D | + * | E | | + * +---------------+-----+ + * ``` + * + * Calling `addView(X, Sizing.Distribute, C, Direction.Right)` will make the following + * changes: + * + * ``` + * +-----+---------------+ + * | A | B | + * +-----+-+-------+-----+ + * | C | X | | + * +-------+-------+ D | + * | E | | + * +---------------+-----+ + * ``` + * + * Or `addView(X, Sizing.Distribute, D, Direction.Down)`: + * + * ``` + * +-----+---------------+ + * | A | B | + * +-----+---------+-----+ + * | C | D | + * +---------------+-----+ + * | E | X | + * +---------------+-----+ + * ``` + * + * @param newView The view to add. + * @param size Either a fixed size, or a dynamic {@link Sizing} strategy. + * @param referenceView Another view to place this new view next to. + * @param direction The direction the new view should be placed next to the reference view. + */ + addView(newView: T, size: number | Sizing, referenceView: T, direction: Direction): void { + if (this.views.has(newView)) { + throw new Error('Can\'t add same view twice'); + } + + const orientation = getDirectionOrientation(direction); + + if (this.views.size === 1 && this.orientation !== orientation) { + this.orientation = orientation; + } + + const referenceLocation = this.getViewLocation(referenceView); + const location = getRelativeLocation(this.gridview.orientation, referenceLocation, direction); + + let viewSize: number | GridViewSizing; + + if (typeof size === 'number') { + viewSize = size; + } else if (size.type === 'split') { + const [, index] = tail(referenceLocation); + viewSize = GridViewSizing.Split(index); + } else if (size.type === 'distribute') { + viewSize = GridViewSizing.Distribute; + } else if (size.type === 'auto') { + const [, index] = tail(referenceLocation); + viewSize = GridViewSizing.Auto(index); + } else { + viewSize = size; + } + + this._addView(newView, viewSize, location); + } + + private addViewAt(newView: T, size: number | DistributeSizing | InvisibleSizing, location: GridLocation): void { + if (this.views.has(newView)) { + throw new Error('Can\'t add same view twice'); + } + + let viewSize: number | GridViewSizing; + + if (typeof size === 'number') { + viewSize = size; + } else if (size.type === 'distribute') { + viewSize = GridViewSizing.Distribute; + } else { + viewSize = size; + } + + this._addView(newView, viewSize, location); + } + + protected _addView(newView: T, size: number | GridViewSizing, location: GridLocation): void { + this.views.set(newView, newView.element); + this.gridview.addView(newView, size, location); + } + + /** + * Remove a {@link IView view} from this {@link Grid}. + * + * @param view The {@link IView view} to remove. + * @param sizing Whether to distribute other {@link IView view}'s sizes. + */ + removeView(view: T, sizing?: Sizing): void { + if (this.views.size === 1) { + throw new Error('Can\'t remove last view'); + } + + const location = this.getViewLocation(view); + + let gridViewSizing: DistributeSizing | SplitViewAutoSizing | undefined; + + if (sizing?.type === 'distribute') { + gridViewSizing = GridViewSizing.Distribute; + } else if (sizing?.type === 'auto') { + const index = location[location.length - 1]; + gridViewSizing = GridViewSizing.Auto(index === 0 ? 1 : index - 1); + } + + this.gridview.removeView(location, gridViewSizing); + this.views.delete(view); + } + + /** + * Move a {@link IView view} to another location in the grid. + * + * @remarks See {@link Grid.addView}. + * + * @param view The {@link IView view} to move. + * @param sizing Either a fixed size, or a dynamic {@link Sizing} strategy. + * @param referenceView Another view to place the view next to. + * @param direction The direction the view should be placed next to the reference view. + */ + moveView(view: T, sizing: number | Sizing, referenceView: T, direction: Direction): void { + const sourceLocation = this.getViewLocation(view); + const [sourceParentLocation, from] = tail(sourceLocation); + + const referenceLocation = this.getViewLocation(referenceView); + const targetLocation = getRelativeLocation(this.gridview.orientation, referenceLocation, direction); + const [targetParentLocation, to] = tail(targetLocation); + + if (equals(sourceParentLocation, targetParentLocation)) { + this.gridview.moveView(sourceParentLocation, from, to); + } else { + this.removeView(view, typeof sizing === 'number' ? undefined : sizing); + this.addView(view, sizing, referenceView, direction); + } + } + + /** + * Move a {@link IView view} to another location in the grid. + * + * @remarks Internal method, do not use without knowing what you're doing. + * @remarks See {@link GridView.moveView}. + * + * @param view The {@link IView view} to move. + * @param location The {@link GridLocation location} to insert the view on. + */ + moveViewTo(view: T, location: GridLocation): void { + const sourceLocation = this.getViewLocation(view); + const [sourceParentLocation, from] = tail(sourceLocation); + const [targetParentLocation, to] = tail(location); + + if (equals(sourceParentLocation, targetParentLocation)) { + this.gridview.moveView(sourceParentLocation, from, to); + } else { + const size = this.getViewSize(view); + const orientation = getLocationOrientation(this.gridview.orientation, sourceLocation); + const cachedViewSize = this.getViewCachedVisibleSize(view); + const sizing = typeof cachedViewSize === 'undefined' + ? (orientation === Orientation.HORIZONTAL ? size.width : size.height) + : Sizing.Invisible(cachedViewSize); + + this.removeView(view); + this.addViewAt(view, sizing, location); + } + } + + /** + * Swap two {@link IView views} within the {@link Grid}. + * + * @param from One {@link IView view}. + * @param to Another {@link IView view}. + */ + swapViews(from: T, to: T): void { + const fromLocation = this.getViewLocation(from); + const toLocation = this.getViewLocation(to); + return this.gridview.swapViews(fromLocation, toLocation); + } + + /** + * Resize a {@link IView view}. + * + * @param view The {@link IView view} to resize. + * @param size The size the view should be. + */ + resizeView(view: T, size: IViewSize): void { + const location = this.getViewLocation(view); + return this.gridview.resizeView(location, size); + } + + /** + * Returns whether all other {@link IView views} are at their minimum size. + * + * @param view The reference {@link IView view}. + */ + isViewExpanded(view: T): boolean { + const location = this.getViewLocation(view); + return this.gridview.isViewExpanded(location); + } + + /** + * Returns whether the {@link IView view} is maximized. + * + * @param view The reference {@link IView view}. + */ + isViewMaximized(view: T): boolean { + const location = this.getViewLocation(view); + return this.gridview.isViewMaximized(location); + } + + /** + * Returns whether the {@link IView view} is maximized. + * + * @param view The reference {@link IView view}. + */ + hasMaximizedView(): boolean { + return this.gridview.hasMaximizedView(); + } + + /** + * Get the size of a {@link IView view}. + * + * @param view The {@link IView view}. Provide `undefined` to get the size + * of the grid itself. + */ + getViewSize(view?: T): IViewSize { + if (!view) { + return this.gridview.getViewSize(); + } + + const location = this.getViewLocation(view); + return this.gridview.getViewSize(location); + } + + /** + * Get the cached visible size of a {@link IView view}. This was the size + * of the view at the moment it last became hidden. + * + * @param view The {@link IView view}. + */ + getViewCachedVisibleSize(view: T): number | undefined { + const location = this.getViewLocation(view); + return this.gridview.getViewCachedVisibleSize(location); + } + + /** + * Maximizes the specified view and hides all other views. + * @param view The view to maximize. + */ + maximizeView(view: T) { + if (this.views.size < 2) { + throw new Error('At least two views are required to maximize a view'); + } + const location = this.getViewLocation(view); + this.gridview.maximizeView(location); + } + + exitMaximizedView(): void { + this.gridview.exitMaximizedView(); + } + + /** + * Expand the size of a {@link IView view} by collapsing all other views + * to their minimum sizes. + * + * @param view The {@link IView view}. + */ + expandView(view: T): void { + const location = this.getViewLocation(view); + this.gridview.expandView(location); + } + + /** + * Distribute the size among all {@link IView views} within the entire + * grid or within a single {@link SplitView}. + */ + distributeViewSizes(): void { + this.gridview.distributeViewSizes(); + } + + /** + * Returns whether a {@link IView view} is visible. + * + * @param view The {@link IView view}. + */ + isViewVisible(view: T): boolean { + const location = this.getViewLocation(view); + return this.gridview.isViewVisible(location); + } + + /** + * Set the visibility state of a {@link IView view}. + * + * @param view The {@link IView view}. + */ + setViewVisible(view: T, visible: boolean): void { + const location = this.getViewLocation(view); + this.gridview.setViewVisible(location, visible); + } + + /** + * Returns a descriptor for the entire grid. + */ + getViews(): GridBranchNode { + return this.gridview.getView() as GridBranchNode; + } + + /** + * Utility method to return the collection all views which intersect + * a view's edge. + * + * @param view The {@link IView view}. + * @param direction Which direction edge to be considered. + * @param wrap Whether the grid wraps around (from right to left, from bottom to top). + */ + getNeighborViews(view: T, direction: Direction, wrap: boolean = false): T[] { + if (!this.didLayout) { + throw new Error('Can\'t call getNeighborViews before first layout'); + } + + const location = this.getViewLocation(view); + const root = this.getViews(); + const node = getGridNode(root, location); + let boundary = getBoxBoundary(node.box, direction); + + if (wrap) { + if (direction === Direction.Up && node.box.top === 0) { + boundary = { offset: root.box.top + root.box.height, range: boundary.range }; + } else if (direction === Direction.Right && node.box.left + node.box.width === root.box.width) { + boundary = { offset: 0, range: boundary.range }; + } else if (direction === Direction.Down && node.box.top + node.box.height === root.box.height) { + boundary = { offset: 0, range: boundary.range }; + } else if (direction === Direction.Left && node.box.left === 0) { + boundary = { offset: root.box.left + root.box.width, range: boundary.range }; + } + } + + return findAdjacentBoxLeafNodes(root, oppositeDirection(direction), boundary) + .map(node => node.view); + } + + private getViewLocation(view: T): GridLocation { + const element = this.views.get(view); + + if (!element) { + throw new Error('View not found'); + } + + return getGridLocation(element); + } + + private onDidSashReset(location: GridLocation): void { + const resizeToPreferredSize = (location: GridLocation): boolean => { + const node = this.gridview.getView(location) as GridNode; + + if (isGridBranchNode(node)) { + return false; + } + + const direction = getLocationOrientation(this.orientation, location); + const size = direction === Orientation.HORIZONTAL ? node.view.preferredWidth : node.view.preferredHeight; + + if (typeof size !== 'number') { + return false; + } + + const viewSize = direction === Orientation.HORIZONTAL ? { width: Math.round(size) } : { height: Math.round(size) }; + this.gridview.resizeView(location, viewSize); + return true; + }; + + if (resizeToPreferredSize(location)) { + return; + } + + const [parentLocation, index] = tail(location); + + if (resizeToPreferredSize([...parentLocation, index + 1])) { + return; + } + + this.gridview.distributeViewSizes(parentLocation); + } +} + +export interface ISerializableView extends IView { + toJSON(): object; +} + +export interface IViewDeserializer { + fromJSON(json: any): T; +} + +export interface ISerializedLeafNode { + type: 'leaf'; + data: any; + size: number; + visible?: boolean; + maximized?: boolean; +} + +export interface ISerializedBranchNode { + type: 'branch'; + data: ISerializedNode[]; + size: number; + visible?: boolean; +} + +export type ISerializedNode = ISerializedLeafNode | ISerializedBranchNode; + +export interface ISerializedGrid { + root: ISerializedNode; + orientation: Orientation; + width: number; + height: number; +} + +/** + * A {@link Grid} which can serialize itself. + */ +export class SerializableGrid extends Grid { + + private static serializeNode(node: GridNode, orientation: Orientation): ISerializedNode { + const size = orientation === Orientation.VERTICAL ? node.box.width : node.box.height; + + if (!isGridBranchNode(node)) { + const serializedLeafNode: ISerializedLeafNode = { type: 'leaf', data: node.view.toJSON(), size }; + + if (typeof node.cachedVisibleSize === 'number') { + serializedLeafNode.size = node.cachedVisibleSize; + serializedLeafNode.visible = false; + } else if (node.maximized) { + serializedLeafNode.maximized = true; + } + + return serializedLeafNode; + } + + const data = node.children.map(c => SerializableGrid.serializeNode(c, orthogonal(orientation))); + if (data.some(c => c.visible !== false)) { + return { type: 'branch', data: data, size }; + } + return { type: 'branch', data: data, size, visible: false }; + } + + /** + * Construct a new {@link SerializableGrid} from a JSON object. + * + * @param json The JSON object. + * @param deserializer A deserializer which can revive each view. + * @returns A new {@link SerializableGrid} instance. + */ + static deserialize(json: ISerializedGrid, deserializer: IViewDeserializer, options: IGridOptions = {}): SerializableGrid { + if (typeof json.orientation !== 'number') { + throw new Error('Invalid JSON: \'orientation\' property must be a number.'); + } else if (typeof json.width !== 'number') { + throw new Error('Invalid JSON: \'width\' property must be a number.'); + } else if (typeof json.height !== 'number') { + throw new Error('Invalid JSON: \'height\' property must be a number.'); + } + + const gridview = GridView.deserialize(json, deserializer, options); + const result = new SerializableGrid(gridview, options); + + return result; + } + + /** + * Construct a new {@link SerializableGrid} from a grid descriptor. + * + * @param gridDescriptor A grid descriptor in which leaf nodes point to actual views. + * @returns A new {@link SerializableGrid} instance. + */ + static from(gridDescriptor: GridDescriptor, options: IGridOptions = {}): SerializableGrid { + return SerializableGrid.deserialize(createSerializedGrid(gridDescriptor), { fromJSON: view => view }, options); + } + + /** + * Useful information in order to proportionally restore view sizes + * upon the very first layout call. + */ + private initialLayoutContext: boolean = true; + + /** + * Serialize this grid into a JSON object. + */ + serialize(): ISerializedGrid { + return { + root: SerializableGrid.serializeNode(this.getViews(), this.orientation), + orientation: this.orientation, + width: this.width, + height: this.height + }; + } + + override layout(width: number, height: number, top: number = 0, left: number = 0): void { + super.layout(width, height, top, left); + + if (this.initialLayoutContext) { + this.initialLayoutContext = false; + this.gridview.trySet2x2(); + } + } +} + +export type GridLeafNodeDescriptor = { size?: number; data?: any }; +export type GridBranchNodeDescriptor = { size?: number; groups: GridNodeDescriptor[] }; +export type GridNodeDescriptor = GridBranchNodeDescriptor | GridLeafNodeDescriptor; +export type GridDescriptor = { orientation: Orientation } & GridBranchNodeDescriptor; + +function isGridBranchNodeDescriptor(nodeDescriptor: GridNodeDescriptor): nodeDescriptor is GridBranchNodeDescriptor { + return !!(nodeDescriptor as GridBranchNodeDescriptor).groups; +} + +export function sanitizeGridNodeDescriptor(nodeDescriptor: GridNodeDescriptor, rootNode: boolean): void { + if (!rootNode && (nodeDescriptor as any).groups && (nodeDescriptor as any).groups.length <= 1) { + (nodeDescriptor as any).groups = undefined; + } + + if (!isGridBranchNodeDescriptor(nodeDescriptor)) { + return; + } + + let totalDefinedSize = 0; + let totalDefinedSizeCount = 0; + + for (const child of nodeDescriptor.groups) { + sanitizeGridNodeDescriptor(child, false); + + if (child.size) { + totalDefinedSize += child.size; + totalDefinedSizeCount++; + } + } + + const totalUndefinedSize = totalDefinedSizeCount > 0 ? totalDefinedSize : 1; + const totalUndefinedSizeCount = nodeDescriptor.groups.length - totalDefinedSizeCount; + const eachUndefinedSize = totalUndefinedSize / totalUndefinedSizeCount; + + for (const child of nodeDescriptor.groups) { + if (!child.size) { + child.size = eachUndefinedSize; + } + } +} + +function createSerializedNode(nodeDescriptor: GridNodeDescriptor): ISerializedNode { + if (isGridBranchNodeDescriptor(nodeDescriptor)) { + return { type: 'branch', data: nodeDescriptor.groups.map(c => createSerializedNode(c)), size: nodeDescriptor.size! }; + } else { + return { type: 'leaf', data: nodeDescriptor.data, size: nodeDescriptor.size! }; + } +} + +function getDimensions(node: ISerializedNode, orientation: Orientation): { width?: number; height?: number } { + if (node.type === 'branch') { + const childrenDimensions = node.data.map(c => getDimensions(c, orthogonal(orientation))); + + if (orientation === Orientation.VERTICAL) { + const width = node.size || (childrenDimensions.length === 0 ? undefined : Math.max(...childrenDimensions.map(d => d.width || 0))); + const height = childrenDimensions.length === 0 ? undefined : childrenDimensions.reduce((r, d) => r + (d.height || 0), 0); + return { width, height }; + } else { + const width = childrenDimensions.length === 0 ? undefined : childrenDimensions.reduce((r, d) => r + (d.width || 0), 0); + const height = node.size || (childrenDimensions.length === 0 ? undefined : Math.max(...childrenDimensions.map(d => d.height || 0))); + return { width, height }; + } + } else { + const width = orientation === Orientation.VERTICAL ? node.size : undefined; + const height = orientation === Orientation.VERTICAL ? undefined : node.size; + return { width, height }; + } +} + +/** + * Creates a new JSON object from a {@link GridDescriptor}, which can + * be deserialized by {@link SerializableGrid.deserialize}. + */ +export function createSerializedGrid(gridDescriptor: GridDescriptor): ISerializedGrid { + sanitizeGridNodeDescriptor(gridDescriptor, true); + + const root = createSerializedNode(gridDescriptor); + const { width, height } = getDimensions(root, gridDescriptor.orientation); + + return { + root, + orientation: gridDescriptor.orientation, + width: width || 1, + height: height || 1 + }; +} diff --git a/src/vs/base/browser/ui/grid/gridview.css b/src/vs/base/browser/ui/grid/gridview.css new file mode 100644 index 0000000000..d38154de9c --- /dev/null +++ b/src/vs/base/browser/ui/grid/gridview.css @@ -0,0 +1,16 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.monaco-grid-view { + position: relative; + overflow: hidden; + width: 100%; + height: 100%; +} + +.monaco-grid-branch-node { + width: 100%; + height: 100%; +} diff --git a/src/vs/base/browser/ui/grid/gridview.ts b/src/vs/base/browser/ui/grid/gridview.ts new file mode 100644 index 0000000000..0d2179a66a --- /dev/null +++ b/src/vs/base/browser/ui/grid/gridview.ts @@ -0,0 +1,1836 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { $ } from 'vs/base/browser/dom'; +import { IBoundarySashes, Orientation, Sash } from 'vs/base/browser/ui/sash/sash'; +import { DistributeSizing, ISplitViewStyles, IView as ISplitView, LayoutPriority, Sizing, AutoSizing, SplitView } from 'vs/base/browser/ui/splitview/splitview'; +import { equals as arrayEquals, tail2 as tail } from 'vs/base/common/arrays'; +import { Color } from 'vs/base/common/color'; +import { Emitter, Event, Relay } from 'vs/base/common/event'; +import { Disposable, DisposableStore, IDisposable, toDisposable } from 'vs/base/common/lifecycle'; +import { rot } from 'vs/base/common/numbers'; +import { isUndefined } from 'vs/base/common/types'; +// import 'vs/css!./gridview'; + +export { Orientation } from 'vs/base/browser/ui/sash/sash'; +export { LayoutPriority, Sizing } from 'vs/base/browser/ui/splitview/splitview'; + +export interface IGridViewStyles extends ISplitViewStyles { } + +const defaultStyles: IGridViewStyles = { + separatorBorder: Color.transparent +}; + +export interface IViewSize { + readonly width: number; + readonly height: number; +} + +interface IRelativeBoundarySashes { + readonly start?: Sash; + readonly end?: Sash; + readonly orthogonalStart?: Sash; + readonly orthogonalEnd?: Sash; +} + +/** + * The interface to implement for views within a {@link GridView}. + */ +export interface IView { + + /** + * The DOM element for this view. + */ + readonly element: HTMLElement; + + /** + * A minimum width for this view. + * + * @remarks If none, set it to `0`. + */ + readonly minimumWidth: number; + + /** + * A minimum width for this view. + * + * @remarks If none, set it to `Number.POSITIVE_INFINITY`. + */ + readonly maximumWidth: number; + + /** + * A minimum height for this view. + * + * @remarks If none, set it to `0`. + */ + readonly minimumHeight: number; + + /** + * A minimum height for this view. + * + * @remarks If none, set it to `Number.POSITIVE_INFINITY`. + */ + readonly maximumHeight: number; + + /** + * The priority of the view when the {@link GridView} layout algorithm + * runs. Views with higher priority will be resized first. + * + * @remarks Only used when `proportionalLayout` is false. + */ + readonly priority?: LayoutPriority; + + /** + * If the {@link GridView} supports proportional layout, + * this property allows for finer control over the proportional layout algorithm, per view. + * + * @defaultValue `true` + */ + readonly proportionalLayout?: boolean; + + /** + * Whether the view will snap whenever the user reaches its minimum size or + * attempts to grow it beyond the minimum size. + * + * @defaultValue `false` + */ + readonly snap?: boolean; + + /** + * View instances are supposed to fire this event whenever any of the constraint + * properties have changed: + * + * - {@link IView.minimumWidth} + * - {@link IView.maximumWidth} + * - {@link IView.minimumHeight} + * - {@link IView.maximumHeight} + * - {@link IView.priority} + * - {@link IView.snap} + * + * The {@link GridView} will relayout whenever that happens. The event can + * optionally emit the view's preferred size for that relayout. + */ + readonly onDidChange: Event; + + /** + * This will be called by the {@link GridView} during layout. A view meant to + * pass along the layout information down to its descendants. + */ + layout(width: number, height: number, top: number, left: number): void; + + /** + * This will be called by the {@link GridView} whenever this view is made + * visible or hidden. + * + * @param visible Whether the view becomes visible. + */ + setVisible?(visible: boolean): void; + + /** + * This will be called by the {@link GridView} whenever this view is on + * an edge of the grid and the grid's + * {@link GridView.boundarySashes boundary sashes} change. + */ + setBoundarySashes?(sashes: IBoundarySashes): void; +} + +export interface ISerializableView extends IView { + toJSON(): object; +} + +export interface IViewDeserializer { + fromJSON(json: any): T; +} + +export interface ISerializedLeafNode { + type: 'leaf'; + data: any; + size: number; + visible?: boolean; + maximized?: boolean; +} + +export interface ISerializedBranchNode { + type: 'branch'; + data: ISerializedNode[]; + size: number; + visible?: boolean; +} + +export type ISerializedNode = ISerializedLeafNode | ISerializedBranchNode; + +export interface ISerializedGridView { + root: ISerializedNode; + orientation: Orientation; + width: number; + height: number; +} + +export function orthogonal(orientation: Orientation): Orientation { + return orientation === Orientation.VERTICAL ? Orientation.HORIZONTAL : Orientation.VERTICAL; +} + +export interface Box { + readonly top: number; + readonly left: number; + readonly width: number; + readonly height: number; +} + +export interface GridLeafNode { + readonly view: IView; + readonly box: Box; + readonly cachedVisibleSize: number | undefined; + readonly maximized: boolean; +} + +export interface GridBranchNode { + readonly children: GridNode[]; + readonly box: Box; +} + +export type GridNode = GridLeafNode | GridBranchNode; + +export function isGridBranchNode(node: GridNode): node is GridBranchNode { + return !!(node as any).children; +} + +class LayoutController { + constructor(public isLayoutEnabled: boolean) { } +} + +export interface IGridViewOptions { + + /** + * Styles overriding the {@link defaultStyles default ones}. + */ + readonly styles?: IGridViewStyles; + + /** + * Resize each view proportionally when resizing the {@link GridView}. + * + * @defaultValue `true` + */ + readonly proportionalLayout?: boolean; // default true +} + +interface ILayoutContext { + readonly orthogonalSize: number; + readonly absoluteOffset: number; + readonly absoluteOrthogonalOffset: number; + readonly absoluteSize: number; + readonly absoluteOrthogonalSize: number; +} + +function toAbsoluteBoundarySashes(sashes: IRelativeBoundarySashes, orientation: Orientation): IBoundarySashes { + if (orientation === Orientation.HORIZONTAL) { + return { left: sashes.start, right: sashes.end, top: sashes.orthogonalStart, bottom: sashes.orthogonalEnd }; + } else { + return { top: sashes.start, bottom: sashes.end, left: sashes.orthogonalStart, right: sashes.orthogonalEnd }; + } +} + +function fromAbsoluteBoundarySashes(sashes: IBoundarySashes, orientation: Orientation): IRelativeBoundarySashes { + if (orientation === Orientation.HORIZONTAL) { + return { start: sashes.left, end: sashes.right, orthogonalStart: sashes.top, orthogonalEnd: sashes.bottom }; + } else { + return { start: sashes.top, end: sashes.bottom, orthogonalStart: sashes.left, orthogonalEnd: sashes.right }; + } +} + +function validateIndex(index: number, numChildren: number): number { + if (Math.abs(index) > numChildren) { + throw new Error('Invalid index'); + } + + return rot(index, numChildren + 1); +} + +class BranchNode implements ISplitView, IDisposable { + + readonly element: HTMLElement; + readonly children: Node[] = []; + private splitview: SplitView; + + private _size: number; + get size(): number { return this._size; } + + private _orthogonalSize: number; + get orthogonalSize(): number { return this._orthogonalSize; } + + private _absoluteOffset: number = 0; + get absoluteOffset(): number { return this._absoluteOffset; } + + private _absoluteOrthogonalOffset: number = 0; + get absoluteOrthogonalOffset(): number { return this._absoluteOrthogonalOffset; } + + private absoluteOrthogonalSize: number = 0; + + private _styles: IGridViewStyles; + get styles(): IGridViewStyles { return this._styles; } + + get width(): number { + return this.orientation === Orientation.HORIZONTAL ? this.size : this.orthogonalSize; + } + + get height(): number { + return this.orientation === Orientation.HORIZONTAL ? this.orthogonalSize : this.size; + } + + get top(): number { + return this.orientation === Orientation.HORIZONTAL ? this._absoluteOffset : this._absoluteOrthogonalOffset; + } + + get left(): number { + return this.orientation === Orientation.HORIZONTAL ? this._absoluteOrthogonalOffset : this._absoluteOffset; + } + + get minimumSize(): number { + return this.children.length === 0 ? 0 : Math.max(...this.children.map((c, index) => this.splitview.isViewVisible(index) ? c.minimumOrthogonalSize : 0)); + } + + get maximumSize(): number { + return Math.min(...this.children.map((c, index) => this.splitview.isViewVisible(index) ? c.maximumOrthogonalSize : Number.POSITIVE_INFINITY)); + } + + get priority(): LayoutPriority { + if (this.children.length === 0) { + return LayoutPriority.Normal; + } + + const priorities = this.children.map(c => typeof c.priority === 'undefined' ? LayoutPriority.Normal : c.priority); + + if (priorities.some(p => p === LayoutPriority.High)) { + return LayoutPriority.High; + } else if (priorities.some(p => p === LayoutPriority.Low)) { + return LayoutPriority.Low; + } + + return LayoutPriority.Normal; + } + + get proportionalLayout(): boolean { + if (this.children.length === 0) { + return true; + } + + return this.children.every(c => c.proportionalLayout); + } + + get minimumOrthogonalSize(): number { + return this.splitview.minimumSize; + } + + get maximumOrthogonalSize(): number { + return this.splitview.maximumSize; + } + + get minimumWidth(): number { + return this.orientation === Orientation.HORIZONTAL ? this.minimumOrthogonalSize : this.minimumSize; + } + + get minimumHeight(): number { + return this.orientation === Orientation.HORIZONTAL ? this.minimumSize : this.minimumOrthogonalSize; + } + + get maximumWidth(): number { + return this.orientation === Orientation.HORIZONTAL ? this.maximumOrthogonalSize : this.maximumSize; + } + + get maximumHeight(): number { + return this.orientation === Orientation.HORIZONTAL ? this.maximumSize : this.maximumOrthogonalSize; + } + + private readonly _onDidChange = new Emitter(); + readonly onDidChange: Event = this._onDidChange.event; + + private readonly _onDidVisibilityChange = new Emitter(); + readonly onDidVisibilityChange: Event = this._onDidVisibilityChange.event; + private readonly childrenVisibilityChangeDisposable: DisposableStore = new DisposableStore(); + + private _onDidScroll = new Emitter(); + private onDidScrollDisposable: IDisposable = Disposable.None; + readonly onDidScroll: Event = this._onDidScroll.event; + + private childrenChangeDisposable: IDisposable = Disposable.None; + + private readonly _onDidSashReset = new Emitter(); + readonly onDidSashReset: Event = this._onDidSashReset.event; + private splitviewSashResetDisposable: IDisposable = Disposable.None; + private childrenSashResetDisposable: IDisposable = Disposable.None; + + private _boundarySashes: IRelativeBoundarySashes = {}; + get boundarySashes(): IRelativeBoundarySashes { return this._boundarySashes; } + set boundarySashes(boundarySashes: IRelativeBoundarySashes) { + if (this._boundarySashes.start === boundarySashes.start + && this._boundarySashes.end === boundarySashes.end + && this._boundarySashes.orthogonalStart === boundarySashes.orthogonalStart + && this._boundarySashes.orthogonalEnd === boundarySashes.orthogonalEnd) { + return; + } + + this._boundarySashes = boundarySashes; + + this.splitview.orthogonalStartSash = boundarySashes.orthogonalStart; + this.splitview.orthogonalEndSash = boundarySashes.orthogonalEnd; + + for (let index = 0; index < this.children.length; index++) { + const child = this.children[index]; + const first = index === 0; + const last = index === this.children.length - 1; + + child.boundarySashes = { + start: boundarySashes.orthogonalStart, + end: boundarySashes.orthogonalEnd, + orthogonalStart: first ? boundarySashes.start : child.boundarySashes.orthogonalStart, + orthogonalEnd: last ? boundarySashes.end : child.boundarySashes.orthogonalEnd, + }; + } + } + + private _edgeSnapping = false; + get edgeSnapping(): boolean { return this._edgeSnapping; } + set edgeSnapping(edgeSnapping: boolean) { + if (this._edgeSnapping === edgeSnapping) { + return; + } + + this._edgeSnapping = edgeSnapping; + + for (const child of this.children) { + if (child instanceof BranchNode) { + child.edgeSnapping = edgeSnapping; + } + } + + this.updateSplitviewEdgeSnappingEnablement(); + } + + constructor( + readonly orientation: Orientation, + readonly layoutController: LayoutController, + styles: IGridViewStyles, + readonly splitviewProportionalLayout: boolean, + size: number = 0, + orthogonalSize: number = 0, + edgeSnapping: boolean = false, + childDescriptors?: INodeDescriptor[] + ) { + this._styles = styles; + this._size = size; + this._orthogonalSize = orthogonalSize; + + this.element = $('.monaco-grid-branch-node'); + + if (!childDescriptors) { + // Normal behavior, we have no children yet, just set up the splitview + this.splitview = new SplitView(this.element, { orientation, styles, proportionalLayout: splitviewProportionalLayout }); + this.splitview.layout(size, { orthogonalSize, absoluteOffset: 0, absoluteOrthogonalOffset: 0, absoluteSize: size, absoluteOrthogonalSize: orthogonalSize }); + } else { + // Reconstruction behavior, we want to reconstruct a splitview + const descriptor = { + views: childDescriptors.map(childDescriptor => { + return { + view: childDescriptor.node, + size: childDescriptor.node.size, + visible: childDescriptor.visible !== false + }; + }), + size: this.orthogonalSize + }; + + const options = { proportionalLayout: splitviewProportionalLayout, orientation, styles }; + + this.children = childDescriptors.map(c => c.node); + this.splitview = new SplitView(this.element, { ...options, descriptor }); + + this.children.forEach((node, index) => { + const first = index === 0; + const last = index === this.children.length; + + node.boundarySashes = { + start: this.boundarySashes.orthogonalStart, + end: this.boundarySashes.orthogonalEnd, + orthogonalStart: first ? this.boundarySashes.start : this.splitview.sashes[index - 1], + orthogonalEnd: last ? this.boundarySashes.end : this.splitview.sashes[index], + }; + }); + } + + const onDidSashReset = Event.map(this.splitview.onDidSashReset, i => [i]); + this.splitviewSashResetDisposable = onDidSashReset(this._onDidSashReset.fire, this._onDidSashReset); + + this.updateChildrenEvents(); + } + + style(styles: IGridViewStyles): void { + this._styles = styles; + this.splitview.style(styles); + + for (const child of this.children) { + if (child instanceof BranchNode) { + child.style(styles); + } + } + } + + layout(size: number, offset: number, ctx: ILayoutContext | undefined): void { + if (!this.layoutController.isLayoutEnabled) { + return; + } + + if (typeof ctx === 'undefined') { + throw new Error('Invalid state'); + } + + // branch nodes should flip the normal/orthogonal directions + this._size = ctx.orthogonalSize; + this._orthogonalSize = size; + this._absoluteOffset = ctx.absoluteOffset + offset; + this._absoluteOrthogonalOffset = ctx.absoluteOrthogonalOffset; + this.absoluteOrthogonalSize = ctx.absoluteOrthogonalSize; + + this.splitview.layout(ctx.orthogonalSize, { + orthogonalSize: size, + absoluteOffset: this._absoluteOrthogonalOffset, + absoluteOrthogonalOffset: this._absoluteOffset, + absoluteSize: ctx.absoluteOrthogonalSize, + absoluteOrthogonalSize: ctx.absoluteSize + }); + + this.updateSplitviewEdgeSnappingEnablement(); + } + + setVisible(visible: boolean): void { + for (const child of this.children) { + child.setVisible(visible); + } + } + + addChild(node: Node, size: number | Sizing, index: number, skipLayout?: boolean): void { + index = validateIndex(index, this.children.length); + + this.splitview.addView(node, size, index, skipLayout); + this.children.splice(index, 0, node); + + this.updateBoundarySashes(); + this.onDidChildrenChange(); + } + + removeChild(index: number, sizing?: Sizing): Node { + index = validateIndex(index, this.children.length); + + const result = this.splitview.removeView(index, sizing); + this.children.splice(index, 1); + + this.updateBoundarySashes(); + this.onDidChildrenChange(); + + return result; + } + + removeAllChildren(): Node[] { + const result = this.splitview.removeAllViews(); + + this.children.splice(0, this.children.length); + + this.updateBoundarySashes(); + this.onDidChildrenChange(); + + return result; + } + + moveChild(from: number, to: number): void { + from = validateIndex(from, this.children.length); + to = validateIndex(to, this.children.length); + + if (from === to) { + return; + } + + if (from < to) { + to -= 1; + } + + this.splitview.moveView(from, to); + this.children.splice(to, 0, this.children.splice(from, 1)[0]); + + this.updateBoundarySashes(); + this.onDidChildrenChange(); + } + + swapChildren(from: number, to: number): void { + from = validateIndex(from, this.children.length); + to = validateIndex(to, this.children.length); + + if (from === to) { + return; + } + + this.splitview.swapViews(from, to); + + // swap boundary sashes + [this.children[from].boundarySashes, this.children[to].boundarySashes] + = [this.children[from].boundarySashes, this.children[to].boundarySashes]; + + // swap children + [this.children[from], this.children[to]] = [this.children[to], this.children[from]]; + + this.onDidChildrenChange(); + } + + resizeChild(index: number, size: number): void { + index = validateIndex(index, this.children.length); + + this.splitview.resizeView(index, size); + } + + isChildExpanded(index: number): boolean { + return this.splitview.isViewExpanded(index); + } + + distributeViewSizes(recursive = false): void { + this.splitview.distributeViewSizes(); + + if (recursive) { + for (const child of this.children) { + if (child instanceof BranchNode) { + child.distributeViewSizes(true); + } + } + } + } + + getChildSize(index: number): number { + index = validateIndex(index, this.children.length); + + return this.splitview.getViewSize(index); + } + + isChildVisible(index: number): boolean { + index = validateIndex(index, this.children.length); + + return this.splitview.isViewVisible(index); + } + + setChildVisible(index: number, visible: boolean): void { + index = validateIndex(index, this.children.length); + + if (this.splitview.isViewVisible(index) === visible) { + return; + } + + const wereAllChildrenHidden = this.splitview.contentSize === 0; + this.splitview.setViewVisible(index, visible); + const areAllChildrenHidden = this.splitview.contentSize === 0; + + // If all children are hidden then the parent should hide the entire splitview + // If the entire splitview is hidden then the parent should show the splitview when a child is shown + if ((visible && wereAllChildrenHidden) || (!visible && areAllChildrenHidden)) { + this._onDidVisibilityChange.fire(visible); + } + } + + getChildCachedVisibleSize(index: number): number | undefined { + index = validateIndex(index, this.children.length); + + return this.splitview.getViewCachedVisibleSize(index); + } + + private updateBoundarySashes(): void { + for (let i = 0; i < this.children.length; i++) { + this.children[i].boundarySashes = { + start: this.boundarySashes.orthogonalStart, + end: this.boundarySashes.orthogonalEnd, + orthogonalStart: i === 0 ? this.boundarySashes.start : this.splitview.sashes[i - 1], + orthogonalEnd: i === this.children.length - 1 ? this.boundarySashes.end : this.splitview.sashes[i], + }; + } + } + + private onDidChildrenChange(): void { + this.updateChildrenEvents(); + this._onDidChange.fire(undefined); + } + + private updateChildrenEvents(): void { + const onDidChildrenChange = Event.map(Event.any(...this.children.map(c => c.onDidChange)), () => undefined); + this.childrenChangeDisposable.dispose(); + this.childrenChangeDisposable = onDidChildrenChange(this._onDidChange.fire, this._onDidChange); + + const onDidChildrenSashReset = Event.any(...this.children.map((c, i) => Event.map(c.onDidSashReset, location => [i, ...location]))); + this.childrenSashResetDisposable.dispose(); + this.childrenSashResetDisposable = onDidChildrenSashReset(this._onDidSashReset.fire, this._onDidSashReset); + + const onDidScroll = Event.any(Event.signal(this.splitview.onDidScroll), ...this.children.map(c => c.onDidScroll)); + this.onDidScrollDisposable.dispose(); + this.onDidScrollDisposable = onDidScroll(this._onDidScroll.fire, this._onDidScroll); + + this.childrenVisibilityChangeDisposable.clear(); + this.children.forEach((child, index) => { + if (child instanceof BranchNode) { + this.childrenVisibilityChangeDisposable.add(child.onDidVisibilityChange((visible) => { + this.setChildVisible(index, visible); + })); + } + }); + } + + trySet2x2(other: BranchNode): IDisposable { + if (this.children.length !== 2 || other.children.length !== 2) { + return Disposable.None; + } + + if (this.getChildSize(0) !== other.getChildSize(0)) { + return Disposable.None; + } + + const [firstChild, secondChild] = this.children; + const [otherFirstChild, otherSecondChild] = other.children; + + if (!(firstChild instanceof LeafNode) || !(secondChild instanceof LeafNode)) { + return Disposable.None; + } + + if (!(otherFirstChild instanceof LeafNode) || !(otherSecondChild instanceof LeafNode)) { + return Disposable.None; + } + + if (this.orientation === Orientation.VERTICAL) { + secondChild.linkedWidthNode = otherFirstChild.linkedHeightNode = firstChild; + firstChild.linkedWidthNode = otherSecondChild.linkedHeightNode = secondChild; + otherSecondChild.linkedWidthNode = firstChild.linkedHeightNode = otherFirstChild; + otherFirstChild.linkedWidthNode = secondChild.linkedHeightNode = otherSecondChild; + } else { + otherFirstChild.linkedWidthNode = secondChild.linkedHeightNode = firstChild; + otherSecondChild.linkedWidthNode = firstChild.linkedHeightNode = secondChild; + firstChild.linkedWidthNode = otherSecondChild.linkedHeightNode = otherFirstChild; + secondChild.linkedWidthNode = otherFirstChild.linkedHeightNode = otherSecondChild; + } + + const mySash = this.splitview.sashes[0]; + const otherSash = other.splitview.sashes[0]; + mySash.linkedSash = otherSash; + otherSash.linkedSash = mySash; + + this._onDidChange.fire(undefined); + other._onDidChange.fire(undefined); + + return toDisposable(() => { + mySash.linkedSash = otherSash.linkedSash = undefined; + firstChild.linkedHeightNode = firstChild.linkedWidthNode = undefined; + secondChild.linkedHeightNode = secondChild.linkedWidthNode = undefined; + otherFirstChild.linkedHeightNode = otherFirstChild.linkedWidthNode = undefined; + otherSecondChild.linkedHeightNode = otherSecondChild.linkedWidthNode = undefined; + }); + } + + private updateSplitviewEdgeSnappingEnablement(): void { + this.splitview.startSnappingEnabled = this._edgeSnapping || this._absoluteOrthogonalOffset > 0; + this.splitview.endSnappingEnabled = this._edgeSnapping || this._absoluteOrthogonalOffset + this._size < this.absoluteOrthogonalSize; + } + + dispose(): void { + for (const child of this.children) { + child.dispose(); + } + + this._onDidChange.dispose(); + this._onDidSashReset.dispose(); + this._onDidVisibilityChange.dispose(); + + this.childrenVisibilityChangeDisposable.dispose(); + this.splitviewSashResetDisposable.dispose(); + this.childrenSashResetDisposable.dispose(); + this.childrenChangeDisposable.dispose(); + this.onDidScrollDisposable.dispose(); + this.splitview.dispose(); + } +} + +/** + * Creates a latched event that avoids being fired when the view + * constraints do not change at all. + */ +function createLatchedOnDidChangeViewEvent(view: IView): Event { + const [onDidChangeViewConstraints, onDidSetViewSize] = Event.split(view.onDidChange, isUndefined); + + return Event.any( + onDidSetViewSize, + Event.map( + Event.latch( + Event.map(onDidChangeViewConstraints, _ => ([view.minimumWidth, view.maximumWidth, view.minimumHeight, view.maximumHeight])), + arrayEquals + ), + _ => undefined + ) + ); +} + +class LeafNode implements ISplitView, IDisposable { + + private _size: number = 0; + get size(): number { return this._size; } + + private _orthogonalSize: number; + get orthogonalSize(): number { return this._orthogonalSize; } + + private absoluteOffset: number = 0; + private absoluteOrthogonalOffset: number = 0; + + readonly onDidScroll: Event = Event.None; + readonly onDidSashReset: Event = Event.None; + + private _onDidLinkedWidthNodeChange = new Relay(); + private _linkedWidthNode: LeafNode | undefined = undefined; + get linkedWidthNode(): LeafNode | undefined { return this._linkedWidthNode; } + set linkedWidthNode(node: LeafNode | undefined) { + this._onDidLinkedWidthNodeChange.input = node ? node._onDidViewChange : Event.None; + this._linkedWidthNode = node; + this._onDidSetLinkedNode.fire(undefined); + } + + private _onDidLinkedHeightNodeChange = new Relay(); + private _linkedHeightNode: LeafNode | undefined = undefined; + get linkedHeightNode(): LeafNode | undefined { return this._linkedHeightNode; } + set linkedHeightNode(node: LeafNode | undefined) { + this._onDidLinkedHeightNodeChange.input = node ? node._onDidViewChange : Event.None; + this._linkedHeightNode = node; + this._onDidSetLinkedNode.fire(undefined); + } + + private readonly _onDidSetLinkedNode = new Emitter(); + private _onDidViewChange: Event; + readonly onDidChange: Event; + + private readonly disposables = new DisposableStore(); + + constructor( + readonly view: IView, + readonly orientation: Orientation, + readonly layoutController: LayoutController, + orthogonalSize: number, + size: number = 0 + ) { + this._orthogonalSize = orthogonalSize; + this._size = size; + + const onDidChange = createLatchedOnDidChangeViewEvent(view); + this._onDidViewChange = Event.map(onDidChange, e => e && (this.orientation === Orientation.VERTICAL ? e.width : e.height), this.disposables); + this.onDidChange = Event.any(this._onDidViewChange, this._onDidSetLinkedNode.event, this._onDidLinkedWidthNodeChange.event, this._onDidLinkedHeightNodeChange.event); + } + + get width(): number { + return this.orientation === Orientation.HORIZONTAL ? this.orthogonalSize : this.size; + } + + get height(): number { + return this.orientation === Orientation.HORIZONTAL ? this.size : this.orthogonalSize; + } + + get top(): number { + return this.orientation === Orientation.HORIZONTAL ? this.absoluteOffset : this.absoluteOrthogonalOffset; + } + + get left(): number { + return this.orientation === Orientation.HORIZONTAL ? this.absoluteOrthogonalOffset : this.absoluteOffset; + } + + get element(): HTMLElement { + return this.view.element; + } + + private get minimumWidth(): number { + return this.linkedWidthNode ? Math.max(this.linkedWidthNode.view.minimumWidth, this.view.minimumWidth) : this.view.minimumWidth; + } + + private get maximumWidth(): number { + return this.linkedWidthNode ? Math.min(this.linkedWidthNode.view.maximumWidth, this.view.maximumWidth) : this.view.maximumWidth; + } + + private get minimumHeight(): number { + return this.linkedHeightNode ? Math.max(this.linkedHeightNode.view.minimumHeight, this.view.minimumHeight) : this.view.minimumHeight; + } + + private get maximumHeight(): number { + return this.linkedHeightNode ? Math.min(this.linkedHeightNode.view.maximumHeight, this.view.maximumHeight) : this.view.maximumHeight; + } + + get minimumSize(): number { + return this.orientation === Orientation.HORIZONTAL ? this.minimumHeight : this.minimumWidth; + } + + get maximumSize(): number { + return this.orientation === Orientation.HORIZONTAL ? this.maximumHeight : this.maximumWidth; + } + + get priority(): LayoutPriority | undefined { + return this.view.priority; + } + + get proportionalLayout(): boolean { + return this.view.proportionalLayout ?? true; + } + + get snap(): boolean | undefined { + return this.view.snap; + } + + get minimumOrthogonalSize(): number { + return this.orientation === Orientation.HORIZONTAL ? this.minimumWidth : this.minimumHeight; + } + + get maximumOrthogonalSize(): number { + return this.orientation === Orientation.HORIZONTAL ? this.maximumWidth : this.maximumHeight; + } + + private _boundarySashes: IRelativeBoundarySashes = {}; + get boundarySashes(): IRelativeBoundarySashes { return this._boundarySashes; } + set boundarySashes(boundarySashes: IRelativeBoundarySashes) { + this._boundarySashes = boundarySashes; + + this.view.setBoundarySashes?.(toAbsoluteBoundarySashes(boundarySashes, this.orientation)); + } + + layout(size: number, offset: number, ctx: ILayoutContext | undefined): void { + if (!this.layoutController.isLayoutEnabled) { + return; + } + + if (typeof ctx === 'undefined') { + throw new Error('Invalid state'); + } + + this._size = size; + this._orthogonalSize = ctx.orthogonalSize; + this.absoluteOffset = ctx.absoluteOffset + offset; + this.absoluteOrthogonalOffset = ctx.absoluteOrthogonalOffset; + + this._layout(this.width, this.height, this.top, this.left); + } + + private cachedWidth: number = 0; + private cachedHeight: number = 0; + private cachedTop: number = 0; + private cachedLeft: number = 0; + + private _layout(width: number, height: number, top: number, left: number): void { + if (this.cachedWidth === width && this.cachedHeight === height && this.cachedTop === top && this.cachedLeft === left) { + return; + } + + this.cachedWidth = width; + this.cachedHeight = height; + this.cachedTop = top; + this.cachedLeft = left; + this.view.layout(width, height, top, left); + } + + setVisible(visible: boolean): void { + this.view.setVisible?.(visible); + } + + dispose(): void { + this.disposables.dispose(); + } +} + +type Node = BranchNode | LeafNode; + +export interface INodeDescriptor { + node: Node; + visible?: boolean; +} + +function flipNode(node: BranchNode, size: number, orthogonalSize: number): BranchNode; +function flipNode(node: LeafNode, size: number, orthogonalSize: number): LeafNode; +function flipNode(node: Node, size: number, orthogonalSize: number): Node; +function flipNode(node: Node, size: number, orthogonalSize: number): Node { + if (node instanceof BranchNode) { + const result = new BranchNode(orthogonal(node.orientation), node.layoutController, node.styles, node.splitviewProportionalLayout, size, orthogonalSize, node.edgeSnapping); + + let totalSize = 0; + + for (let i = node.children.length - 1; i >= 0; i--) { + const child = node.children[i]; + const childSize = child instanceof BranchNode ? child.orthogonalSize : child.size; + + let newSize = node.size === 0 ? 0 : Math.round((size * childSize) / node.size); + totalSize += newSize; + + // The last view to add should adjust to rounding errors + if (i === 0) { + newSize += size - totalSize; + } + + result.addChild(flipNode(child, orthogonalSize, newSize), newSize, 0, true); + } + + node.dispose(); + return result; + } else { + const result = new LeafNode(node.view, orthogonal(node.orientation), node.layoutController, orthogonalSize); + node.dispose(); + return result; + } +} + +/** + * The location of a {@link IView view} within a {@link GridView}. + * + * A GridView is a tree composition of multiple {@link SplitView} instances, orthogonal + * between one another. Here's an example: + * + * ``` + * +-----+---------------+ + * | A | B | + * +-----+---------+-----+ + * | C | | + * +---------------+ D | + * | E | | + * +---------------+-----+ + * ``` + * + * The above grid's tree structure is: + * + * ``` + * Vertical SplitView + * +-Horizontal SplitView + * | +-A + * | +-B + * +- Horizontal SplitView + * +-Vertical SplitView + * | +-C + * | +-E + * +-D + * ``` + * + * So, {@link IView views} within a {@link GridView} can be referenced by + * a sequence of indexes, each index referencing each SplitView. Here are + * each view's locations, from the example above: + * + * - `A`: `[0,0]` + * - `B`: `[0,1]` + * - `C`: `[1,0,0]` + * - `D`: `[1,1]` + * - `E`: `[1,0,1]` + */ +export type GridLocation = number[]; + +/** + * The {@link GridView} is the UI component which implements a two dimensional + * flex-like layout algorithm for a collection of {@link IView} instances, which + * are mostly HTMLElement instances with size constraints. A {@link GridView} is a + * tree composition of multiple {@link SplitView} instances, orthogonal between + * one another. It will respect view's size contraints, just like the SplitView. + * + * It has a low-level index based API, allowing for fine grain performant operations. + * Look into the {@link Grid} widget for a higher-level API. + * + * Features: + * - flex-like layout algorithm + * - snap support + * - corner sash support + * - Alt key modifier behavior, macOS style + * - layout (de)serialization + */ +export class GridView implements IDisposable { + + /** + * The DOM element for this view. + */ + readonly element: HTMLElement; + + private styles: IGridViewStyles; + private proportionalLayout: boolean; + private _root!: BranchNode; + private onDidSashResetRelay = new Relay(); + private _onDidScroll = new Relay(); + private _onDidChange = new Relay(); + private _boundarySashes: IBoundarySashes = {}; + + /** + * The layout controller makes sure layout only propagates + * to the views after the very first call to {@link GridView.layout}. + */ + private layoutController: LayoutController; + private disposable2x2: IDisposable = Disposable.None; + + private get root(): BranchNode { return this._root; } + + private set root(root: BranchNode) { + const oldRoot = this._root; + + if (oldRoot) { + oldRoot.element.remove(); + oldRoot.dispose(); + } + + this._root = root; + this.element.appendChild(root.element); + this.onDidSashResetRelay.input = root.onDidSashReset; + this._onDidChange.input = Event.map(root.onDidChange, () => undefined); // TODO + this._onDidScroll.input = root.onDidScroll; + } + + /** + * Fires whenever the user double clicks a {@link Sash sash}. + */ + readonly onDidSashReset = this.onDidSashResetRelay.event; + + /** + * Fires whenever the user scrolls a {@link SplitView} within + * the grid. + */ + readonly onDidScroll = this._onDidScroll.event; + + /** + * Fires whenever a view within the grid changes its size constraints. + */ + readonly onDidChange = this._onDidChange.event; + + /** + * The width of the grid. + */ + get width(): number { return this.root.width; } + + /** + * The height of the grid. + */ + get height(): number { return this.root.height; } + + /** + * The minimum width of the grid. + */ + get minimumWidth(): number { return this.root.minimumWidth; } + + /** + * The minimum height of the grid. + */ + get minimumHeight(): number { return this.root.minimumHeight; } + + /** + * The maximum width of the grid. + */ + get maximumWidth(): number { return this.root.maximumHeight; } + + /** + * The maximum height of the grid. + */ + get maximumHeight(): number { return this.root.maximumHeight; } + + get orientation(): Orientation { return this._root.orientation; } + get boundarySashes(): IBoundarySashes { return this._boundarySashes; } + + /** + * The orientation of the grid. Matches the orientation of the root + * {@link SplitView} in the grid's tree model. + */ + set orientation(orientation: Orientation) { + if (this._root.orientation === orientation) { + return; + } + + const { size, orthogonalSize, absoluteOffset, absoluteOrthogonalOffset } = this._root; + this.root = flipNode(this._root, orthogonalSize, size); + this.root.layout(size, 0, { orthogonalSize, absoluteOffset: absoluteOrthogonalOffset, absoluteOrthogonalOffset: absoluteOffset, absoluteSize: size, absoluteOrthogonalSize: orthogonalSize }); + this.boundarySashes = this.boundarySashes; + } + + /** + * A collection of sashes perpendicular to each edge of the grid. + * Corner sashes will be created for each intersection. + */ + set boundarySashes(boundarySashes: IBoundarySashes) { + this._boundarySashes = boundarySashes; + this.root.boundarySashes = fromAbsoluteBoundarySashes(boundarySashes, this.orientation); + } + + /** + * Enable/disable edge snapping across all grid views. + */ + set edgeSnapping(edgeSnapping: boolean) { + this.root.edgeSnapping = edgeSnapping; + } + + private maximizedNode: LeafNode | undefined = undefined; + + private readonly _onDidChangeViewMaximized = new Emitter(); + readonly onDidChangeViewMaximized = this._onDidChangeViewMaximized.event; + + /** + * Create a new {@link GridView} instance. + * + * @remarks It's the caller's responsibility to append the + * {@link GridView.element} to the page's DOM. + */ + constructor(options: IGridViewOptions = {}) { + this.element = $('.monaco-grid-view'); + this.styles = options.styles || defaultStyles; + this.proportionalLayout = typeof options.proportionalLayout !== 'undefined' ? !!options.proportionalLayout : true; + this.layoutController = new LayoutController(false); + this.root = new BranchNode(Orientation.VERTICAL, this.layoutController, this.styles, this.proportionalLayout); + } + + style(styles: IGridViewStyles): void { + this.styles = styles; + this.root.style(styles); + } + + /** + * Layout the {@link GridView}. + * + * Optionally provide a `top` and `left` positions, those will propagate + * as an origin for positions passed to {@link IView.layout}. + * + * @param width The width of the {@link GridView}. + * @param height The height of the {@link GridView}. + * @param top Optional, the top location of the {@link GridView}. + * @param left Optional, the left location of the {@link GridView}. + */ + layout(width: number, height: number, top: number = 0, left: number = 0): void { + this.layoutController.isLayoutEnabled = true; + + const [size, orthogonalSize, offset, orthogonalOffset] = this.root.orientation === Orientation.HORIZONTAL ? [height, width, top, left] : [width, height, left, top]; + this.root.layout(size, 0, { orthogonalSize, absoluteOffset: offset, absoluteOrthogonalOffset: orthogonalOffset, absoluteSize: size, absoluteOrthogonalSize: orthogonalSize }); + } + + /** + * Add a {@link IView view} to this {@link GridView}. + * + * @param view The view to add. + * @param size Either a fixed size, or a dynamic {@link Sizing} strategy. + * @param location The {@link GridLocation location} to insert the view on. + */ + addView(view: IView, size: number | Sizing, location: GridLocation): void { + if (this.hasMaximizedView()) { + this.exitMaximizedView(); + } + + this.disposable2x2.dispose(); + this.disposable2x2 = Disposable.None; + + const [rest, index] = tail(location); + const [pathToParent, parent] = this.getNode(rest); + + if (parent instanceof BranchNode) { + const node = new LeafNode(view, orthogonal(parent.orientation), this.layoutController, parent.orthogonalSize); + + try { + parent.addChild(node, size, index); + } catch (err) { + node.dispose(); + throw err; + } + } else { + const [, grandParent] = tail(pathToParent); + const [, parentIndex] = tail(rest); + + let newSiblingSize: number | Sizing = 0; + + const newSiblingCachedVisibleSize = grandParent.getChildCachedVisibleSize(parentIndex); + if (typeof newSiblingCachedVisibleSize === 'number') { + newSiblingSize = Sizing.Invisible(newSiblingCachedVisibleSize); + } + + const oldChild = grandParent.removeChild(parentIndex); + oldChild.dispose(); + + const newParent = new BranchNode(parent.orientation, parent.layoutController, this.styles, this.proportionalLayout, parent.size, parent.orthogonalSize, grandParent.edgeSnapping); + grandParent.addChild(newParent, parent.size, parentIndex); + + const newSibling = new LeafNode(parent.view, grandParent.orientation, this.layoutController, parent.size); + newParent.addChild(newSibling, newSiblingSize, 0); + + if (typeof size !== 'number' && size.type === 'split') { + size = Sizing.Split(0); + } + + const node = new LeafNode(view, grandParent.orientation, this.layoutController, parent.size); + newParent.addChild(node, size, index); + } + + this.trySet2x2(); + } + + /** + * Remove a {@link IView view} from this {@link GridView}. + * + * @param location The {@link GridLocation location} of the {@link IView view}. + * @param sizing Whether to distribute other {@link IView view}'s sizes. + */ + removeView(location: GridLocation, sizing?: DistributeSizing | AutoSizing): IView { + if (this.hasMaximizedView()) { + this.exitMaximizedView(); + } + + this.disposable2x2.dispose(); + this.disposable2x2 = Disposable.None; + + const [rest, index] = tail(location); + const [pathToParent, parent] = this.getNode(rest); + + if (!(parent instanceof BranchNode)) { + throw new Error('Invalid location'); + } + + const node = parent.children[index]; + + if (!(node instanceof LeafNode)) { + throw new Error('Invalid location'); + } + + parent.removeChild(index, sizing); + node.dispose(); + + if (parent.children.length === 0) { + throw new Error('Invalid grid state'); + } + + if (parent.children.length > 1) { + this.trySet2x2(); + return node.view; + } + + if (pathToParent.length === 0) { // parent is root + const sibling = parent.children[0]; + + if (sibling instanceof LeafNode) { + return node.view; + } + + // we must promote sibling to be the new root + parent.removeChild(0); + parent.dispose(); + this.root = sibling; + this.boundarySashes = this.boundarySashes; + this.trySet2x2(); + return node.view; + } + + const [, grandParent] = tail(pathToParent); + const [, parentIndex] = tail(rest); + + const isSiblingVisible = parent.isChildVisible(0); + const sibling = parent.removeChild(0); + + const sizes = grandParent.children.map((_, i) => grandParent.getChildSize(i)); + grandParent.removeChild(parentIndex, sizing); + parent.dispose(); + + if (sibling instanceof BranchNode) { + sizes.splice(parentIndex, 1, ...sibling.children.map(c => c.size)); + + const siblingChildren = sibling.removeAllChildren(); + + for (let i = 0; i < siblingChildren.length; i++) { + grandParent.addChild(siblingChildren[i], siblingChildren[i].size, parentIndex + i); + } + } else { + const newSibling = new LeafNode(sibling.view, orthogonal(sibling.orientation), this.layoutController, sibling.size); + const sizing = isSiblingVisible ? sibling.orthogonalSize : Sizing.Invisible(sibling.orthogonalSize); + grandParent.addChild(newSibling, sizing, parentIndex); + } + + sibling.dispose(); + + for (let i = 0; i < sizes.length; i++) { + grandParent.resizeChild(i, sizes[i]); + } + + this.trySet2x2(); + return node.view; + } + + /** + * Move a {@link IView view} within its parent. + * + * @param parentLocation The {@link GridLocation location} of the {@link IView view}'s parent. + * @param from The index of the {@link IView view} to move. + * @param to The index where the {@link IView view} should move to. + */ + moveView(parentLocation: GridLocation, from: number, to: number): void { + if (this.hasMaximizedView()) { + this.exitMaximizedView(); + } + + const [, parent] = this.getNode(parentLocation); + + if (!(parent instanceof BranchNode)) { + throw new Error('Invalid location'); + } + + parent.moveChild(from, to); + + this.trySet2x2(); + } + + /** + * Swap two {@link IView views} within the {@link GridView}. + * + * @param from The {@link GridLocation location} of one view. + * @param to The {@link GridLocation location} of another view. + */ + swapViews(from: GridLocation, to: GridLocation): void { + if (this.hasMaximizedView()) { + this.exitMaximizedView(); + } + + const [fromRest, fromIndex] = tail(from); + const [, fromParent] = this.getNode(fromRest); + + if (!(fromParent instanceof BranchNode)) { + throw new Error('Invalid from location'); + } + + const fromSize = fromParent.getChildSize(fromIndex); + const fromNode = fromParent.children[fromIndex]; + + if (!(fromNode instanceof LeafNode)) { + throw new Error('Invalid from location'); + } + + const [toRest, toIndex] = tail(to); + const [, toParent] = this.getNode(toRest); + + if (!(toParent instanceof BranchNode)) { + throw new Error('Invalid to location'); + } + + const toSize = toParent.getChildSize(toIndex); + const toNode = toParent.children[toIndex]; + + if (!(toNode instanceof LeafNode)) { + throw new Error('Invalid to location'); + } + + if (fromParent === toParent) { + fromParent.swapChildren(fromIndex, toIndex); + } else { + fromParent.removeChild(fromIndex); + toParent.removeChild(toIndex); + + fromParent.addChild(toNode, fromSize, fromIndex); + toParent.addChild(fromNode, toSize, toIndex); + } + + this.trySet2x2(); + } + + /** + * Resize a {@link IView view}. + * + * @param location The {@link GridLocation location} of the view. + * @param size The size the view should be. Optionally provide a single dimension. + */ + resizeView(location: GridLocation, size: Partial): void { + if (this.hasMaximizedView()) { + this.exitMaximizedView(); + } + + const [rest, index] = tail(location); + const [pathToParent, parent] = this.getNode(rest); + + if (!(parent instanceof BranchNode)) { + throw new Error('Invalid location'); + } + + if (!size.width && !size.height) { + return; + } + + const [parentSize, grandParentSize] = parent.orientation === Orientation.HORIZONTAL ? [size.width, size.height] : [size.height, size.width]; + + if (typeof grandParentSize === 'number' && pathToParent.length > 0) { + const [, grandParent] = tail(pathToParent); + const [, parentIndex] = tail(rest); + + grandParent.resizeChild(parentIndex, grandParentSize); + } + + if (typeof parentSize === 'number') { + parent.resizeChild(index, parentSize); + } + + this.trySet2x2(); + } + + /** + * Get the size of a {@link IView view}. + * + * @param location The {@link GridLocation location} of the view. Provide `undefined` to get + * the size of the grid itself. + */ + getViewSize(location?: GridLocation): IViewSize { + if (!location) { + return { width: this.root.width, height: this.root.height }; + } + + const [, node] = this.getNode(location); + return { width: node.width, height: node.height }; + } + + /** + * Get the cached visible size of a {@link IView view}. This was the size + * of the view at the moment it last became hidden. + * + * @param location The {@link GridLocation location} of the view. + */ + getViewCachedVisibleSize(location: GridLocation): number | undefined { + const [rest, index] = tail(location); + const [, parent] = this.getNode(rest); + + if (!(parent instanceof BranchNode)) { + throw new Error('Invalid location'); + } + + return parent.getChildCachedVisibleSize(index); + } + + /** + * Maximize the size of a {@link IView view} by collapsing all other views + * to their minimum sizes. + * + * @param location The {@link GridLocation location} of the view. + */ + expandView(location: GridLocation): void { + if (this.hasMaximizedView()) { + this.exitMaximizedView(); + } + + const [ancestors, node] = this.getNode(location); + + if (!(node instanceof LeafNode)) { + throw new Error('Invalid location'); + } + + for (let i = 0; i < ancestors.length; i++) { + ancestors[i].resizeChild(location[i], Number.POSITIVE_INFINITY); + } + } + + /** + * Returns whether all other {@link IView views} are at their minimum size. + * + * @param location The {@link GridLocation location} of the view. + */ + isViewExpanded(location: GridLocation): boolean { + if (this.hasMaximizedView()) { + // No view can be expanded when a view is maximized + return false; + } + + const [ancestors, node] = this.getNode(location); + + if (!(node instanceof LeafNode)) { + throw new Error('Invalid location'); + } + + for (let i = 0; i < ancestors.length; i++) { + if (!ancestors[i].isChildExpanded(location[i])) { + return false; + } + } + + return true; + } + + maximizeView(location: GridLocation) { + const [, nodeToMaximize] = this.getNode(location); + if (!(nodeToMaximize instanceof LeafNode)) { + throw new Error('Location is not a LeafNode'); + } + + if (this.maximizedNode === nodeToMaximize) { + return; + } + + if (this.hasMaximizedView()) { + this.exitMaximizedView(); + } + + function hideAllViewsBut(parent: BranchNode, exclude: LeafNode): void { + for (let i = 0; i < parent.children.length; i++) { + const child = parent.children[i]; + if (child instanceof LeafNode) { + if (child !== exclude) { + parent.setChildVisible(i, false); + } + } else { + hideAllViewsBut(child, exclude); + } + } + } + + hideAllViewsBut(this.root, nodeToMaximize); + + this.maximizedNode = nodeToMaximize; + this._onDidChangeViewMaximized.fire(true); + } + + exitMaximizedView(): void { + if (!this.maximizedNode) { + return; + } + this.maximizedNode = undefined; + + // When hiding a view, it's previous size is cached. + // To restore the sizes of all views, they need to be made visible in reverse order. + function showViewsInReverseOrder(parent: BranchNode): void { + for (let index = parent.children.length - 1; index >= 0; index--) { + const child = parent.children[index]; + if (child instanceof LeafNode) { + parent.setChildVisible(index, true); + } else { + showViewsInReverseOrder(child); + } + } + } + + showViewsInReverseOrder(this.root); + + this._onDidChangeViewMaximized.fire(false); + } + + hasMaximizedView(): boolean { + return this.maximizedNode !== undefined; + } + + /** + * Returns whether the {@link IView view} is maximized. + * + * @param location The {@link GridLocation location} of the view. + */ + isViewMaximized(location: GridLocation): boolean { + const [, node] = this.getNode(location); + if (!(node instanceof LeafNode)) { + throw new Error('Location is not a LeafNode'); + } + return node === this.maximizedNode; + } + + /** + * Distribute the size among all {@link IView views} within the entire + * grid or within a single {@link SplitView}. + * + * @param location The {@link GridLocation location} of a view containing + * children views, which will have their sizes distributed within the parent + * view's size. Provide `undefined` to recursively distribute all views' sizes + * in the entire grid. + */ + distributeViewSizes(location?: GridLocation): void { + if (this.hasMaximizedView()) { + this.exitMaximizedView(); + } + + if (!location) { + this.root.distributeViewSizes(true); + return; + } + + const [, node] = this.getNode(location); + + if (!(node instanceof BranchNode)) { + throw new Error('Invalid location'); + } + + node.distributeViewSizes(); + this.trySet2x2(); + } + + /** + * Returns whether a {@link IView view} is visible. + * + * @param location The {@link GridLocation location} of the view. + */ + isViewVisible(location: GridLocation): boolean { + const [rest, index] = tail(location); + const [, parent] = this.getNode(rest); + + if (!(parent instanceof BranchNode)) { + throw new Error('Invalid from location'); + } + + return parent.isChildVisible(index); + } + + /** + * Set the visibility state of a {@link IView view}. + * + * @param location The {@link GridLocation location} of the view. + */ + setViewVisible(location: GridLocation, visible: boolean): void { + if (this.hasMaximizedView()) { + this.exitMaximizedView(); + return; + } + + const [rest, index] = tail(location); + const [, parent] = this.getNode(rest); + + if (!(parent instanceof BranchNode)) { + throw new Error('Invalid from location'); + } + + parent.setChildVisible(index, visible); + } + + /** + * Returns a descriptor for the entire grid. + */ + getView(): GridBranchNode; + + /** + * Returns a descriptor for a {@link GridLocation subtree} within the + * {@link GridView}. + * + * @param location The {@link GridLocation location} of the root of + * the {@link GridLocation subtree}. + */ + getView(location: GridLocation): GridNode; + getView(location?: GridLocation): GridNode { + const node = location ? this.getNode(location)[1] : this._root; + return this._getViews(node, this.orientation); + } + + /** + * Construct a new {@link GridView} from a JSON object. + * + * @param json The JSON object. + * @param deserializer A deserializer which can revive each view. + * @returns A new {@link GridView} instance. + */ + static deserialize(json: ISerializedGridView, deserializer: IViewDeserializer, options: IGridViewOptions = {}): GridView { + if (typeof json.orientation !== 'number') { + throw new Error('Invalid JSON: \'orientation\' property must be a number.'); + } else if (typeof json.width !== 'number') { + throw new Error('Invalid JSON: \'width\' property must be a number.'); + } else if (typeof json.height !== 'number') { + throw new Error('Invalid JSON: \'height\' property must be a number.'); + } else if (json.root?.type !== 'branch') { + throw new Error('Invalid JSON: \'root\' property must have \'type\' value of branch.'); + } + + const orientation = json.orientation; + const height = json.height; + + const result = new GridView(options); + result._deserialize(json.root as ISerializedBranchNode, orientation, deserializer, height); + + return result; + } + + private _deserialize(root: ISerializedBranchNode, orientation: Orientation, deserializer: IViewDeserializer, orthogonalSize: number): void { + this.root = this._deserializeNode(root, orientation, deserializer, orthogonalSize) as BranchNode; + } + + private _deserializeNode(node: ISerializedNode, orientation: Orientation, deserializer: IViewDeserializer, orthogonalSize: number): Node { + let result: Node; + if (node.type === 'branch') { + const serializedChildren = node.data as ISerializedNode[]; + const children = serializedChildren.map(serializedChild => { + return { + node: this._deserializeNode(serializedChild, orthogonal(orientation), deserializer, node.size), + visible: (serializedChild as { visible?: boolean }).visible + } satisfies INodeDescriptor; + }); + + result = new BranchNode(orientation, this.layoutController, this.styles, this.proportionalLayout, node.size, orthogonalSize, undefined, children); + } else { + result = new LeafNode(deserializer.fromJSON(node.data), orientation, this.layoutController, orthogonalSize, node.size); + if (node.maximized && !this.maximizedNode) { + this.maximizedNode = result; + this._onDidChangeViewMaximized.fire(true); + } + } + + return result; + } + + private _getViews(node: Node, orientation: Orientation, cachedVisibleSize?: number): GridNode { + const box = { top: node.top, left: node.left, width: node.width, height: node.height }; + + if (node instanceof LeafNode) { + return { view: node.view, box, cachedVisibleSize, maximized: this.maximizedNode === node }; + } + + const children: GridNode[] = []; + + for (let i = 0; i < node.children.length; i++) { + const child = node.children[i]; + const cachedVisibleSize = node.getChildCachedVisibleSize(i); + + children.push(this._getViews(child, orthogonal(orientation), cachedVisibleSize)); + } + + return { children, box }; + } + + private getNode(location: GridLocation, node: Node = this.root, path: BranchNode[] = []): [BranchNode[], Node] { + if (location.length === 0) { + return [path, node]; + } + + if (!(node instanceof BranchNode)) { + throw new Error('Invalid location'); + } + + const [index, ...rest] = location; + + if (index < 0 || index >= node.children.length) { + throw new Error('Invalid location'); + } + + const child = node.children[index]; + path.push(node); + + return this.getNode(rest, child, path); + } + + /** + * Attempt to lock the {@link Sash sashes} in this {@link GridView} so + * the grid behaves as a 2x2 matrix, with a corner sash in the middle. + * + * In case the grid isn't a 2x2 grid _and_ all sashes are not aligned, + * this method is a no-op. + */ + trySet2x2(): void { + this.disposable2x2.dispose(); + this.disposable2x2 = Disposable.None; + + if (this.root.children.length !== 2) { + return; + } + + const [first, second] = this.root.children; + + if (!(first instanceof BranchNode) || !(second instanceof BranchNode)) { + return; + } + + this.disposable2x2 = first.trySet2x2(second); + } + + /** + * Populate a map with views to DOM nodes. + * @remarks To be used internally only. + */ + getViewMap(map: Map, node?: Node): void { + if (!node) { + node = this.root; + } + + if (node instanceof BranchNode) { + node.children.forEach(child => this.getViewMap(map, child)); + } else { + map.set(node.view, node.element); + } + } + + dispose(): void { + this.onDidSashResetRelay.dispose(); + this.root.dispose(); + this.element.remove(); + } +} diff --git a/src/vs/base/browser/ui/mouseCursor/mouseCursor.css b/src/vs/base/browser/ui/mouseCursor/mouseCursor.css new file mode 100644 index 0000000000..1d7ede8417 --- /dev/null +++ b/src/vs/base/browser/ui/mouseCursor/mouseCursor.css @@ -0,0 +1,8 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.monaco-mouse-cursor-text { + cursor: text; +} diff --git a/src/vs/base/browser/ui/mouseCursor/mouseCursor.ts b/src/vs/base/browser/ui/mouseCursor/mouseCursor.ts new file mode 100644 index 0000000000..f845a4db87 --- /dev/null +++ b/src/vs/base/browser/ui/mouseCursor/mouseCursor.ts @@ -0,0 +1,8 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// import 'vs/css!./mouseCursor'; + +export const MOUSE_CURSOR_TEXT_CSS_CLASS_NAME = `monaco-mouse-cursor-text`; diff --git a/src/vs/base/browser/ui/progressbar/progressAccessibilitySignal.ts b/src/vs/base/browser/ui/progressbar/progressAccessibilitySignal.ts new file mode 100644 index 0000000000..19a5deba94 --- /dev/null +++ b/src/vs/base/browser/ui/progressbar/progressAccessibilitySignal.ts @@ -0,0 +1,23 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IDisposable } from 'vs/base/common/lifecycle'; + +export interface IScopedAccessibilityProgressSignalDelegate extends IDisposable { } + +const nullScopedAccessibilityProgressSignalFactory = () => ({ + msLoopTime: -1, + msDelayTime: -1, + dispose: () => { }, +}); +let progressAccessibilitySignalSchedulerFactory: (msDelayTime: number, msLoopTime?: number) => IScopedAccessibilityProgressSignalDelegate = nullScopedAccessibilityProgressSignalFactory; + +export function setProgressAcccessibilitySignalScheduler(progressAccessibilitySignalScheduler: (msDelayTime: number, msLoopTime?: number) => IScopedAccessibilityProgressSignalDelegate) { + progressAccessibilitySignalSchedulerFactory = progressAccessibilitySignalScheduler; +} + +export function getProgressAcccessibilitySignalScheduler(msDelayTime: number, msLoopTime?: number): IScopedAccessibilityProgressSignalDelegate { + return progressAccessibilitySignalSchedulerFactory(msDelayTime, msLoopTime); +} diff --git a/src/vs/base/browser/ui/progressbar/progressbar.css b/src/vs/base/browser/ui/progressbar/progressbar.css new file mode 100644 index 0000000000..dc23cd255e --- /dev/null +++ b/src/vs/base/browser/ui/progressbar/progressbar.css @@ -0,0 +1,61 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.monaco-progress-container { + width: 100%; + height: 2px; + overflow: hidden; /* keep progress bit in bounds */ +} + +.monaco-progress-container .progress-bit { + width: 2%; + height: 2px; + position: absolute; + left: 0; + display: none; +} + +.monaco-progress-container.active .progress-bit { + display: inherit; +} + +.monaco-progress-container.discrete .progress-bit { + left: 0; + transition: width 100ms linear; +} + +.monaco-progress-container.discrete.done .progress-bit { + width: 100%; +} + +.monaco-progress-container.infinite .progress-bit { + animation-name: progress; + animation-duration: 4s; + animation-iteration-count: infinite; + transform: translate3d(0px, 0px, 0px); + animation-timing-function: linear; +} + +.monaco-progress-container.infinite.infinite-long-running .progress-bit { + /* + The more smooth `linear` timing function can cause + higher GPU consumption as indicated in + https://github.com/microsoft/vscode/issues/97900 & + https://github.com/microsoft/vscode/issues/138396 + */ + animation-timing-function: steps(100); +} + +/** + * The progress bit has a width: 2% (1/50) of the parent container. The animation moves it from 0% to 100% of + * that container. Since translateX is relative to the progress bit size, we have to multiple it with + * its relative size to the parent container: + * parent width: 5000% + * bit width: 100% + * translateX should be as follow: + * 50%: 5000% * 50% - 50% (set to center) = 2450% + * 100%: 5000% * 100% - 100% (do not overflow) = 4900% + */ +@keyframes progress { from { transform: translateX(0%) scaleX(1) } 50% { transform: translateX(2500%) scaleX(3) } to { transform: translateX(4900%) scaleX(1) } } diff --git a/src/vs/base/browser/ui/progressbar/progressbar.ts b/src/vs/base/browser/ui/progressbar/progressbar.ts new file mode 100644 index 0000000000..bc717f155b --- /dev/null +++ b/src/vs/base/browser/ui/progressbar/progressbar.ts @@ -0,0 +1,224 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { hide, show } from 'vs/base/browser/dom'; +import { getProgressAcccessibilitySignalScheduler } from 'vs/base/browser/ui/progressbar/progressAccessibilitySignal'; +import { RunOnceScheduler } from 'vs/base/common/async'; +import { Disposable, IDisposable, MutableDisposable } from 'vs/base/common/lifecycle'; +import { isNumber } from 'vs/base/common/types'; +// import 'vs/css!./progressbar'; + +const CSS_DONE = 'done'; +const CSS_ACTIVE = 'active'; +const CSS_INFINITE = 'infinite'; +const CSS_INFINITE_LONG_RUNNING = 'infinite-long-running'; +const CSS_DISCRETE = 'discrete'; + +export interface IProgressBarOptions extends IProgressBarStyles { +} + +export interface IProgressBarStyles { + progressBarBackground: string | undefined; +} + +export const unthemedProgressBarOptions: IProgressBarOptions = { + progressBarBackground: undefined +}; + +/** + * A progress bar with support for infinite or discrete progress. + */ +export class ProgressBar extends Disposable { + + /** + * After a certain time of showing the progress bar, switch + * to long-running mode and throttle animations to reduce + * the pressure on the GPU process. + * + * https://github.com/microsoft/vscode/issues/97900 + * https://github.com/microsoft/vscode/issues/138396 + */ + private static readonly LONG_RUNNING_INFINITE_THRESHOLD = 10000; + + private static readonly PROGRESS_SIGNAL_DEFAULT_DELAY = 3000; + + private workedVal: number; + private element!: HTMLElement; + private bit!: HTMLElement; + private totalWork: number | undefined; + private showDelayedScheduler: RunOnceScheduler; + private longRunningScheduler: RunOnceScheduler; + private readonly progressSignal = this._register(new MutableDisposable()); + + constructor(container: HTMLElement, options?: IProgressBarOptions) { + super(); + + this.workedVal = 0; + + this.showDelayedScheduler = this._register(new RunOnceScheduler(() => show(this.element), 0)); + this.longRunningScheduler = this._register(new RunOnceScheduler(() => this.infiniteLongRunning(), ProgressBar.LONG_RUNNING_INFINITE_THRESHOLD)); + + this.create(container, options); + } + + private create(container: HTMLElement, options?: IProgressBarOptions): void { + this.element = document.createElement('div'); + this.element.classList.add('monaco-progress-container'); + this.element.setAttribute('role', 'progressbar'); + this.element.setAttribute('aria-valuemin', '0'); + container.appendChild(this.element); + + this.bit = document.createElement('div'); + this.bit.classList.add('progress-bit'); + this.bit.style.backgroundColor = options?.progressBarBackground || '#0E70C0'; + this.element.appendChild(this.bit); + } + + private off(): void { + this.bit.style.width = 'inherit'; + this.bit.style.opacity = '1'; + this.element.classList.remove(CSS_ACTIVE, CSS_INFINITE, CSS_INFINITE_LONG_RUNNING, CSS_DISCRETE); + + this.workedVal = 0; + this.totalWork = undefined; + + this.longRunningScheduler.cancel(); + this.progressSignal.clear(); + } + + /** + * Indicates to the progress bar that all work is done. + */ + done(): ProgressBar { + return this.doDone(true); + } + + /** + * Stops the progressbar from showing any progress instantly without fading out. + */ + stop(): ProgressBar { + return this.doDone(false); + } + + private doDone(delayed: boolean): ProgressBar { + this.element.classList.add(CSS_DONE); + + // discrete: let it grow to 100% width and hide afterwards + if (!this.element.classList.contains(CSS_INFINITE)) { + this.bit.style.width = 'inherit'; + + if (delayed) { + setTimeout(() => this.off(), 200); + } else { + this.off(); + } + } + + // infinite: let it fade out and hide afterwards + else { + this.bit.style.opacity = '0'; + if (delayed) { + setTimeout(() => this.off(), 200); + } else { + this.off(); + } + } + + return this; + } + + /** + * Use this mode to indicate progress that has no total number of work units. + */ + infinite(): ProgressBar { + this.bit.style.width = '2%'; + this.bit.style.opacity = '1'; + + this.element.classList.remove(CSS_DISCRETE, CSS_DONE, CSS_INFINITE_LONG_RUNNING); + this.element.classList.add(CSS_ACTIVE, CSS_INFINITE); + + this.longRunningScheduler.schedule(); + + return this; + } + + private infiniteLongRunning(): void { + this.element.classList.add(CSS_INFINITE_LONG_RUNNING); + } + + /** + * Tells the progress bar the total number of work. Use in combination with workedVal() to let + * the progress bar show the actual progress based on the work that is done. + */ + total(value: number): ProgressBar { + this.workedVal = 0; + this.totalWork = value; + this.element.setAttribute('aria-valuemax', value.toString()); + + return this; + } + + /** + * Finds out if this progress bar is configured with total work + */ + hasTotal(): boolean { + return isNumber(this.totalWork); + } + + /** + * Tells the progress bar that an increment of work has been completed. + */ + worked(value: number): ProgressBar { + value = Math.max(1, Number(value)); + + return this.doSetWorked(this.workedVal + value); + } + + /** + * Tells the progress bar the total amount of work that has been completed. + */ + setWorked(value: number): ProgressBar { + value = Math.max(1, Number(value)); + + return this.doSetWorked(value); + } + + private doSetWorked(value: number): ProgressBar { + const totalWork = this.totalWork || 100; + + this.workedVal = value; + this.workedVal = Math.min(totalWork, this.workedVal); + + this.element.classList.remove(CSS_INFINITE, CSS_INFINITE_LONG_RUNNING, CSS_DONE); + this.element.classList.add(CSS_ACTIVE, CSS_DISCRETE); + this.element.setAttribute('aria-valuenow', value.toString()); + + this.bit.style.width = 100 * (this.workedVal / (totalWork)) + '%'; + + return this; + } + + getContainer(): HTMLElement { + return this.element; + } + + show(delay?: number): void { + this.showDelayedScheduler.cancel(); + this.progressSignal.value = getProgressAcccessibilitySignalScheduler(ProgressBar.PROGRESS_SIGNAL_DEFAULT_DELAY); + + if (typeof delay === 'number') { + this.showDelayedScheduler.schedule(delay); + } else { + show(this.element); + } + } + + hide(): void { + hide(this.element); + + this.showDelayedScheduler.cancel(); + this.progressSignal.clear(); + } +} diff --git a/src/vs/base/browser/ui/resizable/resizable.ts b/src/vs/base/browser/ui/resizable/resizable.ts new file mode 100644 index 0000000000..95dfb06b8d --- /dev/null +++ b/src/vs/base/browser/ui/resizable/resizable.ts @@ -0,0 +1,190 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Dimension } from 'vs/base/browser/dom'; +import { Orientation, OrthogonalEdge, Sash, SashState } from 'vs/base/browser/ui/sash/sash'; +import { Emitter, Event } from 'vs/base/common/event'; +import { DisposableStore } from 'vs/base/common/lifecycle'; + + +export interface IResizeEvent { + dimension: Dimension; + done: boolean; + north?: boolean; + east?: boolean; + south?: boolean; + west?: boolean; +} + +export class ResizableHTMLElement { + + readonly domNode: HTMLElement; + + private readonly _onDidWillResize = new Emitter(); + readonly onDidWillResize: Event = this._onDidWillResize.event; + + private readonly _onDidResize = new Emitter(); + readonly onDidResize: Event = this._onDidResize.event; + + private readonly _northSash: Sash; + private readonly _eastSash: Sash; + private readonly _southSash: Sash; + private readonly _westSash: Sash; + private readonly _sashListener = new DisposableStore(); + + private _size = new Dimension(0, 0); + private _minSize = new Dimension(0, 0); + private _maxSize = new Dimension(Number.MAX_SAFE_INTEGER, Number.MAX_SAFE_INTEGER); + private _preferredSize?: Dimension; + + constructor() { + this.domNode = document.createElement('div'); + this._eastSash = new Sash(this.domNode, { getVerticalSashLeft: () => this._size.width }, { orientation: Orientation.VERTICAL }); + this._westSash = new Sash(this.domNode, { getVerticalSashLeft: () => 0 }, { orientation: Orientation.VERTICAL }); + this._northSash = new Sash(this.domNode, { getHorizontalSashTop: () => 0 }, { orientation: Orientation.HORIZONTAL, orthogonalEdge: OrthogonalEdge.North }); + this._southSash = new Sash(this.domNode, { getHorizontalSashTop: () => this._size.height }, { orientation: Orientation.HORIZONTAL, orthogonalEdge: OrthogonalEdge.South }); + + this._northSash.orthogonalStartSash = this._westSash; + this._northSash.orthogonalEndSash = this._eastSash; + this._southSash.orthogonalStartSash = this._westSash; + this._southSash.orthogonalEndSash = this._eastSash; + + let currentSize: Dimension | undefined; + let deltaY = 0; + let deltaX = 0; + + this._sashListener.add(Event.any(this._northSash.onDidStart, this._eastSash.onDidStart, this._southSash.onDidStart, this._westSash.onDidStart)(() => { + if (currentSize === undefined) { + this._onDidWillResize.fire(); + currentSize = this._size; + deltaY = 0; + deltaX = 0; + } + })); + this._sashListener.add(Event.any(this._northSash.onDidEnd, this._eastSash.onDidEnd, this._southSash.onDidEnd, this._westSash.onDidEnd)(() => { + if (currentSize !== undefined) { + currentSize = undefined; + deltaY = 0; + deltaX = 0; + this._onDidResize.fire({ dimension: this._size, done: true }); + } + })); + + this._sashListener.add(this._eastSash.onDidChange(e => { + if (currentSize) { + deltaX = e.currentX - e.startX; + this.layout(currentSize.height + deltaY, currentSize.width + deltaX); + this._onDidResize.fire({ dimension: this._size, done: false, east: true }); + } + })); + this._sashListener.add(this._westSash.onDidChange(e => { + if (currentSize) { + deltaX = -(e.currentX - e.startX); + this.layout(currentSize.height + deltaY, currentSize.width + deltaX); + this._onDidResize.fire({ dimension: this._size, done: false, west: true }); + } + })); + this._sashListener.add(this._northSash.onDidChange(e => { + if (currentSize) { + deltaY = -(e.currentY - e.startY); + this.layout(currentSize.height + deltaY, currentSize.width + deltaX); + this._onDidResize.fire({ dimension: this._size, done: false, north: true }); + } + })); + this._sashListener.add(this._southSash.onDidChange(e => { + if (currentSize) { + deltaY = e.currentY - e.startY; + this.layout(currentSize.height + deltaY, currentSize.width + deltaX); + this._onDidResize.fire({ dimension: this._size, done: false, south: true }); + } + })); + + this._sashListener.add(Event.any(this._eastSash.onDidReset, this._westSash.onDidReset)(e => { + if (this._preferredSize) { + this.layout(this._size.height, this._preferredSize.width); + this._onDidResize.fire({ dimension: this._size, done: true }); + } + })); + this._sashListener.add(Event.any(this._northSash.onDidReset, this._southSash.onDidReset)(e => { + if (this._preferredSize) { + this.layout(this._preferredSize.height, this._size.width); + this._onDidResize.fire({ dimension: this._size, done: true }); + } + })); + } + + dispose(): void { + this._northSash.dispose(); + this._southSash.dispose(); + this._eastSash.dispose(); + this._westSash.dispose(); + this._sashListener.dispose(); + this._onDidResize.dispose(); + this._onDidWillResize.dispose(); + this.domNode.remove(); + } + + enableSashes(north: boolean, east: boolean, south: boolean, west: boolean): void { + this._northSash.state = north ? SashState.Enabled : SashState.Disabled; + this._eastSash.state = east ? SashState.Enabled : SashState.Disabled; + this._southSash.state = south ? SashState.Enabled : SashState.Disabled; + this._westSash.state = west ? SashState.Enabled : SashState.Disabled; + } + + layout(height: number = this.size.height, width: number = this.size.width): void { + + const { height: minHeight, width: minWidth } = this._minSize; + const { height: maxHeight, width: maxWidth } = this._maxSize; + + height = Math.max(minHeight, Math.min(maxHeight, height)); + width = Math.max(minWidth, Math.min(maxWidth, width)); + + const newSize = new Dimension(width, height); + if (!Dimension.equals(newSize, this._size)) { + this.domNode.style.height = height + 'px'; + this.domNode.style.width = width + 'px'; + this._size = newSize; + this._northSash.layout(); + this._eastSash.layout(); + this._southSash.layout(); + this._westSash.layout(); + } + } + + clearSashHoverState(): void { + this._eastSash.clearSashHoverState(); + this._westSash.clearSashHoverState(); + this._northSash.clearSashHoverState(); + this._southSash.clearSashHoverState(); + } + + get size() { + return this._size; + } + + set maxSize(value: Dimension) { + this._maxSize = value; + } + + get maxSize() { + return this._maxSize; + } + + set minSize(value: Dimension) { + this._minSize = value; + } + + get minSize() { + return this._minSize; + } + + set preferredSize(value: Dimension | undefined) { + this._preferredSize = value; + } + + get preferredSize() { + return this._preferredSize; + } +} diff --git a/src/vs/base/browser/ui/sash/sash.css b/src/vs/base/browser/ui/sash/sash.css new file mode 100644 index 0000000000..42b0f42578 --- /dev/null +++ b/src/vs/base/browser/ui/sash/sash.css @@ -0,0 +1,149 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +:root { + --vscode-sash-size: 4px; + --vscode-sash-hover-size: 4px; +} + +.monaco-sash { + position: absolute; + z-index: 35; + touch-action: none; +} + +.monaco-sash.disabled { + pointer-events: none; +} + +.monaco-sash.mac.vertical { + cursor: col-resize; +} + +.monaco-sash.vertical.minimum { + cursor: e-resize; +} + +.monaco-sash.vertical.maximum { + cursor: w-resize; +} + +.monaco-sash.mac.horizontal { + cursor: row-resize; +} + +.monaco-sash.horizontal.minimum { + cursor: s-resize; +} + +.monaco-sash.horizontal.maximum { + cursor: n-resize; +} + +.monaco-sash.disabled { + cursor: default !important; + pointer-events: none !important; +} + +.monaco-sash.vertical { + cursor: ew-resize; + top: 0; + width: var(--vscode-sash-size); + height: 100%; +} + +.monaco-sash.horizontal { + cursor: ns-resize; + left: 0; + width: 100%; + height: var(--vscode-sash-size); +} + +.monaco-sash:not(.disabled) > .orthogonal-drag-handle { + content: " "; + height: calc(var(--vscode-sash-size) * 2); + width: calc(var(--vscode-sash-size) * 2); + z-index: 100; + display: block; + cursor: all-scroll; + position: absolute; +} + +.monaco-sash.horizontal.orthogonal-edge-north:not(.disabled) + > .orthogonal-drag-handle.start, +.monaco-sash.horizontal.orthogonal-edge-south:not(.disabled) + > .orthogonal-drag-handle.end { + cursor: nwse-resize; +} + +.monaco-sash.horizontal.orthogonal-edge-north:not(.disabled) + > .orthogonal-drag-handle.end, +.monaco-sash.horizontal.orthogonal-edge-south:not(.disabled) + > .orthogonal-drag-handle.start { + cursor: nesw-resize; +} + +.monaco-sash.vertical > .orthogonal-drag-handle.start { + left: calc(var(--vscode-sash-size) * -0.5); + top: calc(var(--vscode-sash-size) * -1); +} +.monaco-sash.vertical > .orthogonal-drag-handle.end { + left: calc(var(--vscode-sash-size) * -0.5); + bottom: calc(var(--vscode-sash-size) * -1); +} +.monaco-sash.horizontal > .orthogonal-drag-handle.start { + top: calc(var(--vscode-sash-size) * -0.5); + left: calc(var(--vscode-sash-size) * -1); +} +.monaco-sash.horizontal > .orthogonal-drag-handle.end { + top: calc(var(--vscode-sash-size) * -0.5); + right: calc(var(--vscode-sash-size) * -1); +} + +.monaco-sash:before { + content: ''; + pointer-events: none; + position: absolute; + width: 100%; + height: 100%; + background: transparent; +} + +.monaco-workbench:not(.reduce-motion) .monaco-sash:before { + transition: background-color 0.1s ease-out; +} + +.monaco-sash.hover:before, +.monaco-sash.active:before { + background: var(--vscode-sash-hoverBorder); +} + +.monaco-sash.vertical:before { + width: var(--vscode-sash-hover-size); + left: calc(50% - (var(--vscode-sash-hover-size) / 2)); +} + +.monaco-sash.horizontal:before { + height: var(--vscode-sash-hover-size); + top: calc(50% - (var(--vscode-sash-hover-size) / 2)); +} + +.pointer-events-disabled { + pointer-events: none !important; +} + +/** Debug **/ + +.monaco-sash.debug { + background: cyan; +} + +.monaco-sash.debug.disabled { + background: rgba(0, 255, 255, 0.2); +} + +.monaco-sash.debug:not(.disabled) > .orthogonal-drag-handle { + background: red; +} diff --git a/src/vs/base/browser/ui/sash/sash.ts b/src/vs/base/browser/ui/sash/sash.ts new file mode 100644 index 0000000000..f469510905 --- /dev/null +++ b/src/vs/base/browser/ui/sash/sash.ts @@ -0,0 +1,688 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { $, append, createStyleSheet, EventHelper, EventLike, getWindow, isHTMLElement } from 'vs/base/browser/dom'; +import { DomEmitter } from 'vs/base/browser/event'; +import { EventType, Gesture } from 'vs/base/browser/touch'; +import { Delayer } from 'vs/base/common/async'; +import { memoize } from 'vs/base/common/decorators'; +import { Emitter, Event } from 'vs/base/common/event'; +import { Disposable, DisposableStore, toDisposable } from 'vs/base/common/lifecycle'; +import { isMacintosh } from 'vs/base/common/platform'; +// import 'vs/css!./sash'; + +/** + * Allow the sashes to be visible at runtime. + * @remark Use for development purposes only. + */ +const DEBUG = false; +// DEBUG = Boolean("true"); // done "weirdly" so that a lint warning prevents you from pushing this + +/** + * A vertical sash layout provider provides position and height for a sash. + */ +export interface IVerticalSashLayoutProvider { + getVerticalSashLeft(sash: Sash): number; + getVerticalSashTop?(sash: Sash): number; + getVerticalSashHeight?(sash: Sash): number; +} + +/** + * A vertical sash layout provider provides position and width for a sash. + */ +export interface IHorizontalSashLayoutProvider { + getHorizontalSashTop(sash: Sash): number; + getHorizontalSashLeft?(sash: Sash): number; + getHorizontalSashWidth?(sash: Sash): number; +} + +type ISashLayoutProvider = IVerticalSashLayoutProvider | IHorizontalSashLayoutProvider; + +export interface ISashEvent { + readonly startX: number; + readonly currentX: number; + readonly startY: number; + readonly currentY: number; + readonly altKey: boolean; +} + +export enum OrthogonalEdge { + North = 'north', + South = 'south', + East = 'east', + West = 'west' +} + +export interface IBoundarySashes { + readonly top?: Sash; + readonly right?: Sash; + readonly bottom?: Sash; + readonly left?: Sash; +} + +export interface ISashOptions { + + /** + * Whether a sash is horizontal or vertical. + */ + readonly orientation: Orientation; + + /** + * The width or height of a vertical or horizontal sash, respectively. + */ + readonly size?: number; + + /** + * A reference to another sash, perpendicular to this one, which + * aligns at the start of this one. A corner sash will be created + * automatically at that location. + * + * The start of a horizontal sash is its left-most position. + * The start of a vertical sash is its top-most position. + */ + readonly orthogonalStartSash?: Sash; + + /** + * A reference to another sash, perpendicular to this one, which + * aligns at the end of this one. A corner sash will be created + * automatically at that location. + * + * The end of a horizontal sash is its right-most position. + * The end of a vertical sash is its bottom-most position. + */ + readonly orthogonalEndSash?: Sash; + + /** + * Provides a hint as to what mouse cursor to use whenever the user + * hovers over a corner sash provided by this and an orthogonal sash. + */ + readonly orthogonalEdge?: OrthogonalEdge; +} + +export interface IVerticalSashOptions extends ISashOptions { + readonly orientation: Orientation.VERTICAL; +} + +export interface IHorizontalSashOptions extends ISashOptions { + readonly orientation: Orientation.HORIZONTAL; +} + +export const enum Orientation { + VERTICAL, + HORIZONTAL +} + +export const enum SashState { + + /** + * Disable any UI interaction. + */ + Disabled, + + /** + * Allow dragging down or to the right, depending on the sash orientation. + * + * Some OSs allow customizing the mouse cursor differently whenever + * some resizable component can't be any smaller, but can be larger. + */ + AtMinimum, + + /** + * Allow dragging up or to the left, depending on the sash orientation. + * + * Some OSs allow customizing the mouse cursor differently whenever + * some resizable component can't be any larger, but can be smaller. + */ + AtMaximum, + + /** + * Enable dragging. + */ + Enabled +} + +let globalSize = 4; +const onDidChangeGlobalSize = new Emitter(); +export function setGlobalSashSize(size: number): void { + globalSize = size; + onDidChangeGlobalSize.fire(size); +} + +let globalHoverDelay = 300; +const onDidChangeHoverDelay = new Emitter(); +export function setGlobalHoverDelay(size: number): void { + globalHoverDelay = size; + onDidChangeHoverDelay.fire(size); +} + +interface PointerEvent extends EventLike { + readonly pageX: number; + readonly pageY: number; + readonly altKey: boolean; + readonly target: EventTarget | null; + readonly initialTarget?: EventTarget | undefined; +} + +interface IPointerEventFactory { + readonly onPointerMove: Event; + readonly onPointerUp: Event; + dispose(): void; +} + +class MouseEventFactory implements IPointerEventFactory { + + private readonly disposables = new DisposableStore(); + + constructor(private el: HTMLElement) { } + + @memoize + get onPointerMove(): Event { + return this.disposables.add(new DomEmitter(getWindow(this.el), 'mousemove')).event; + } + + @memoize + get onPointerUp(): Event { + return this.disposables.add(new DomEmitter(getWindow(this.el), 'mouseup')).event; + } + + dispose(): void { + this.disposables.dispose(); + } +} + +class GestureEventFactory implements IPointerEventFactory { + + private readonly disposables = new DisposableStore(); + + @memoize + get onPointerMove(): Event { + return this.disposables.add(new DomEmitter(this.el, EventType.Change)).event; + } + + @memoize + get onPointerUp(): Event { + return this.disposables.add(new DomEmitter(this.el, EventType.End)).event; + } + + constructor(private el: HTMLElement) { } + + dispose(): void { + this.disposables.dispose(); + } +} + +class OrthogonalPointerEventFactory implements IPointerEventFactory { + + @memoize + get onPointerMove(): Event { + return this.factory.onPointerMove; + } + + @memoize + get onPointerUp(): Event { + return this.factory.onPointerUp; + } + + constructor(private factory: IPointerEventFactory) { } + + dispose(): void { + // noop + } +} + +const PointerEventsDisabledCssClass = 'pointer-events-disabled'; + +/** + * The {@link Sash} is the UI component which allows the user to resize other + * components. It's usually an invisible horizontal or vertical line which, when + * hovered, becomes highlighted and can be dragged along the perpendicular dimension + * to its direction. + * + * Features: + * - Touch event handling + * - Corner sash support + * - Hover with different mouse cursor support + * - Configurable hover size + * - Linked sash support, for 2x2 corner sashes + */ +export class Sash extends Disposable { + + private el: HTMLElement; + private layoutProvider: ISashLayoutProvider; + private orientation: Orientation; + private size: number; + private hoverDelay = globalHoverDelay; + private hoverDelayer = this._register(new Delayer(this.hoverDelay)); + + private _state: SashState = SashState.Enabled; + private readonly onDidEnablementChange = this._register(new Emitter()); + private readonly _onDidStart = this._register(new Emitter()); + private readonly _onDidChange = this._register(new Emitter()); + private readonly _onDidReset = this._register(new Emitter()); + private readonly _onDidEnd = this._register(new Emitter()); + private readonly orthogonalStartSashDisposables = this._register(new DisposableStore()); + private _orthogonalStartSash: Sash | undefined; + private readonly orthogonalStartDragHandleDisposables = this._register(new DisposableStore()); + private _orthogonalStartDragHandle: HTMLElement | undefined; + private readonly orthogonalEndSashDisposables = this._register(new DisposableStore()); + private _orthogonalEndSash: Sash | undefined; + private readonly orthogonalEndDragHandleDisposables = this._register(new DisposableStore()); + private _orthogonalEndDragHandle: HTMLElement | undefined; + + get state(): SashState { return this._state; } + get orthogonalStartSash(): Sash | undefined { return this._orthogonalStartSash; } + get orthogonalEndSash(): Sash | undefined { return this._orthogonalEndSash; } + + /** + * The state of a sash defines whether it can be interacted with by the user + * as well as what mouse cursor to use, when hovered. + */ + set state(state: SashState) { + if (this._state === state) { + return; + } + + this.el.classList.toggle('disabled', state === SashState.Disabled); + this.el.classList.toggle('minimum', state === SashState.AtMinimum); + this.el.classList.toggle('maximum', state === SashState.AtMaximum); + + this._state = state; + this.onDidEnablementChange.fire(state); + } + + /** + * An event which fires whenever the user starts dragging this sash. + */ + readonly onDidStart: Event = this._onDidStart.event; + + /** + * An event which fires whenever the user moves the mouse while + * dragging this sash. + */ + readonly onDidChange: Event = this._onDidChange.event; + + /** + * An event which fires whenever the user double clicks this sash. + */ + readonly onDidReset: Event = this._onDidReset.event; + + /** + * An event which fires whenever the user stops dragging this sash. + */ + readonly onDidEnd: Event = this._onDidEnd.event; + + /** + * A linked sash will be forwarded the same user interactions and events + * so it moves exactly the same way as this sash. + * + * Useful in 2x2 grids. Not meant for widespread usage. + */ + linkedSash: Sash | undefined = undefined; + + /** + * A reference to another sash, perpendicular to this one, which + * aligns at the start of this one. A corner sash will be created + * automatically at that location. + * + * The start of a horizontal sash is its left-most position. + * The start of a vertical sash is its top-most position. + */ + set orthogonalStartSash(sash: Sash | undefined) { + if (this._orthogonalStartSash === sash) { + return; + } + + this.orthogonalStartDragHandleDisposables.clear(); + this.orthogonalStartSashDisposables.clear(); + + if (sash) { + const onChange = (state: SashState) => { + this.orthogonalStartDragHandleDisposables.clear(); + + if (state !== SashState.Disabled) { + this._orthogonalStartDragHandle = append(this.el, $('.orthogonal-drag-handle.start')); + this.orthogonalStartDragHandleDisposables.add(toDisposable(() => this._orthogonalStartDragHandle!.remove())); + this.orthogonalStartDragHandleDisposables.add(new DomEmitter(this._orthogonalStartDragHandle, 'mouseenter')).event + (() => Sash.onMouseEnter(sash), undefined, this.orthogonalStartDragHandleDisposables); + this.orthogonalStartDragHandleDisposables.add(new DomEmitter(this._orthogonalStartDragHandle, 'mouseleave')).event + (() => Sash.onMouseLeave(sash), undefined, this.orthogonalStartDragHandleDisposables); + } + }; + + this.orthogonalStartSashDisposables.add(sash.onDidEnablementChange.event(onChange, this)); + onChange(sash.state); + } + + this._orthogonalStartSash = sash; + } + + /** + * A reference to another sash, perpendicular to this one, which + * aligns at the end of this one. A corner sash will be created + * automatically at that location. + * + * The end of a horizontal sash is its right-most position. + * The end of a vertical sash is its bottom-most position. + */ + + set orthogonalEndSash(sash: Sash | undefined) { + if (this._orthogonalEndSash === sash) { + return; + } + + this.orthogonalEndDragHandleDisposables.clear(); + this.orthogonalEndSashDisposables.clear(); + + if (sash) { + const onChange = (state: SashState) => { + this.orthogonalEndDragHandleDisposables.clear(); + + if (state !== SashState.Disabled) { + this._orthogonalEndDragHandle = append(this.el, $('.orthogonal-drag-handle.end')); + this.orthogonalEndDragHandleDisposables.add(toDisposable(() => this._orthogonalEndDragHandle!.remove())); + this.orthogonalEndDragHandleDisposables.add(new DomEmitter(this._orthogonalEndDragHandle, 'mouseenter')).event + (() => Sash.onMouseEnter(sash), undefined, this.orthogonalEndDragHandleDisposables); + this.orthogonalEndDragHandleDisposables.add(new DomEmitter(this._orthogonalEndDragHandle, 'mouseleave')).event + (() => Sash.onMouseLeave(sash), undefined, this.orthogonalEndDragHandleDisposables); + } + }; + + this.orthogonalEndSashDisposables.add(sash.onDidEnablementChange.event(onChange, this)); + onChange(sash.state); + } + + this._orthogonalEndSash = sash; + } + + /** + * Create a new vertical sash. + * + * @param container A DOM node to append the sash to. + * @param verticalLayoutProvider A vertical layout provider. + * @param options The options. + */ + constructor(container: HTMLElement, verticalLayoutProvider: IVerticalSashLayoutProvider, options: IVerticalSashOptions); + + /** + * Create a new horizontal sash. + * + * @param container A DOM node to append the sash to. + * @param horizontalLayoutProvider A horizontal layout provider. + * @param options The options. + */ + constructor(container: HTMLElement, horizontalLayoutProvider: IHorizontalSashLayoutProvider, options: IHorizontalSashOptions); + constructor(container: HTMLElement, layoutProvider: ISashLayoutProvider, options: ISashOptions) { + super(); + + this.el = append(container, $('.monaco-sash')); + + if (options.orthogonalEdge) { + this.el.classList.add(`orthogonal-edge-${options.orthogonalEdge}`); + } + + if (isMacintosh) { + this.el.classList.add('mac'); + } + + const onMouseDown = this._register(new DomEmitter(this.el, 'mousedown')).event; + this._register(onMouseDown(e => this.onPointerStart(e, new MouseEventFactory(container)), this)); + const onMouseDoubleClick = this._register(new DomEmitter(this.el, 'dblclick')).event; + this._register(onMouseDoubleClick(this.onPointerDoublePress, this)); + const onMouseEnter = this._register(new DomEmitter(this.el, 'mouseenter')).event; + this._register(onMouseEnter(() => Sash.onMouseEnter(this))); + const onMouseLeave = this._register(new DomEmitter(this.el, 'mouseleave')).event; + this._register(onMouseLeave(() => Sash.onMouseLeave(this))); + + this._register(Gesture.addTarget(this.el)); + + const onTouchStart = this._register(new DomEmitter(this.el, EventType.Start)).event; + this._register(onTouchStart(e => this.onPointerStart(e, new GestureEventFactory(this.el)), this)); + const onTap = this._register(new DomEmitter(this.el, EventType.Tap)).event; + + let doubleTapTimeout: any = undefined; + this._register(onTap(event => { + if (doubleTapTimeout) { + clearTimeout(doubleTapTimeout); + doubleTapTimeout = undefined; + this.onPointerDoublePress(event); + return; + } + + clearTimeout(doubleTapTimeout); + doubleTapTimeout = setTimeout(() => doubleTapTimeout = undefined, 250); + }, this)); + + if (typeof options.size === 'number') { + this.size = options.size; + + if (options.orientation === Orientation.VERTICAL) { + this.el.style.width = `${this.size}px`; + } else { + this.el.style.height = `${this.size}px`; + } + } else { + this.size = globalSize; + this._register(onDidChangeGlobalSize.event(size => { + this.size = size; + this.layout(); + })); + } + + this._register(onDidChangeHoverDelay.event(delay => this.hoverDelay = delay)); + + this.layoutProvider = layoutProvider; + + this.orthogonalStartSash = options.orthogonalStartSash; + this.orthogonalEndSash = options.orthogonalEndSash; + + this.orientation = options.orientation || Orientation.VERTICAL; + + if (this.orientation === Orientation.HORIZONTAL) { + this.el.classList.add('horizontal'); + this.el.classList.remove('vertical'); + } else { + this.el.classList.remove('horizontal'); + this.el.classList.add('vertical'); + } + + this.el.classList.toggle('debug', DEBUG); + + this.layout(); + } + + private onPointerStart(event: PointerEvent, pointerEventFactory: IPointerEventFactory): void { + EventHelper.stop(event); + + let isMultisashResize = false; + + if (!(event as any).__orthogonalSashEvent) { + const orthogonalSash = this.getOrthogonalSash(event); + + if (orthogonalSash) { + isMultisashResize = true; + (event as any).__orthogonalSashEvent = true; + orthogonalSash.onPointerStart(event, new OrthogonalPointerEventFactory(pointerEventFactory)); + } + } + + if (this.linkedSash && !(event as any).__linkedSashEvent) { + (event as any).__linkedSashEvent = true; + this.linkedSash.onPointerStart(event, new OrthogonalPointerEventFactory(pointerEventFactory)); + } + + if (!this.state) { + return; + } + + const iframes = this.el.ownerDocument.getElementsByTagName('iframe'); + for (const iframe of iframes) { + iframe.classList.add(PointerEventsDisabledCssClass); // disable mouse events on iframes as long as we drag the sash + } + + const startX = event.pageX; + const startY = event.pageY; + const altKey = event.altKey; + const startEvent: ISashEvent = { startX, currentX: startX, startY, currentY: startY, altKey }; + + this.el.classList.add('active'); + this._onDidStart.fire(startEvent); + + // fix https://github.com/microsoft/vscode/issues/21675 + const style = createStyleSheet(this.el); + const updateStyle = () => { + let cursor = ''; + + if (isMultisashResize) { + cursor = 'all-scroll'; + } else if (this.orientation === Orientation.HORIZONTAL) { + if (this.state === SashState.AtMinimum) { + cursor = 's-resize'; + } else if (this.state === SashState.AtMaximum) { + cursor = 'n-resize'; + } else { + cursor = isMacintosh ? 'row-resize' : 'ns-resize'; + } + } else { + if (this.state === SashState.AtMinimum) { + cursor = 'e-resize'; + } else if (this.state === SashState.AtMaximum) { + cursor = 'w-resize'; + } else { + cursor = isMacintosh ? 'col-resize' : 'ew-resize'; + } + } + + style.textContent = `* { cursor: ${cursor} !important; }`; + }; + + const disposables = new DisposableStore(); + + updateStyle(); + + if (!isMultisashResize) { + this.onDidEnablementChange.event(updateStyle, null, disposables); + } + + const onPointerMove = (e: PointerEvent) => { + EventHelper.stop(e, false); + const event: ISashEvent = { startX, currentX: e.pageX, startY, currentY: e.pageY, altKey }; + + this._onDidChange.fire(event); + }; + + const onPointerUp = (e: PointerEvent) => { + EventHelper.stop(e, false); + + style.remove(); + + this.el.classList.remove('active'); + this._onDidEnd.fire(); + + disposables.dispose(); + + for (const iframe of iframes) { + iframe.classList.remove(PointerEventsDisabledCssClass); + } + }; + + pointerEventFactory.onPointerMove(onPointerMove, null, disposables); + pointerEventFactory.onPointerUp(onPointerUp, null, disposables); + disposables.add(pointerEventFactory); + } + + private onPointerDoublePress(e: MouseEvent): void { + const orthogonalSash = this.getOrthogonalSash(e); + + if (orthogonalSash) { + orthogonalSash._onDidReset.fire(); + } + + if (this.linkedSash) { + this.linkedSash._onDidReset.fire(); + } + + this._onDidReset.fire(); + } + + private static onMouseEnter(sash: Sash, fromLinkedSash: boolean = false): void { + if (sash.el.classList.contains('active')) { + sash.hoverDelayer.cancel(); + sash.el.classList.add('hover'); + } else { + sash.hoverDelayer.trigger(() => sash.el.classList.add('hover'), sash.hoverDelay).then(undefined, () => { }); + } + + if (!fromLinkedSash && sash.linkedSash) { + Sash.onMouseEnter(sash.linkedSash, true); + } + } + + private static onMouseLeave(sash: Sash, fromLinkedSash: boolean = false): void { + sash.hoverDelayer.cancel(); + sash.el.classList.remove('hover'); + + if (!fromLinkedSash && sash.linkedSash) { + Sash.onMouseLeave(sash.linkedSash, true); + } + } + + /** + * Forcefully stop any user interactions with this sash. + * Useful when hiding a parent component, while the user is still + * interacting with the sash. + */ + clearSashHoverState(): void { + Sash.onMouseLeave(this); + } + + /** + * Layout the sash. The sash will size and position itself + * based on its provided {@link ISashLayoutProvider layout provider}. + */ + layout(): void { + if (this.orientation === Orientation.VERTICAL) { + const verticalProvider = (this.layoutProvider); + this.el.style.left = verticalProvider.getVerticalSashLeft(this) - (this.size / 2) + 'px'; + + if (verticalProvider.getVerticalSashTop) { + this.el.style.top = verticalProvider.getVerticalSashTop(this) + 'px'; + } + + if (verticalProvider.getVerticalSashHeight) { + this.el.style.height = verticalProvider.getVerticalSashHeight(this) + 'px'; + } + } else { + const horizontalProvider = (this.layoutProvider); + this.el.style.top = horizontalProvider.getHorizontalSashTop(this) - (this.size / 2) + 'px'; + + if (horizontalProvider.getHorizontalSashLeft) { + this.el.style.left = horizontalProvider.getHorizontalSashLeft(this) + 'px'; + } + + if (horizontalProvider.getHorizontalSashWidth) { + this.el.style.width = horizontalProvider.getHorizontalSashWidth(this) + 'px'; + } + } + } + + private getOrthogonalSash(e: PointerEvent): Sash | undefined { + const target = e.initialTarget ?? e.target; + + if (!target || !(isHTMLElement(target))) { + return undefined; + } + + if (target.classList.contains('orthogonal-drag-handle')) { + return target.classList.contains('start') ? this.orthogonalStartSash : this.orthogonalEndSash; + } + + return undefined; + } + + override dispose(): void { + super.dispose(); + this.el.remove(); + } +} diff --git a/src/vs/base/browser/ui/scrollbar/abstractScrollbar.ts b/src/vs/base/browser/ui/scrollbar/abstractScrollbar.ts new file mode 100644 index 0000000000..5ab75393d9 --- /dev/null +++ b/src/vs/base/browser/ui/scrollbar/abstractScrollbar.ts @@ -0,0 +1,303 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as dom from 'vs/base/browser/dom'; +import { createFastDomNode, FastDomNode } from 'vs/base/browser/fastDomNode'; +import { GlobalPointerMoveMonitor } from 'vs/base/browser/globalPointerMoveMonitor'; +import { StandardWheelEvent } from 'vs/base/browser/mouseEvent'; +import { ScrollbarArrow, ScrollbarArrowOptions } from 'vs/base/browser/ui/scrollbar/scrollbarArrow'; +import { ScrollbarState } from 'vs/base/browser/ui/scrollbar/scrollbarState'; +import { ScrollbarVisibilityController } from 'vs/base/browser/ui/scrollbar/scrollbarVisibilityController'; +import { Widget } from 'vs/base/browser/ui/widget'; +import * as platform from 'vs/base/common/platform'; +import { INewScrollPosition, Scrollable, ScrollbarVisibility } from 'vs/base/common/scrollable'; + +/** + * The orthogonal distance to the slider at which dragging "resets". This implements "snapping" + */ +const POINTER_DRAG_RESET_DISTANCE = 140; + +export interface ISimplifiedPointerEvent { + buttons: number; + pageX: number; + pageY: number; +} + +export interface ScrollbarHost { + onMouseWheel(mouseWheelEvent: StandardWheelEvent): void; + onDragStart(): void; + onDragEnd(): void; +} + +export interface AbstractScrollbarOptions { + lazyRender: boolean; + host: ScrollbarHost; + scrollbarState: ScrollbarState; + visibility: ScrollbarVisibility; + extraScrollbarClassName: string; + scrollable: Scrollable; + scrollByPage: boolean; +} + +export abstract class AbstractScrollbar extends Widget { + + protected _host: ScrollbarHost; + protected _scrollable: Scrollable; + protected _scrollByPage: boolean; + private _lazyRender: boolean; + protected _scrollbarState: ScrollbarState; + protected _visibilityController: ScrollbarVisibilityController; + private _pointerMoveMonitor: GlobalPointerMoveMonitor; + + public domNode: FastDomNode; + public slider!: FastDomNode; + + protected _shouldRender: boolean; + + constructor(opts: AbstractScrollbarOptions) { + super(); + this._lazyRender = opts.lazyRender; + this._host = opts.host; + this._scrollable = opts.scrollable; + this._scrollByPage = opts.scrollByPage; + this._scrollbarState = opts.scrollbarState; + this._visibilityController = this._register(new ScrollbarVisibilityController(opts.visibility, 'visible scrollbar ' + opts.extraScrollbarClassName, 'invisible scrollbar ' + opts.extraScrollbarClassName)); + this._visibilityController.setIsNeeded(this._scrollbarState.isNeeded()); + this._pointerMoveMonitor = this._register(new GlobalPointerMoveMonitor()); + this._shouldRender = true; + this.domNode = createFastDomNode(document.createElement('div')); + this.domNode.setAttribute('role', 'presentation'); + this.domNode.setAttribute('aria-hidden', 'true'); + + this._visibilityController.setDomNode(this.domNode); + this.domNode.setPosition('absolute'); + + this._register(dom.addDisposableListener(this.domNode.domNode, dom.EventType.POINTER_DOWN, (e: PointerEvent) => this._domNodePointerDown(e))); + } + + // ----------------- creation + + /** + * Creates the dom node for an arrow & adds it to the container + */ + protected _createArrow(opts: ScrollbarArrowOptions): void { + const arrow = this._register(new ScrollbarArrow(opts)); + this.domNode.domNode.appendChild(arrow.bgDomNode); + this.domNode.domNode.appendChild(arrow.domNode); + } + + /** + * Creates the slider dom node, adds it to the container & hooks up the events + */ + protected _createSlider(top: number, left: number, width: number | undefined, height: number | undefined): void { + this.slider = createFastDomNode(document.createElement('div')); + this.slider.setClassName('slider'); + this.slider.setPosition('absolute'); + this.slider.setTop(top); + this.slider.setLeft(left); + if (typeof width === 'number') { + this.slider.setWidth(width); + } + if (typeof height === 'number') { + this.slider.setHeight(height); + } + this.slider.setLayerHinting(true); + this.slider.setContain('strict'); + + this.domNode.domNode.appendChild(this.slider.domNode); + + this._register(dom.addDisposableListener( + this.slider.domNode, + dom.EventType.POINTER_DOWN, + (e: PointerEvent) => { + if (e.button === 0) { + e.preventDefault(); + this._sliderPointerDown(e); + } + } + )); + + this.onclick(this.slider.domNode, e => { + if (e.leftButton) { + e.stopPropagation(); + } + }); + } + + // ----------------- Update state + + protected _onElementSize(visibleSize: number): boolean { + if (this._scrollbarState.setVisibleSize(visibleSize)) { + this._visibilityController.setIsNeeded(this._scrollbarState.isNeeded()); + this._shouldRender = true; + if (!this._lazyRender) { + this.render(); + } + } + return this._shouldRender; + } + + protected _onElementScrollSize(elementScrollSize: number): boolean { + if (this._scrollbarState.setScrollSize(elementScrollSize)) { + this._visibilityController.setIsNeeded(this._scrollbarState.isNeeded()); + this._shouldRender = true; + if (!this._lazyRender) { + this.render(); + } + } + return this._shouldRender; + } + + protected _onElementScrollPosition(elementScrollPosition: number): boolean { + if (this._scrollbarState.setScrollPosition(elementScrollPosition)) { + this._visibilityController.setIsNeeded(this._scrollbarState.isNeeded()); + this._shouldRender = true; + if (!this._lazyRender) { + this.render(); + } + } + return this._shouldRender; + } + + // ----------------- rendering + + public beginReveal(): void { + this._visibilityController.setShouldBeVisible(true); + } + + public beginHide(): void { + this._visibilityController.setShouldBeVisible(false); + } + + public render(): void { + if (!this._shouldRender) { + return; + } + this._shouldRender = false; + + this._renderDomNode(this._scrollbarState.getRectangleLargeSize(), this._scrollbarState.getRectangleSmallSize()); + this._updateSlider(this._scrollbarState.getSliderSize(), this._scrollbarState.getArrowSize() + this._scrollbarState.getSliderPosition()); + } + // ----------------- DOM events + + private _domNodePointerDown(e: PointerEvent): void { + if (e.target !== this.domNode.domNode) { + return; + } + this._onPointerDown(e); + } + + public delegatePointerDown(e: PointerEvent): void { + const domTop = this.domNode.domNode.getClientRects()[0].top; + const sliderStart = domTop + this._scrollbarState.getSliderPosition(); + const sliderStop = domTop + this._scrollbarState.getSliderPosition() + this._scrollbarState.getSliderSize(); + const pointerPos = this._sliderPointerPosition(e); + if (sliderStart <= pointerPos && pointerPos <= sliderStop) { + // Act as if it was a pointer down on the slider + if (e.button === 0) { + e.preventDefault(); + this._sliderPointerDown(e); + } + } else { + // Act as if it was a pointer down on the scrollbar + this._onPointerDown(e); + } + } + + private _onPointerDown(e: PointerEvent): void { + let offsetX: number; + let offsetY: number; + if (e.target === this.domNode.domNode && typeof e.offsetX === 'number' && typeof e.offsetY === 'number') { + offsetX = e.offsetX; + offsetY = e.offsetY; + } else { + const domNodePosition = dom.getDomNodePagePosition(this.domNode.domNode); + offsetX = e.pageX - domNodePosition.left; + offsetY = e.pageY - domNodePosition.top; + } + + const offset = this._pointerDownRelativePosition(offsetX, offsetY); + this._setDesiredScrollPositionNow( + this._scrollByPage + ? this._scrollbarState.getDesiredScrollPositionFromOffsetPaged(offset) + : this._scrollbarState.getDesiredScrollPositionFromOffset(offset) + ); + + if (e.button === 0) { + // left button + e.preventDefault(); + this._sliderPointerDown(e); + } + } + + private _sliderPointerDown(e: PointerEvent): void { + if (!e.target || !(e.target instanceof Element)) { + return; + } + const initialPointerPosition = this._sliderPointerPosition(e); + const initialPointerOrthogonalPosition = this._sliderOrthogonalPointerPosition(e); + const initialScrollbarState = this._scrollbarState.clone(); + this.slider.toggleClassName('active', true); + + this._pointerMoveMonitor.startMonitoring( + e.target, + e.pointerId, + e.buttons, + (pointerMoveData: PointerEvent) => { + const pointerOrthogonalPosition = this._sliderOrthogonalPointerPosition(pointerMoveData); + const pointerOrthogonalDelta = Math.abs(pointerOrthogonalPosition - initialPointerOrthogonalPosition); + + if (platform.isWindows && pointerOrthogonalDelta > POINTER_DRAG_RESET_DISTANCE) { + // The pointer has wondered away from the scrollbar => reset dragging + this._setDesiredScrollPositionNow(initialScrollbarState.getScrollPosition()); + return; + } + + const pointerPosition = this._sliderPointerPosition(pointerMoveData); + const pointerDelta = pointerPosition - initialPointerPosition; + this._setDesiredScrollPositionNow(initialScrollbarState.getDesiredScrollPositionFromDelta(pointerDelta)); + }, + () => { + this.slider.toggleClassName('active', false); + this._host.onDragEnd(); + } + ); + + this._host.onDragStart(); + } + + private _setDesiredScrollPositionNow(_desiredScrollPosition: number): void { + + const desiredScrollPosition: INewScrollPosition = {}; + this.writeScrollPosition(desiredScrollPosition, _desiredScrollPosition); + + this._scrollable.setScrollPositionNow(desiredScrollPosition); + } + + public updateScrollbarSize(scrollbarSize: number): void { + this._updateScrollbarSize(scrollbarSize); + this._scrollbarState.setScrollbarSize(scrollbarSize); + this._shouldRender = true; + if (!this._lazyRender) { + this.render(); + } + } + + public isNeeded(): boolean { + return this._scrollbarState.isNeeded(); + } + + // ----------------- Overwrite these + + protected abstract _renderDomNode(largeSize: number, smallSize: number): void; + protected abstract _updateSlider(sliderSize: number, sliderPosition: number): void; + + protected abstract _pointerDownRelativePosition(offsetX: number, offsetY: number): number; + protected abstract _sliderPointerPosition(e: ISimplifiedPointerEvent): number; + protected abstract _sliderOrthogonalPointerPosition(e: ISimplifiedPointerEvent): number; + protected abstract _updateScrollbarSize(size: number): void; + + public abstract writeScrollPosition(target: INewScrollPosition, scrollPosition: number): void; +} diff --git a/src/vs/base/browser/ui/scrollbar/horizontalScrollbar.ts b/src/vs/base/browser/ui/scrollbar/horizontalScrollbar.ts new file mode 100644 index 0000000000..e64e4226d7 --- /dev/null +++ b/src/vs/base/browser/ui/scrollbar/horizontalScrollbar.ts @@ -0,0 +1,114 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { AbstractScrollbar, ISimplifiedPointerEvent, ScrollbarHost } from 'vs/base/browser/ui/scrollbar/abstractScrollbar'; +import { ScrollableElementResolvedOptions } from 'vs/base/browser/ui/scrollbar/scrollableElementOptions'; +import { ScrollbarState } from 'vs/base/browser/ui/scrollbar/scrollbarState'; +import { INewScrollPosition, Scrollable, ScrollbarVisibility, ScrollEvent } from 'vs/base/common/scrollable'; + + + + +export class HorizontalScrollbar extends AbstractScrollbar { + + constructor(scrollable: Scrollable, options: ScrollableElementResolvedOptions, host: ScrollbarHost) { + const scrollDimensions = scrollable.getScrollDimensions(); + const scrollPosition = scrollable.getCurrentScrollPosition(); + super({ + lazyRender: options.lazyRender, + host: host, + scrollbarState: new ScrollbarState( + (options.horizontalHasArrows ? options.arrowSize : 0), + (options.horizontal === ScrollbarVisibility.Hidden ? 0 : options.horizontalScrollbarSize), + (options.vertical === ScrollbarVisibility.Hidden ? 0 : options.verticalScrollbarSize), + scrollDimensions.width, + scrollDimensions.scrollWidth, + scrollPosition.scrollLeft + ), + visibility: options.horizontal, + extraScrollbarClassName: 'horizontal', + scrollable: scrollable, + scrollByPage: options.scrollByPage + }); + + if (options.horizontalHasArrows) { + throw new Error('horizontalHasArrows is not supported in xterm.js'); + // const arrowDelta = (options.arrowSize - ARROW_IMG_SIZE) / 2; + // const scrollbarDelta = (options.horizontalScrollbarSize - ARROW_IMG_SIZE) / 2; + + // this._createArrow({ + // className: 'scra', + // icon: Codicon.scrollbarButtonLeft, + // top: scrollbarDelta, + // left: arrowDelta, + // bottom: undefined, + // right: undefined, + // bgWidth: options.arrowSize, + // bgHeight: options.horizontalScrollbarSize, + // onActivate: () => this._host.onMouseWheel(new StandardWheelEvent(null, 1, 0)), + // }); + + // this._createArrow({ + // className: 'scra', + // icon: Codicon.scrollbarButtonRight, + // top: scrollbarDelta, + // left: undefined, + // bottom: undefined, + // right: arrowDelta, + // bgWidth: options.arrowSize, + // bgHeight: options.horizontalScrollbarSize, + // onActivate: () => this._host.onMouseWheel(new StandardWheelEvent(null, -1, 0)), + // }); + } + + this._createSlider(Math.floor((options.horizontalScrollbarSize - options.horizontalSliderSize) / 2), 0, undefined, options.horizontalSliderSize); + } + + protected _updateSlider(sliderSize: number, sliderPosition: number): void { + this.slider.setWidth(sliderSize); + this.slider.setLeft(sliderPosition); + } + + protected _renderDomNode(largeSize: number, smallSize: number): void { + this.domNode.setWidth(largeSize); + this.domNode.setHeight(smallSize); + this.domNode.setLeft(0); + this.domNode.setBottom(0); + } + + public onDidScroll(e: ScrollEvent): boolean { + this._shouldRender = this._onElementScrollSize(e.scrollWidth) || this._shouldRender; + this._shouldRender = this._onElementScrollPosition(e.scrollLeft) || this._shouldRender; + this._shouldRender = this._onElementSize(e.width) || this._shouldRender; + return this._shouldRender; + } + + protected _pointerDownRelativePosition(offsetX: number, offsetY: number): number { + return offsetX; + } + + protected _sliderPointerPosition(e: ISimplifiedPointerEvent): number { + return e.pageX; + } + + protected _sliderOrthogonalPointerPosition(e: ISimplifiedPointerEvent): number { + return e.pageY; + } + + protected _updateScrollbarSize(size: number): void { + this.slider.setHeight(size); + } + + public writeScrollPosition(target: INewScrollPosition, scrollPosition: number): void { + target.scrollLeft = scrollPosition; + } + + public updateOptions(options: ScrollableElementResolvedOptions): void { + this.updateScrollbarSize(options.horizontal === ScrollbarVisibility.Hidden ? 0 : options.horizontalScrollbarSize); + this._scrollbarState.setOppositeScrollbarSize(options.vertical === ScrollbarVisibility.Hidden ? 0 : options.verticalScrollbarSize); + this._visibilityController.setVisibility(options.horizontal); + this._scrollByPage = options.scrollByPage; + } +} diff --git a/src/vs/base/browser/ui/scrollbar/media/scrollbars.css b/src/vs/base/browser/ui/scrollbar/media/scrollbars.css new file mode 100644 index 0000000000..84c1737089 --- /dev/null +++ b/src/vs/base/browser/ui/scrollbar/media/scrollbars.css @@ -0,0 +1,72 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/* Arrows */ +.monaco-scrollable-element > .scrollbar > .scra { + cursor: pointer; + font-size: 11px !important; +} + +.monaco-scrollable-element > .visible { + opacity: 1; + + /* Background rule added for IE9 - to allow clicks on dom node */ + background:rgba(0,0,0,0); + + transition: opacity 100ms linear; + /* In front of peek view */ + z-index: 11; +} +.monaco-scrollable-element > .invisible { + opacity: 0; + pointer-events: none; +} +.monaco-scrollable-element > .invisible.fade { + transition: opacity 800ms linear; +} + +/* Scrollable Content Inset Shadow */ +.monaco-scrollable-element > .shadow { + position: absolute; + display: none; +} +.monaco-scrollable-element > .shadow.top { + display: block; + top: 0; + left: 3px; + height: 3px; + width: 100%; + box-shadow: var(--vscode-scrollbar-shadow) 0 6px 6px -6px inset; +} +.monaco-scrollable-element > .shadow.left { + display: block; + top: 3px; + left: 0; + height: 100%; + width: 3px; + box-shadow: var(--vscode-scrollbar-shadow) 6px 0 6px -6px inset; +} +.monaco-scrollable-element > .shadow.top-left-corner { + display: block; + top: 0; + left: 0; + height: 3px; + width: 3px; +} +.monaco-scrollable-element > .shadow.top.left { + box-shadow: var(--vscode-scrollbar-shadow) 6px 0 6px -6px inset; +} + +.monaco-scrollable-element > .scrollbar > .slider { + background: var(--vscode-scrollbarSlider-background); +} + +.monaco-scrollable-element > .scrollbar > .slider:hover { + background: var(--vscode-scrollbarSlider-hoverBackground); +} + +.monaco-scrollable-element > .scrollbar > .slider.active { + background: var(--vscode-scrollbarSlider-activeBackground); +} diff --git a/src/vs/base/browser/ui/scrollbar/scrollableElement.ts b/src/vs/base/browser/ui/scrollbar/scrollableElement.ts new file mode 100644 index 0000000000..14edb37102 --- /dev/null +++ b/src/vs/base/browser/ui/scrollbar/scrollableElement.ts @@ -0,0 +1,718 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { getZoomFactor, isChrome } from 'vs/base/browser/browser'; +import * as dom from 'vs/base/browser/dom'; +import { FastDomNode, createFastDomNode } from 'vs/base/browser/fastDomNode'; +import { IMouseEvent, IMouseWheelEvent, StandardWheelEvent } from 'vs/base/browser/mouseEvent'; +import { ScrollbarHost } from 'vs/base/browser/ui/scrollbar/abstractScrollbar'; +import { HorizontalScrollbar } from 'vs/base/browser/ui/scrollbar/horizontalScrollbar'; +import { ScrollableElementChangeOptions, ScrollableElementCreationOptions, ScrollableElementResolvedOptions } from 'vs/base/browser/ui/scrollbar/scrollableElementOptions'; +import { VerticalScrollbar } from 'vs/base/browser/ui/scrollbar/verticalScrollbar'; +import { Widget } from 'vs/base/browser/ui/widget'; +import { TimeoutTimer } from 'vs/base/common/async'; +import { Emitter, Event } from 'vs/base/common/event'; +import { IDisposable, dispose } from 'vs/base/common/lifecycle'; +import * as platform from 'vs/base/common/platform'; +import { INewScrollDimensions, INewScrollPosition, IScrollDimensions, IScrollPosition, ScrollEvent, Scrollable, ScrollbarVisibility } from 'vs/base/common/scrollable'; +// import 'vs/css!./media/scrollbars'; + +const HIDE_TIMEOUT = 500; +const SCROLL_WHEEL_SENSITIVITY = 50; +const SCROLL_WHEEL_SMOOTH_SCROLL_ENABLED = true; + +export interface IOverviewRulerLayoutInfo { + parent: HTMLElement; + insertBefore: HTMLElement; +} + +class MouseWheelClassifierItem { + public timestamp: number; + public deltaX: number; + public deltaY: number; + public score: number; + + constructor(timestamp: number, deltaX: number, deltaY: number) { + this.timestamp = timestamp; + this.deltaX = deltaX; + this.deltaY = deltaY; + this.score = 0; + } +} + +export class MouseWheelClassifier { + + public static readonly INSTANCE = new MouseWheelClassifier(); + + private readonly _capacity: number; + private _memory: MouseWheelClassifierItem[]; + private _front: number; + private _rear: number; + + constructor() { + this._capacity = 5; + this._memory = []; + this._front = -1; + this._rear = -1; + } + + public isPhysicalMouseWheel(): boolean { + if (this._front === -1 && this._rear === -1) { + // no elements + return false; + } + + // 0.5 * last + 0.25 * 2nd last + 0.125 * 3rd last + ... + let remainingInfluence = 1; + let score = 0; + let iteration = 1; + + let index = this._rear; + do { + const influence = (index === this._front ? remainingInfluence : Math.pow(2, -iteration)); + remainingInfluence -= influence; + score += this._memory[index].score * influence; + + if (index === this._front) { + break; + } + + index = (this._capacity + index - 1) % this._capacity; + iteration++; + } while (true); + + return (score <= 0.5); + } + + public acceptStandardWheelEvent(e: StandardWheelEvent): void { + if (isChrome) { + const targetWindow = dom.getWindow(e.browserEvent); + const pageZoomFactor = getZoomFactor(targetWindow); + // On Chrome, the incoming delta events are multiplied with the OS zoom factor. + // The OS zoom factor can be reverse engineered by using the device pixel ratio and the configured zoom factor into account. + this.accept(Date.now(), e.deltaX * pageZoomFactor, e.deltaY * pageZoomFactor); + } else { + this.accept(Date.now(), e.deltaX, e.deltaY); + } + } + + public accept(timestamp: number, deltaX: number, deltaY: number): void { + let previousItem = null; + const item = new MouseWheelClassifierItem(timestamp, deltaX, deltaY); + + if (this._front === -1 && this._rear === -1) { + this._memory[0] = item; + this._front = 0; + this._rear = 0; + } else { + previousItem = this._memory[this._rear]; + + this._rear = (this._rear + 1) % this._capacity; + if (this._rear === this._front) { + // Drop oldest + this._front = (this._front + 1) % this._capacity; + } + this._memory[this._rear] = item; + } + + item.score = this._computeScore(item, previousItem); + } + + /** + * A score between 0 and 1 for `item`. + * - a score towards 0 indicates that the source appears to be a physical mouse wheel + * - a score towards 1 indicates that the source appears to be a touchpad or magic mouse, etc. + */ + private _computeScore(item: MouseWheelClassifierItem, previousItem: MouseWheelClassifierItem | null): number { + + if (Math.abs(item.deltaX) > 0 && Math.abs(item.deltaY) > 0) { + // both axes exercised => definitely not a physical mouse wheel + return 1; + } + + let score: number = 0.5; + + if (!this._isAlmostInt(item.deltaX) || !this._isAlmostInt(item.deltaY)) { + // non-integer deltas => indicator that this is not a physical mouse wheel + score += 0.25; + } + + // Non-accelerating scroll => indicator that this is a physical mouse wheel + // These can be identified by seeing whether they are the module of one another. + if (previousItem) { + const absDeltaX = Math.abs(item.deltaX); + const absDeltaY = Math.abs(item.deltaY); + + const absPreviousDeltaX = Math.abs(previousItem.deltaX); + const absPreviousDeltaY = Math.abs(previousItem.deltaY); + + // Min 1 to avoid division by zero, module 1 will still be 0. + const minDeltaX = Math.max(Math.min(absDeltaX, absPreviousDeltaX), 1); + const minDeltaY = Math.max(Math.min(absDeltaY, absPreviousDeltaY), 1); + + const maxDeltaX = Math.max(absDeltaX, absPreviousDeltaX); + const maxDeltaY = Math.max(absDeltaY, absPreviousDeltaY); + + const isSameModulo = (maxDeltaX % minDeltaX === 0 && maxDeltaY % minDeltaY === 0); + if (isSameModulo) { + score -= 0.5; + } + } + + return Math.min(Math.max(score, 0), 1); + } + + private _isAlmostInt(value: number): boolean { + const delta = Math.abs(Math.round(value) - value); + return (delta < 0.01); + } +} + +export abstract class AbstractScrollableElement extends Widget { + + private readonly _options: ScrollableElementResolvedOptions; + protected readonly _scrollable: Scrollable; + private readonly _verticalScrollbar: VerticalScrollbar; + private readonly _horizontalScrollbar: HorizontalScrollbar; + private readonly _domNode: HTMLElement; + + private readonly _leftShadowDomNode: FastDomNode | null; + private readonly _topShadowDomNode: FastDomNode | null; + private readonly _topLeftShadowDomNode: FastDomNode | null; + + private readonly _listenOnDomNode: HTMLElement; + + private _mouseWheelToDispose: IDisposable[]; + + private _isDragging: boolean; + private _mouseIsOver: boolean; + + private readonly _hideTimeout: TimeoutTimer; + private _shouldRender: boolean; + + private _revealOnScroll: boolean; + + private readonly _onScroll = this._register(new Emitter()); + public readonly onScroll: Event = this._onScroll.event; + + private readonly _onWillScroll = this._register(new Emitter()); + public readonly onWillScroll: Event = this._onWillScroll.event; + + public get options(): Readonly { + return this._options; + } + + protected constructor(element: HTMLElement, options: ScrollableElementCreationOptions, scrollable: Scrollable) { + super(); + element.style.overflow = 'hidden'; + this._options = resolveOptions(options); + this._scrollable = scrollable; + + this._register(this._scrollable.onScroll((e) => { + this._onWillScroll.fire(e); + this._onDidScroll(e); + this._onScroll.fire(e); + })); + + const scrollbarHost: ScrollbarHost = { + onMouseWheel: (mouseWheelEvent: StandardWheelEvent) => this._onMouseWheel(mouseWheelEvent), + onDragStart: () => this._onDragStart(), + onDragEnd: () => this._onDragEnd(), + }; + this._verticalScrollbar = this._register(new VerticalScrollbar(this._scrollable, this._options, scrollbarHost)); + this._horizontalScrollbar = this._register(new HorizontalScrollbar(this._scrollable, this._options, scrollbarHost)); + + this._domNode = document.createElement('div'); + this._domNode.className = 'monaco-scrollable-element ' + this._options.className; + this._domNode.setAttribute('role', 'presentation'); + this._domNode.style.position = 'relative'; + this._domNode.style.overflow = 'hidden'; + this._domNode.appendChild(element); + this._domNode.appendChild(this._horizontalScrollbar.domNode.domNode); + this._domNode.appendChild(this._verticalScrollbar.domNode.domNode); + + if (this._options.useShadows) { + this._leftShadowDomNode = createFastDomNode(document.createElement('div')); + this._leftShadowDomNode.setClassName('shadow'); + this._domNode.appendChild(this._leftShadowDomNode.domNode); + + this._topShadowDomNode = createFastDomNode(document.createElement('div')); + this._topShadowDomNode.setClassName('shadow'); + this._domNode.appendChild(this._topShadowDomNode.domNode); + + this._topLeftShadowDomNode = createFastDomNode(document.createElement('div')); + this._topLeftShadowDomNode.setClassName('shadow'); + this._domNode.appendChild(this._topLeftShadowDomNode.domNode); + } else { + this._leftShadowDomNode = null; + this._topShadowDomNode = null; + this._topLeftShadowDomNode = null; + } + + this._listenOnDomNode = this._options.listenOnDomNode || this._domNode; + + this._mouseWheelToDispose = []; + this._setListeningToMouseWheel(this._options.handleMouseWheel); + + this.onmouseover(this._listenOnDomNode, (e) => this._onMouseOver(e)); + this.onmouseleave(this._listenOnDomNode, (e) => this._onMouseLeave(e)); + + this._hideTimeout = this._register(new TimeoutTimer()); + this._isDragging = false; + this._mouseIsOver = false; + + this._shouldRender = true; + + this._revealOnScroll = true; + } + + public override dispose(): void { + this._mouseWheelToDispose = dispose(this._mouseWheelToDispose); + super.dispose(); + } + + /** + * Get the generated 'scrollable' dom node + */ + public getDomNode(): HTMLElement { + return this._domNode; + } + + public getOverviewRulerLayoutInfo(): IOverviewRulerLayoutInfo { + return { + parent: this._domNode, + insertBefore: this._verticalScrollbar.domNode.domNode, + }; + } + + /** + * Delegate a pointer down event to the vertical scrollbar. + * This is to help with clicking somewhere else and having the scrollbar react. + */ + public delegateVerticalScrollbarPointerDown(browserEvent: PointerEvent): void { + this._verticalScrollbar.delegatePointerDown(browserEvent); + } + + public getScrollDimensions(): IScrollDimensions { + return this._scrollable.getScrollDimensions(); + } + + public setScrollDimensions(dimensions: INewScrollDimensions): void { + this._scrollable.setScrollDimensions(dimensions, false); + } + + /** + * Update the class name of the scrollable element. + */ + public updateClassName(newClassName: string): void { + this._options.className = newClassName; + // Defaults are different on Macs + if (platform.isMacintosh) { + this._options.className += ' mac'; + } + this._domNode.className = 'monaco-scrollable-element ' + this._options.className; + } + + /** + * Update configuration options for the scrollbar. + */ + public updateOptions(newOptions: ScrollableElementChangeOptions): void { + if (typeof newOptions.handleMouseWheel !== 'undefined') { + this._options.handleMouseWheel = newOptions.handleMouseWheel; + this._setListeningToMouseWheel(this._options.handleMouseWheel); + } + if (typeof newOptions.mouseWheelScrollSensitivity !== 'undefined') { + this._options.mouseWheelScrollSensitivity = newOptions.mouseWheelScrollSensitivity; + } + if (typeof newOptions.fastScrollSensitivity !== 'undefined') { + this._options.fastScrollSensitivity = newOptions.fastScrollSensitivity; + } + if (typeof newOptions.scrollPredominantAxis !== 'undefined') { + this._options.scrollPredominantAxis = newOptions.scrollPredominantAxis; + } + if (typeof newOptions.horizontal !== 'undefined') { + this._options.horizontal = newOptions.horizontal; + } + if (typeof newOptions.vertical !== 'undefined') { + this._options.vertical = newOptions.vertical; + } + if (typeof newOptions.horizontalScrollbarSize !== 'undefined') { + this._options.horizontalScrollbarSize = newOptions.horizontalScrollbarSize; + } + if (typeof newOptions.verticalScrollbarSize !== 'undefined') { + this._options.verticalScrollbarSize = newOptions.verticalScrollbarSize; + } + if (typeof newOptions.scrollByPage !== 'undefined') { + this._options.scrollByPage = newOptions.scrollByPage; + } + this._horizontalScrollbar.updateOptions(this._options); + this._verticalScrollbar.updateOptions(this._options); + + if (!this._options.lazyRender) { + this._render(); + } + } + + public setRevealOnScroll(value: boolean) { + this._revealOnScroll = value; + } + + public delegateScrollFromMouseWheelEvent(browserEvent: IMouseWheelEvent) { + this._onMouseWheel(new StandardWheelEvent(browserEvent)); + } + + // -------------------- mouse wheel scrolling -------------------- + + private _setListeningToMouseWheel(shouldListen: boolean): void { + const isListening = (this._mouseWheelToDispose.length > 0); + + if (isListening === shouldListen) { + // No change + return; + } + + // Stop listening (if necessary) + this._mouseWheelToDispose = dispose(this._mouseWheelToDispose); + + // Start listening (if necessary) + if (shouldListen) { + const onMouseWheel = (browserEvent: IMouseWheelEvent) => { + this._onMouseWheel(new StandardWheelEvent(browserEvent)); + }; + + this._mouseWheelToDispose.push(dom.addDisposableListener(this._listenOnDomNode, dom.EventType.MOUSE_WHEEL, onMouseWheel, { passive: false })); + } + } + + private _onMouseWheel(e: StandardWheelEvent): void { + if (e.browserEvent?.defaultPrevented) { + return; + } + + const classifier = MouseWheelClassifier.INSTANCE; + if (SCROLL_WHEEL_SMOOTH_SCROLL_ENABLED) { + classifier.acceptStandardWheelEvent(e); + } + + // useful for creating unit tests: + // console.log(`${Date.now()}, ${e.deltaY}, ${e.deltaX}`); + + let didScroll = false; + + if (e.deltaY || e.deltaX) { + let deltaY = e.deltaY * this._options.mouseWheelScrollSensitivity; + let deltaX = e.deltaX * this._options.mouseWheelScrollSensitivity; + + if (this._options.scrollPredominantAxis) { + if (this._options.scrollYToX && deltaX + deltaY === 0) { + // when configured to map Y to X and we both see + // no dominant axis and X and Y are competing with + // identical values into opposite directions, we + // ignore the delta as we cannot make a decision then + deltaX = deltaY = 0; + } else if (Math.abs(deltaY) >= Math.abs(deltaX)) { + deltaX = 0; + } else { + deltaY = 0; + } + } + + if (this._options.flipAxes) { + [deltaY, deltaX] = [deltaX, deltaY]; + } + + // Convert vertical scrolling to horizontal if shift is held, this + // is handled at a higher level on Mac + const shiftConvert = !platform.isMacintosh && e.browserEvent && e.browserEvent.shiftKey; + if ((this._options.scrollYToX || shiftConvert) && !deltaX) { + deltaX = deltaY; + deltaY = 0; + } + + if (e.browserEvent && e.browserEvent.altKey) { + // fastScrolling + deltaX = deltaX * this._options.fastScrollSensitivity; + deltaY = deltaY * this._options.fastScrollSensitivity; + } + + const futureScrollPosition = this._scrollable.getFutureScrollPosition(); + + let desiredScrollPosition: INewScrollPosition = {}; + if (deltaY) { + const deltaScrollTop = SCROLL_WHEEL_SENSITIVITY * deltaY; + // Here we convert values such as -0.3 to -1 or 0.3 to 1, otherwise low speed scrolling will never scroll + const desiredScrollTop = futureScrollPosition.scrollTop - (deltaScrollTop < 0 ? Math.floor(deltaScrollTop) : Math.ceil(deltaScrollTop)); + this._verticalScrollbar.writeScrollPosition(desiredScrollPosition, desiredScrollTop); + } + if (deltaX) { + const deltaScrollLeft = SCROLL_WHEEL_SENSITIVITY * deltaX; + // Here we convert values such as -0.3 to -1 or 0.3 to 1, otherwise low speed scrolling will never scroll + const desiredScrollLeft = futureScrollPosition.scrollLeft - (deltaScrollLeft < 0 ? Math.floor(deltaScrollLeft) : Math.ceil(deltaScrollLeft)); + this._horizontalScrollbar.writeScrollPosition(desiredScrollPosition, desiredScrollLeft); + } + + // Check that we are scrolling towards a location which is valid + desiredScrollPosition = this._scrollable.validateScrollPosition(desiredScrollPosition); + + if (futureScrollPosition.scrollLeft !== desiredScrollPosition.scrollLeft || futureScrollPosition.scrollTop !== desiredScrollPosition.scrollTop) { + + const canPerformSmoothScroll = ( + SCROLL_WHEEL_SMOOTH_SCROLL_ENABLED + && this._options.mouseWheelSmoothScroll + && classifier.isPhysicalMouseWheel() + ); + + if (canPerformSmoothScroll) { + this._scrollable.setScrollPositionSmooth(desiredScrollPosition); + } else { + this._scrollable.setScrollPositionNow(desiredScrollPosition); + } + + didScroll = true; + } + } + + let consumeMouseWheel = didScroll; + if (!consumeMouseWheel && this._options.alwaysConsumeMouseWheel) { + consumeMouseWheel = true; + } + if (!consumeMouseWheel && this._options.consumeMouseWheelIfScrollbarIsNeeded && (this._verticalScrollbar.isNeeded() || this._horizontalScrollbar.isNeeded())) { + consumeMouseWheel = true; + } + + if (consumeMouseWheel) { + e.preventDefault(); + e.stopPropagation(); + } + } + + private _onDidScroll(e: ScrollEvent): void { + this._shouldRender = this._horizontalScrollbar.onDidScroll(e) || this._shouldRender; + this._shouldRender = this._verticalScrollbar.onDidScroll(e) || this._shouldRender; + + if (this._options.useShadows) { + this._shouldRender = true; + } + + if (this._revealOnScroll) { + this._reveal(); + } + + if (!this._options.lazyRender) { + this._render(); + } + } + + /** + * Render / mutate the DOM now. + * Should be used together with the ctor option `lazyRender`. + */ + public renderNow(): void { + if (!this._options.lazyRender) { + throw new Error('Please use `lazyRender` together with `renderNow`!'); + } + + this._render(); + } + + private _render(): void { + if (!this._shouldRender) { + return; + } + + this._shouldRender = false; + + this._horizontalScrollbar.render(); + this._verticalScrollbar.render(); + + if (this._options.useShadows) { + const scrollState = this._scrollable.getCurrentScrollPosition(); + const enableTop = scrollState.scrollTop > 0; + const enableLeft = scrollState.scrollLeft > 0; + + const leftClassName = (enableLeft ? ' left' : ''); + const topClassName = (enableTop ? ' top' : ''); + const topLeftClassName = (enableLeft || enableTop ? ' top-left-corner' : ''); + this._leftShadowDomNode!.setClassName(`shadow${leftClassName}`); + this._topShadowDomNode!.setClassName(`shadow${topClassName}`); + this._topLeftShadowDomNode!.setClassName(`shadow${topLeftClassName}${topClassName}${leftClassName}`); + } + } + + // -------------------- fade in / fade out -------------------- + + private _onDragStart(): void { + this._isDragging = true; + this._reveal(); + } + + private _onDragEnd(): void { + this._isDragging = false; + this._hide(); + } + + private _onMouseLeave(e: IMouseEvent): void { + this._mouseIsOver = false; + this._hide(); + } + + private _onMouseOver(e: IMouseEvent): void { + this._mouseIsOver = true; + this._reveal(); + } + + private _reveal(): void { + this._verticalScrollbar.beginReveal(); + this._horizontalScrollbar.beginReveal(); + this._scheduleHide(); + } + + private _hide(): void { + if (!this._mouseIsOver && !this._isDragging) { + this._verticalScrollbar.beginHide(); + this._horizontalScrollbar.beginHide(); + } + } + + private _scheduleHide(): void { + if (!this._mouseIsOver && !this._isDragging) { + this._hideTimeout.cancelAndSet(() => this._hide(), HIDE_TIMEOUT); + } + } +} + +export class ScrollableElement extends AbstractScrollableElement { + + constructor(element: HTMLElement, options: ScrollableElementCreationOptions) { + options = options || {}; + options.mouseWheelSmoothScroll = false; + const scrollable = new Scrollable({ + forceIntegerValues: true, + smoothScrollDuration: 0, + scheduleAtNextAnimationFrame: (callback) => dom.scheduleAtNextAnimationFrame(dom.getWindow(element), callback) + }); + super(element, options, scrollable); + this._register(scrollable); + } + + public setScrollPosition(update: INewScrollPosition): void { + this._scrollable.setScrollPositionNow(update); + } + + public getScrollPosition(): IScrollPosition { + return this._scrollable.getCurrentScrollPosition(); + } +} + +export class SmoothScrollableElement extends AbstractScrollableElement { + + constructor(element: HTMLElement, options: ScrollableElementCreationOptions, scrollable: Scrollable) { + super(element, options, scrollable); + } + + public setScrollPosition(update: INewScrollPosition & { reuseAnimation?: boolean }): void { + if (update.reuseAnimation) { + this._scrollable.setScrollPositionSmooth(update, update.reuseAnimation); + } else { + this._scrollable.setScrollPositionNow(update); + } + } + + public getScrollPosition(): IScrollPosition { + return this._scrollable.getCurrentScrollPosition(); + } + +} + +export class DomScrollableElement extends AbstractScrollableElement { + + private _element: HTMLElement; + + constructor(element: HTMLElement, options: ScrollableElementCreationOptions) { + options = options || {}; + options.mouseWheelSmoothScroll = false; + const scrollable = new Scrollable({ + forceIntegerValues: false, // See https://github.com/microsoft/vscode/issues/139877 + smoothScrollDuration: 0, + scheduleAtNextAnimationFrame: (callback) => dom.scheduleAtNextAnimationFrame(dom.getWindow(element), callback) + }); + super(element, options, scrollable); + this._register(scrollable); + this._element = element; + this._register(this.onScroll((e) => { + if (e.scrollTopChanged) { + this._element.scrollTop = e.scrollTop; + } + if (e.scrollLeftChanged) { + this._element.scrollLeft = e.scrollLeft; + } + })); + this.scanDomNode(); + } + + public setScrollPosition(update: INewScrollPosition): void { + this._scrollable.setScrollPositionNow(update); + } + + public getScrollPosition(): IScrollPosition { + return this._scrollable.getCurrentScrollPosition(); + } + + public scanDomNode(): void { + // width, scrollLeft, scrollWidth, height, scrollTop, scrollHeight + this.setScrollDimensions({ + width: this._element.clientWidth, + scrollWidth: this._element.scrollWidth, + height: this._element.clientHeight, + scrollHeight: this._element.scrollHeight + }); + this.setScrollPosition({ + scrollLeft: this._element.scrollLeft, + scrollTop: this._element.scrollTop, + }); + } +} + +function resolveOptions(opts: ScrollableElementCreationOptions): ScrollableElementResolvedOptions { + const result: ScrollableElementResolvedOptions = { + lazyRender: (typeof opts.lazyRender !== 'undefined' ? opts.lazyRender : false), + className: (typeof opts.className !== 'undefined' ? opts.className : ''), + useShadows: (typeof opts.useShadows !== 'undefined' ? opts.useShadows : true), + handleMouseWheel: (typeof opts.handleMouseWheel !== 'undefined' ? opts.handleMouseWheel : true), + flipAxes: (typeof opts.flipAxes !== 'undefined' ? opts.flipAxes : false), + consumeMouseWheelIfScrollbarIsNeeded: (typeof opts.consumeMouseWheelIfScrollbarIsNeeded !== 'undefined' ? opts.consumeMouseWheelIfScrollbarIsNeeded : false), + alwaysConsumeMouseWheel: (typeof opts.alwaysConsumeMouseWheel !== 'undefined' ? opts.alwaysConsumeMouseWheel : false), + scrollYToX: (typeof opts.scrollYToX !== 'undefined' ? opts.scrollYToX : false), + mouseWheelScrollSensitivity: (typeof opts.mouseWheelScrollSensitivity !== 'undefined' ? opts.mouseWheelScrollSensitivity : 1), + fastScrollSensitivity: (typeof opts.fastScrollSensitivity !== 'undefined' ? opts.fastScrollSensitivity : 5), + scrollPredominantAxis: (typeof opts.scrollPredominantAxis !== 'undefined' ? opts.scrollPredominantAxis : true), + mouseWheelSmoothScroll: (typeof opts.mouseWheelSmoothScroll !== 'undefined' ? opts.mouseWheelSmoothScroll : true), + arrowSize: (typeof opts.arrowSize !== 'undefined' ? opts.arrowSize : 11), + + listenOnDomNode: (typeof opts.listenOnDomNode !== 'undefined' ? opts.listenOnDomNode : null), + + horizontal: (typeof opts.horizontal !== 'undefined' ? opts.horizontal : ScrollbarVisibility.Auto), + horizontalScrollbarSize: (typeof opts.horizontalScrollbarSize !== 'undefined' ? opts.horizontalScrollbarSize : 10), + horizontalSliderSize: (typeof opts.horizontalSliderSize !== 'undefined' ? opts.horizontalSliderSize : 0), + horizontalHasArrows: (typeof opts.horizontalHasArrows !== 'undefined' ? opts.horizontalHasArrows : false), + + vertical: (typeof opts.vertical !== 'undefined' ? opts.vertical : ScrollbarVisibility.Auto), + verticalScrollbarSize: (typeof opts.verticalScrollbarSize !== 'undefined' ? opts.verticalScrollbarSize : 10), + verticalHasArrows: (typeof opts.verticalHasArrows !== 'undefined' ? opts.verticalHasArrows : false), + verticalSliderSize: (typeof opts.verticalSliderSize !== 'undefined' ? opts.verticalSliderSize : 0), + + scrollByPage: (typeof opts.scrollByPage !== 'undefined' ? opts.scrollByPage : false) + }; + + result.horizontalSliderSize = (typeof opts.horizontalSliderSize !== 'undefined' ? opts.horizontalSliderSize : result.horizontalScrollbarSize); + result.verticalSliderSize = (typeof opts.verticalSliderSize !== 'undefined' ? opts.verticalSliderSize : result.verticalScrollbarSize); + + // Defaults are different on Macs + if (platform.isMacintosh) { + result.className += ' mac'; + } + + return result; +} diff --git a/src/vs/base/browser/ui/scrollbar/scrollableElementOptions.ts b/src/vs/base/browser/ui/scrollbar/scrollableElementOptions.ts new file mode 100644 index 0000000000..8e75751fff --- /dev/null +++ b/src/vs/base/browser/ui/scrollbar/scrollableElementOptions.ts @@ -0,0 +1,165 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { ScrollbarVisibility } from 'vs/base/common/scrollable'; + +export interface ScrollableElementCreationOptions { + /** + * The scrollable element should not do any DOM mutations until renderNow() is called. + * Defaults to false. + */ + lazyRender?: boolean; + /** + * CSS Class name for the scrollable element. + */ + className?: string; + /** + * Drop subtle horizontal and vertical shadows. + * Defaults to false. + */ + useShadows?: boolean; + /** + * Handle mouse wheel (listen to mouse wheel scrolling). + * Defaults to true + */ + handleMouseWheel?: boolean; + /** + * If mouse wheel is handled, make mouse wheel scrolling smooth. + * Defaults to true. + */ + mouseWheelSmoothScroll?: boolean; + /** + * Flip axes. Treat vertical scrolling like horizontal and vice-versa. + * Defaults to false. + */ + flipAxes?: boolean; + /** + * If enabled, will scroll horizontally when scrolling vertical. + * Defaults to false. + */ + scrollYToX?: boolean; + /** + * Consume all mouse wheel events if a scrollbar is needed (i.e. scrollSize > size). + * Defaults to false. + */ + consumeMouseWheelIfScrollbarIsNeeded?: boolean; + /** + * Always consume mouse wheel events, even when scrolling is no longer possible. + * Defaults to false. + */ + alwaysConsumeMouseWheel?: boolean; + /** + * A multiplier to be used on the `deltaX` and `deltaY` of mouse wheel scroll events. + * Defaults to 1. + */ + mouseWheelScrollSensitivity?: number; + /** + * FastScrolling mulitplier speed when pressing `Alt` + * Defaults to 5. + */ + fastScrollSensitivity?: number; + /** + * Whether the scrollable will only scroll along the predominant axis when scrolling both + * vertically and horizontally at the same time. + * Prevents horizontal drift when scrolling vertically on a trackpad. + * Defaults to true. + */ + scrollPredominantAxis?: boolean; + /** + * Height for vertical arrows (top/bottom) and width for horizontal arrows (left/right). + * Defaults to 11. + */ + arrowSize?: number; + /** + * The dom node events should be bound to. + * If no listenOnDomNode is provided, the dom node passed to the constructor will be used for event listening. + */ + listenOnDomNode?: HTMLElement; + /** + * Control the visibility of the horizontal scrollbar. + * Accepted values: 'auto' (on mouse over), 'visible' (always visible), 'hidden' (never visible) + * Defaults to 'auto'. + */ + horizontal?: ScrollbarVisibility; + /** + * Height (in px) of the horizontal scrollbar. + * Defaults to 10. + */ + horizontalScrollbarSize?: number; + /** + * Height (in px) of the horizontal scrollbar slider. + * Defaults to `horizontalScrollbarSize` + */ + horizontalSliderSize?: number; + /** + * Render arrows (left/right) for the horizontal scrollbar. + * Defaults to false. + */ + horizontalHasArrows?: boolean; + /** + * Control the visibility of the vertical scrollbar. + * Accepted values: 'auto' (on mouse over), 'visible' (always visible), 'hidden' (never visible) + * Defaults to 'auto'. + */ + vertical?: ScrollbarVisibility; + /** + * Width (in px) of the vertical scrollbar. + * Defaults to 10. + */ + verticalScrollbarSize?: number; + /** + * Width (in px) of the vertical scrollbar slider. + * Defaults to `verticalScrollbarSize` + */ + verticalSliderSize?: number; + /** + * Render arrows (top/bottom) for the vertical scrollbar. + * Defaults to false. + */ + verticalHasArrows?: boolean; + /** + * Scroll gutter clicks move by page vs. jump to position. + * Defaults to false. + */ + scrollByPage?: boolean; +} + +export interface ScrollableElementChangeOptions { + handleMouseWheel?: boolean; + mouseWheelScrollSensitivity?: number; + fastScrollSensitivity?: number; + scrollPredominantAxis?: boolean; + horizontal?: ScrollbarVisibility; + horizontalScrollbarSize?: number; + vertical?: ScrollbarVisibility; + verticalScrollbarSize?: number; + scrollByPage?: boolean; +} + +export interface ScrollableElementResolvedOptions { + lazyRender: boolean; + className: string; + useShadows: boolean; + handleMouseWheel: boolean; + flipAxes: boolean; + scrollYToX: boolean; + consumeMouseWheelIfScrollbarIsNeeded: boolean; + alwaysConsumeMouseWheel: boolean; + mouseWheelScrollSensitivity: number; + fastScrollSensitivity: number; + scrollPredominantAxis: boolean; + mouseWheelSmoothScroll: boolean; + arrowSize: number; + listenOnDomNode: HTMLElement | null; + horizontal: ScrollbarVisibility; + horizontalScrollbarSize: number; + horizontalSliderSize: number; + horizontalHasArrows: boolean; + vertical: ScrollbarVisibility; + verticalScrollbarSize: number; + verticalSliderSize: number; + verticalHasArrows: boolean; + scrollByPage: boolean; +} diff --git a/src/vs/base/browser/ui/scrollbar/scrollbarArrow.ts b/src/vs/base/browser/ui/scrollbar/scrollbarArrow.ts new file mode 100644 index 0000000000..7bc7e9bb3b --- /dev/null +++ b/src/vs/base/browser/ui/scrollbar/scrollbarArrow.ts @@ -0,0 +1,114 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { GlobalPointerMoveMonitor } from 'vs/base/browser/globalPointerMoveMonitor'; +import { Widget } from 'vs/base/browser/ui/widget'; +import { TimeoutTimer } from 'vs/base/common/async'; +import * as dom from 'vs/base/browser/dom'; + +/** + * The arrow image size. + */ +export const ARROW_IMG_SIZE = 11; + +export interface ScrollbarArrowOptions { + onActivate: () => void; + className: string; + // icon: ThemeIcon; + + bgWidth: number; + bgHeight: number; + + top?: number; + left?: number; + bottom?: number; + right?: number; +} + +export class ScrollbarArrow extends Widget { + + private _onActivate: () => void; + public bgDomNode: HTMLElement; + public domNode: HTMLElement; + private _pointerdownRepeatTimer: dom.WindowIntervalTimer; + private _pointerdownScheduleRepeatTimer: TimeoutTimer; + private _pointerMoveMonitor: GlobalPointerMoveMonitor; + + constructor(opts: ScrollbarArrowOptions) { + super(); + this._onActivate = opts.onActivate; + + this.bgDomNode = document.createElement('div'); + this.bgDomNode.className = 'arrow-background'; + this.bgDomNode.style.position = 'absolute'; + this.bgDomNode.style.width = opts.bgWidth + 'px'; + this.bgDomNode.style.height = opts.bgHeight + 'px'; + if (typeof opts.top !== 'undefined') { + this.bgDomNode.style.top = '0px'; + } + if (typeof opts.left !== 'undefined') { + this.bgDomNode.style.left = '0px'; + } + if (typeof opts.bottom !== 'undefined') { + this.bgDomNode.style.bottom = '0px'; + } + if (typeof opts.right !== 'undefined') { + this.bgDomNode.style.right = '0px'; + } + + this.domNode = document.createElement('div'); + this.domNode.className = opts.className; + // this.domNode.classList.add(...ThemeIcon.asClassNameArray(opts.icon)); + + this.domNode.style.position = 'absolute'; + this.domNode.style.width = ARROW_IMG_SIZE + 'px'; + this.domNode.style.height = ARROW_IMG_SIZE + 'px'; + if (typeof opts.top !== 'undefined') { + this.domNode.style.top = opts.top + 'px'; + } + if (typeof opts.left !== 'undefined') { + this.domNode.style.left = opts.left + 'px'; + } + if (typeof opts.bottom !== 'undefined') { + this.domNode.style.bottom = opts.bottom + 'px'; + } + if (typeof opts.right !== 'undefined') { + this.domNode.style.right = opts.right + 'px'; + } + + this._pointerMoveMonitor = this._register(new GlobalPointerMoveMonitor()); + this._register(dom.addStandardDisposableListener(this.bgDomNode, dom.EventType.POINTER_DOWN, (e) => this._arrowPointerDown(e))); + this._register(dom.addStandardDisposableListener(this.domNode, dom.EventType.POINTER_DOWN, (e) => this._arrowPointerDown(e))); + + this._pointerdownRepeatTimer = this._register(new dom.WindowIntervalTimer()); + this._pointerdownScheduleRepeatTimer = this._register(new TimeoutTimer()); + } + + private _arrowPointerDown(e: PointerEvent): void { + if (!e.target || !(e.target instanceof Element)) { + return; + } + const scheduleRepeater = () => { + this._pointerdownRepeatTimer.cancelAndSet(() => this._onActivate(), 1000 / 24, dom.getWindow(e)); + }; + + this._onActivate(); + this._pointerdownRepeatTimer.cancel(); + this._pointerdownScheduleRepeatTimer.cancelAndSet(scheduleRepeater, 200); + + this._pointerMoveMonitor.startMonitoring( + e.target, + e.pointerId, + e.buttons, + (pointerMoveData) => { /* Intentional empty */ }, + () => { + this._pointerdownRepeatTimer.cancel(); + this._pointerdownScheduleRepeatTimer.cancel(); + } + ); + + e.preventDefault(); + } +} diff --git a/src/vs/base/browser/ui/scrollbar/scrollbarState.ts b/src/vs/base/browser/ui/scrollbar/scrollbarState.ts new file mode 100644 index 0000000000..9a4f4e2cbb --- /dev/null +++ b/src/vs/base/browser/ui/scrollbar/scrollbarState.ts @@ -0,0 +1,243 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * The minimal size of the slider (such that it can still be clickable) -- it is artificially enlarged. + */ +const MINIMUM_SLIDER_SIZE = 20; + +export class ScrollbarState { + + /** + * For the vertical scrollbar: the width. + * For the horizontal scrollbar: the height. + */ + private _scrollbarSize: number; + + /** + * For the vertical scrollbar: the height of the pair horizontal scrollbar. + * For the horizontal scrollbar: the width of the pair vertical scrollbar. + */ + private _oppositeScrollbarSize: number; + + /** + * For the vertical scrollbar: the height of the scrollbar's arrows. + * For the horizontal scrollbar: the width of the scrollbar's arrows. + */ + private readonly _arrowSize: number; + + // --- variables + /** + * For the vertical scrollbar: the viewport height. + * For the horizontal scrollbar: the viewport width. + */ + private _visibleSize: number; + + /** + * For the vertical scrollbar: the scroll height. + * For the horizontal scrollbar: the scroll width. + */ + private _scrollSize: number; + + /** + * For the vertical scrollbar: the scroll top. + * For the horizontal scrollbar: the scroll left. + */ + private _scrollPosition: number; + + // --- computed variables + + /** + * `visibleSize` - `oppositeScrollbarSize` + */ + private _computedAvailableSize: number; + /** + * (`scrollSize` > 0 && `scrollSize` > `visibleSize`) + */ + private _computedIsNeeded: boolean; + + private _computedSliderSize: number; + private _computedSliderRatio: number; + private _computedSliderPosition: number; + + constructor(arrowSize: number, scrollbarSize: number, oppositeScrollbarSize: number, visibleSize: number, scrollSize: number, scrollPosition: number) { + this._scrollbarSize = Math.round(scrollbarSize); + this._oppositeScrollbarSize = Math.round(oppositeScrollbarSize); + this._arrowSize = Math.round(arrowSize); + + this._visibleSize = visibleSize; + this._scrollSize = scrollSize; + this._scrollPosition = scrollPosition; + + this._computedAvailableSize = 0; + this._computedIsNeeded = false; + this._computedSliderSize = 0; + this._computedSliderRatio = 0; + this._computedSliderPosition = 0; + + this._refreshComputedValues(); + } + + public clone(): ScrollbarState { + return new ScrollbarState(this._arrowSize, this._scrollbarSize, this._oppositeScrollbarSize, this._visibleSize, this._scrollSize, this._scrollPosition); + } + + public setVisibleSize(visibleSize: number): boolean { + const iVisibleSize = Math.round(visibleSize); + if (this._visibleSize !== iVisibleSize) { + this._visibleSize = iVisibleSize; + this._refreshComputedValues(); + return true; + } + return false; + } + + public setScrollSize(scrollSize: number): boolean { + const iScrollSize = Math.round(scrollSize); + if (this._scrollSize !== iScrollSize) { + this._scrollSize = iScrollSize; + this._refreshComputedValues(); + return true; + } + return false; + } + + public setScrollPosition(scrollPosition: number): boolean { + const iScrollPosition = Math.round(scrollPosition); + if (this._scrollPosition !== iScrollPosition) { + this._scrollPosition = iScrollPosition; + this._refreshComputedValues(); + return true; + } + return false; + } + + public setScrollbarSize(scrollbarSize: number): void { + this._scrollbarSize = Math.round(scrollbarSize); + } + + public setOppositeScrollbarSize(oppositeScrollbarSize: number): void { + this._oppositeScrollbarSize = Math.round(oppositeScrollbarSize); + } + + private static _computeValues(oppositeScrollbarSize: number, arrowSize: number, visibleSize: number, scrollSize: number, scrollPosition: number) { + const computedAvailableSize = Math.max(0, visibleSize - oppositeScrollbarSize); + const computedRepresentableSize = Math.max(0, computedAvailableSize - 2 * arrowSize); + const computedIsNeeded = (scrollSize > 0 && scrollSize > visibleSize); + + if (!computedIsNeeded) { + // There is no need for a slider + return { + computedAvailableSize: Math.round(computedAvailableSize), + computedIsNeeded: computedIsNeeded, + computedSliderSize: Math.round(computedRepresentableSize), + computedSliderRatio: 0, + computedSliderPosition: 0, + }; + } + + // We must artificially increase the size of the slider if needed, since the slider would be too small to grab with the mouse otherwise + const computedSliderSize = Math.round(Math.max(MINIMUM_SLIDER_SIZE, Math.floor(visibleSize * computedRepresentableSize / scrollSize))); + + // The slider can move from 0 to `computedRepresentableSize` - `computedSliderSize` + // in the same way `scrollPosition` can move from 0 to `scrollSize` - `visibleSize`. + const computedSliderRatio = (computedRepresentableSize - computedSliderSize) / (scrollSize - visibleSize); + const computedSliderPosition = (scrollPosition * computedSliderRatio); + + return { + computedAvailableSize: Math.round(computedAvailableSize), + computedIsNeeded: computedIsNeeded, + computedSliderSize: Math.round(computedSliderSize), + computedSliderRatio: computedSliderRatio, + computedSliderPosition: Math.round(computedSliderPosition), + }; + } + + private _refreshComputedValues(): void { + const r = ScrollbarState._computeValues(this._oppositeScrollbarSize, this._arrowSize, this._visibleSize, this._scrollSize, this._scrollPosition); + this._computedAvailableSize = r.computedAvailableSize; + this._computedIsNeeded = r.computedIsNeeded; + this._computedSliderSize = r.computedSliderSize; + this._computedSliderRatio = r.computedSliderRatio; + this._computedSliderPosition = r.computedSliderPosition; + } + + public getArrowSize(): number { + return this._arrowSize; + } + + public getScrollPosition(): number { + return this._scrollPosition; + } + + public getRectangleLargeSize(): number { + return this._computedAvailableSize; + } + + public getRectangleSmallSize(): number { + return this._scrollbarSize; + } + + public isNeeded(): boolean { + return this._computedIsNeeded; + } + + public getSliderSize(): number { + return this._computedSliderSize; + } + + public getSliderPosition(): number { + return this._computedSliderPosition; + } + + /** + * Compute a desired `scrollPosition` such that `offset` ends up in the center of the slider. + * `offset` is based on the same coordinate system as the `sliderPosition`. + */ + public getDesiredScrollPositionFromOffset(offset: number): number { + if (!this._computedIsNeeded) { + // no need for a slider + return 0; + } + + const desiredSliderPosition = offset - this._arrowSize - this._computedSliderSize / 2; + return Math.round(desiredSliderPosition / this._computedSliderRatio); + } + + /** + * Compute a desired `scrollPosition` from if offset is before or after the slider position. + * If offset is before slider, treat as a page up (or left). If after, page down (or right). + * `offset` and `_computedSliderPosition` are based on the same coordinate system. + * `_visibleSize` corresponds to a "page" of lines in the returned coordinate system. + */ + public getDesiredScrollPositionFromOffsetPaged(offset: number): number { + if (!this._computedIsNeeded) { + // no need for a slider + return 0; + } + + const correctedOffset = offset - this._arrowSize; // compensate if has arrows + let desiredScrollPosition = this._scrollPosition; + if (correctedOffset < this._computedSliderPosition) { + desiredScrollPosition -= this._visibleSize; // page up/left + } else { + desiredScrollPosition += this._visibleSize; // page down/right + } + return desiredScrollPosition; + } + + /** + * Compute a desired `scrollPosition` such that the slider moves by `delta`. + */ + public getDesiredScrollPositionFromDelta(delta: number): number { + if (!this._computedIsNeeded) { + // no need for a slider + return 0; + } + + const desiredSliderPosition = this._computedSliderPosition + delta; + return Math.round(desiredSliderPosition / this._computedSliderRatio); + } +} diff --git a/src/vs/base/browser/ui/scrollbar/scrollbarVisibilityController.ts b/src/vs/base/browser/ui/scrollbar/scrollbarVisibilityController.ts new file mode 100644 index 0000000000..791c7332ff --- /dev/null +++ b/src/vs/base/browser/ui/scrollbar/scrollbarVisibilityController.ts @@ -0,0 +1,118 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { FastDomNode } from 'vs/base/browser/fastDomNode'; +import { TimeoutTimer } from 'vs/base/common/async'; +import { Disposable } from 'vs/base/common/lifecycle'; +import { ScrollbarVisibility } from 'vs/base/common/scrollable'; + +export class ScrollbarVisibilityController extends Disposable { + private _visibility: ScrollbarVisibility; + private _visibleClassName: string; + private _invisibleClassName: string; + private _domNode: FastDomNode | null; + private _rawShouldBeVisible: boolean; + private _shouldBeVisible: boolean; + private _isNeeded: boolean; + private _isVisible: boolean; + private _revealTimer: TimeoutTimer; + + constructor(visibility: ScrollbarVisibility, visibleClassName: string, invisibleClassName: string) { + super(); + this._visibility = visibility; + this._visibleClassName = visibleClassName; + this._invisibleClassName = invisibleClassName; + this._domNode = null; + this._isVisible = false; + this._isNeeded = false; + this._rawShouldBeVisible = false; + this._shouldBeVisible = false; + this._revealTimer = this._register(new TimeoutTimer()); + } + + public setVisibility(visibility: ScrollbarVisibility): void { + if (this._visibility !== visibility) { + this._visibility = visibility; + this._updateShouldBeVisible(); + } + } + + // ----------------- Hide / Reveal + + public setShouldBeVisible(rawShouldBeVisible: boolean): void { + this._rawShouldBeVisible = rawShouldBeVisible; + this._updateShouldBeVisible(); + } + + private _applyVisibilitySetting(): boolean { + if (this._visibility === ScrollbarVisibility.Hidden) { + return false; + } + if (this._visibility === ScrollbarVisibility.Visible) { + return true; + } + return this._rawShouldBeVisible; + } + + private _updateShouldBeVisible(): void { + const shouldBeVisible = this._applyVisibilitySetting(); + + if (this._shouldBeVisible !== shouldBeVisible) { + this._shouldBeVisible = shouldBeVisible; + this.ensureVisibility(); + } + } + + public setIsNeeded(isNeeded: boolean): void { + if (this._isNeeded !== isNeeded) { + this._isNeeded = isNeeded; + this.ensureVisibility(); + } + } + + public setDomNode(domNode: FastDomNode): void { + this._domNode = domNode; + this._domNode.setClassName(this._invisibleClassName); + + // Now that the flags & the dom node are in a consistent state, ensure the Hidden/Visible configuration + this.setShouldBeVisible(false); + } + + public ensureVisibility(): void { + + if (!this._isNeeded) { + // Nothing to be rendered + this._hide(false); + return; + } + + if (this._shouldBeVisible) { + this._reveal(); + } else { + this._hide(true); + } + } + + private _reveal(): void { + if (this._isVisible) { + return; + } + this._isVisible = true; + + // The CSS animation doesn't play otherwise + this._revealTimer.setIfNotSet(() => { + this._domNode?.setClassName(this._visibleClassName); + }, 0); + } + + private _hide(withFadeAway: boolean): void { + this._revealTimer.cancel(); + if (!this._isVisible) { + return; + } + this._isVisible = false; + this._domNode?.setClassName(this._invisibleClassName + (withFadeAway ? ' fade' : '')); + } +} diff --git a/src/vs/base/browser/ui/scrollbar/verticalScrollbar.ts b/src/vs/base/browser/ui/scrollbar/verticalScrollbar.ts new file mode 100644 index 0000000000..331654af3a --- /dev/null +++ b/src/vs/base/browser/ui/scrollbar/verticalScrollbar.ts @@ -0,0 +1,116 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { AbstractScrollbar, ISimplifiedPointerEvent, ScrollbarHost } from 'vs/base/browser/ui/scrollbar/abstractScrollbar'; +import { ScrollableElementResolvedOptions } from 'vs/base/browser/ui/scrollbar/scrollableElementOptions'; +import { ScrollbarState } from 'vs/base/browser/ui/scrollbar/scrollbarState'; +import { INewScrollPosition, Scrollable, ScrollbarVisibility, ScrollEvent } from 'vs/base/common/scrollable'; + + + +export class VerticalScrollbar extends AbstractScrollbar { + + constructor(scrollable: Scrollable, options: ScrollableElementResolvedOptions, host: ScrollbarHost) { + const scrollDimensions = scrollable.getScrollDimensions(); + const scrollPosition = scrollable.getCurrentScrollPosition(); + super({ + lazyRender: options.lazyRender, + host: host, + scrollbarState: new ScrollbarState( + (options.verticalHasArrows ? options.arrowSize : 0), + (options.vertical === ScrollbarVisibility.Hidden ? 0 : options.verticalScrollbarSize), + // give priority to vertical scroll bar over horizontal and let it scroll all the way to the bottom + 0, + scrollDimensions.height, + scrollDimensions.scrollHeight, + scrollPosition.scrollTop + ), + visibility: options.vertical, + extraScrollbarClassName: 'vertical', + scrollable: scrollable, + scrollByPage: options.scrollByPage + }); + + if (options.verticalHasArrows) { + throw new Error('horizontalHasArrows is not supported in xterm.js'); + // const arrowDelta = (options.arrowSize - ARROW_IMG_SIZE) / 2; + // const scrollbarDelta = (options.verticalScrollbarSize - ARROW_IMG_SIZE) / 2; + + // this._createArrow({ + // className: 'scra', + // icon: Codicon.scrollbarButtonUp, + // top: arrowDelta, + // left: scrollbarDelta, + // bottom: undefined, + // right: undefined, + // bgWidth: options.verticalScrollbarSize, + // bgHeight: options.arrowSize, + // onActivate: () => this._host.onMouseWheel(new StandardWheelEvent(null, 0, 1)), + // }); + + // this._createArrow({ + // className: 'scra', + // icon: Codicon.scrollbarButtonDown, + // top: undefined, + // left: scrollbarDelta, + // bottom: arrowDelta, + // right: undefined, + // bgWidth: options.verticalScrollbarSize, + // bgHeight: options.arrowSize, + // onActivate: () => this._host.onMouseWheel(new StandardWheelEvent(null, 0, -1)), + // }); + } + + this._createSlider(0, Math.floor((options.verticalScrollbarSize - options.verticalSliderSize) / 2), options.verticalSliderSize, undefined); + } + + protected _updateSlider(sliderSize: number, sliderPosition: number): void { + this.slider.setHeight(sliderSize); + this.slider.setTop(sliderPosition); + } + + protected _renderDomNode(largeSize: number, smallSize: number): void { + this.domNode.setWidth(smallSize); + this.domNode.setHeight(largeSize); + this.domNode.setRight(0); + this.domNode.setTop(0); + } + + public onDidScroll(e: ScrollEvent): boolean { + this._shouldRender = this._onElementScrollSize(e.scrollHeight) || this._shouldRender; + this._shouldRender = this._onElementScrollPosition(e.scrollTop) || this._shouldRender; + this._shouldRender = this._onElementSize(e.height) || this._shouldRender; + return this._shouldRender; + } + + protected _pointerDownRelativePosition(offsetX: number, offsetY: number): number { + return offsetY; + } + + protected _sliderPointerPosition(e: ISimplifiedPointerEvent): number { + return e.pageY; + } + + protected _sliderOrthogonalPointerPosition(e: ISimplifiedPointerEvent): number { + return e.pageX; + } + + protected _updateScrollbarSize(size: number): void { + this.slider.setWidth(size); + } + + public writeScrollPosition(target: INewScrollPosition, scrollPosition: number): void { + target.scrollTop = scrollPosition; + } + + public updateOptions(options: ScrollableElementResolvedOptions): void { + this.updateScrollbarSize(options.vertical === ScrollbarVisibility.Hidden ? 0 : options.verticalScrollbarSize); + // give priority to vertical scroll bar over horizontal and let it scroll all the way to the bottom + this._scrollbarState.setOppositeScrollbarSize(0); + this._visibilityController.setVisibility(options.vertical); + this._scrollByPage = options.scrollByPage; + } + +} diff --git a/src/vs/base/browser/ui/splitview/paneview.css b/src/vs/base/browser/ui/splitview/paneview.css new file mode 100644 index 0000000000..62cb3fe642 --- /dev/null +++ b/src/vs/base/browser/ui/splitview/paneview.css @@ -0,0 +1,152 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.monaco-pane-view { + width: 100%; + height: 100%; +} + +.monaco-pane-view .pane { + overflow: hidden; + width: 100%; + height: 100%; + display: flex; + flex-direction: column; +} + +.monaco-pane-view .pane.horizontal:not(.expanded) { + flex-direction: row; +} + +.monaco-pane-view .pane > .pane-header { + height: 22px; + font-size: 11px; + font-weight: bold; + overflow: hidden; + display: flex; + cursor: pointer; + align-items: center; + box-sizing: border-box; +} + +.monaco-pane-view .pane > .pane-header.not-collapsible { + cursor: default; +} + +.monaco-pane-view .pane > .pane-header > .title { + text-transform: uppercase; +} + +.monaco-pane-view .pane.horizontal:not(.expanded) > .pane-header { + flex-direction: column; + height: 100%; + width: 22px; +} + +.monaco-pane-view .pane > .pane-header > .codicon:first-of-type { + margin: 0 2px; +} + +.monaco-pane-view .pane.horizontal:not(.expanded) > .pane-header > .codicon:first-of-type { + margin: 2px; +} + +/* TODO: actions should be part of the pane, but they aren't yet */ +.monaco-pane-view .pane > .pane-header > .actions { + display: none; + margin-left: auto; +} + +.monaco-pane-view .pane > .pane-header > .actions .action-item { + margin-right: 4px; +} + +.monaco-pane-view .pane > .pane-header > .actions .action-label { + padding: 2px; +} + +/* TODO: actions should be part of the pane, but they aren't yet */ +.monaco-pane-view .pane:hover > .pane-header.expanded > .actions, +.monaco-pane-view .pane:focus-within > .pane-header.expanded > .actions, +.monaco-pane-view .pane > .pane-header.actions-always-visible.expanded > .actions, +.monaco-pane-view .pane > .pane-header.focused.expanded > .actions { + display: initial; +} + +.monaco-pane-view .pane > .pane-header .monaco-action-bar .action-item.select-container { + cursor: default; +} + +.monaco-pane-view .pane > .pane-header .action-item .monaco-select-box { + cursor: pointer; + min-width: 110px; + min-height: 18px; + padding: 2px 23px 2px 8px; +} + +.linux .monaco-pane-view .pane > .pane-header .action-item .monaco-select-box, +.windows .monaco-pane-view .pane > .pane-header .action-item .monaco-select-box { + padding: 0px 23px 0px 8px; +} + +/* Bold font style does not go well with CJK fonts */ +.monaco-pane-view:lang(zh-Hans) .pane > .pane-header, +.monaco-pane-view:lang(zh-Hant) .pane > .pane-header, +.monaco-pane-view:lang(ja) .pane > .pane-header, +.monaco-pane-view:lang(ko) .pane > .pane-header { + font-weight: normal; +} + +.monaco-pane-view .pane > .pane-header.hidden { + display: none; +} + +.monaco-pane-view .pane > .pane-body { + overflow: hidden; + flex: 1; +} + +/* Animation */ + +.monaco-pane-view.animated .split-view-view { + transition-duration: 0.15s; + transition-timing-function: ease-out; +} + +.reduce-motion .monaco-pane-view .split-view-view { + transition-duration: 0s !important; +} + +.monaco-pane-view.animated.vertical .split-view-view { + transition-property: height; +} + +.monaco-pane-view.animated.horizontal .split-view-view { + transition-property: width; +} + +#monaco-pane-drop-overlay { + position: absolute; + z-index: 10000; + width: 100%; + height: 100%; + left: 0; + box-sizing: border-box; +} + +#monaco-pane-drop-overlay > .pane-overlay-indicator { + position: absolute; + width: 100%; + height: 100%; + min-height: 22px; + min-width: 19px; + + pointer-events: none; /* very important to not take events away from the parent */ + transition: opacity 150ms ease-out; +} + +#monaco-pane-drop-overlay > .pane-overlay-indicator.overlay-move-transition { + transition: top 70ms ease-out, left 70ms ease-out, width 70ms ease-out, height 70ms ease-out, opacity 150ms ease-out; +} diff --git a/src/vs/base/browser/ui/splitview/paneview.ts b/src/vs/base/browser/ui/splitview/paneview.ts new file mode 100644 index 0000000000..464bcdded7 --- /dev/null +++ b/src/vs/base/browser/ui/splitview/paneview.ts @@ -0,0 +1,681 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { isFirefox } from 'vs/base/browser/browser'; +import { DataTransfers } from 'vs/base/browser/dnd'; +import { $, addDisposableListener, append, clearNode, EventHelper, EventType, getWindow, isHTMLElement, trackFocus } from 'vs/base/browser/dom'; +import { DomEmitter } from 'vs/base/browser/event'; +import { StandardKeyboardEvent } from 'vs/base/browser/keyboardEvent'; +import { Gesture, EventType as TouchEventType } from 'vs/base/browser/touch'; +import { IBoundarySashes, Orientation } from 'vs/base/browser/ui/sash/sash'; +import { Color, RGBA } from 'vs/base/common/color'; +import { Emitter, Event } from 'vs/base/common/event'; +import { KeyCode } from 'vs/base/common/keyCodes'; +import { Disposable, DisposableStore, IDisposable } from 'vs/base/common/lifecycle'; +import { ScrollEvent } from 'vs/base/common/scrollable'; +// import 'vs/css!./paneview'; +import { localize } from 'vs/nls'; +import { IView, Sizing, SplitView } from './splitview'; + +export interface IPaneOptions { + minimumBodySize?: number; + maximumBodySize?: number; + expanded?: boolean; + orientation?: Orientation; + title: string; + titleDescription?: string; +} + +export interface IPaneStyles { + readonly dropBackground: string | undefined; + readonly headerForeground: string | undefined; + readonly headerBackground: string | undefined; + readonly headerBorder: string | undefined; + readonly leftBorder: string | undefined; +} + +/** + * A Pane is a structured SplitView view. + * + * WARNING: You must call `render()` after you construct it. + * It can't be done automatically at the end of the ctor + * because of the order of property initialization in TypeScript. + * Subclasses wouldn't be able to set own properties + * before the `render()` call, thus forbidding their use. + */ +export abstract class Pane extends Disposable implements IView { + + private static readonly HEADER_SIZE = 22; + + readonly element: HTMLElement; + private header!: HTMLElement; + private body!: HTMLElement; + + protected _expanded: boolean; + protected _orientation: Orientation; + + private expandedSize: number | undefined = undefined; + private _headerVisible = true; + private _collapsible = true; + private _bodyRendered = false; + private _minimumBodySize: number; + private _maximumBodySize: number; + private _ariaHeaderLabel: string; + private styles: IPaneStyles = { + dropBackground: undefined, + headerBackground: undefined, + headerBorder: undefined, + headerForeground: undefined, + leftBorder: undefined + }; + private animationTimer: number | undefined = undefined; + + private readonly _onDidChange = this._register(new Emitter()); + readonly onDidChange: Event = this._onDidChange.event; + + private readonly _onDidChangeExpansionState = this._register(new Emitter()); + readonly onDidChangeExpansionState: Event = this._onDidChangeExpansionState.event; + + get ariaHeaderLabel(): string { + return this._ariaHeaderLabel; + } + + set ariaHeaderLabel(newLabel: string) { + this._ariaHeaderLabel = newLabel; + this.header.setAttribute('aria-label', this.ariaHeaderLabel); + } + + get draggableElement(): HTMLElement { + return this.header; + } + + get dropTargetElement(): HTMLElement { + return this.element; + } + + get dropBackground(): string | undefined { + return this.styles.dropBackground; + } + + get minimumBodySize(): number { + return this._minimumBodySize; + } + + set minimumBodySize(size: number) { + this._minimumBodySize = size; + this._onDidChange.fire(undefined); + } + + get maximumBodySize(): number { + return this._maximumBodySize; + } + + set maximumBodySize(size: number) { + this._maximumBodySize = size; + this._onDidChange.fire(undefined); + } + + private get headerSize(): number { + return this.headerVisible ? Pane.HEADER_SIZE : 0; + } + + get minimumSize(): number { + const headerSize = this.headerSize; + const expanded = !this.headerVisible || this.isExpanded(); + const minimumBodySize = expanded ? this.minimumBodySize : 0; + + return headerSize + minimumBodySize; + } + + get maximumSize(): number { + const headerSize = this.headerSize; + const expanded = !this.headerVisible || this.isExpanded(); + const maximumBodySize = expanded ? this.maximumBodySize : 0; + + return headerSize + maximumBodySize; + } + + orthogonalSize: number = 0; + + constructor(options: IPaneOptions) { + super(); + this._expanded = typeof options.expanded === 'undefined' ? true : !!options.expanded; + this._orientation = typeof options.orientation === 'undefined' ? Orientation.VERTICAL : options.orientation; + this._ariaHeaderLabel = localize('viewSection', "{0} Section", options.title); + this._minimumBodySize = typeof options.minimumBodySize === 'number' ? options.minimumBodySize : this._orientation === Orientation.HORIZONTAL ? 200 : 120; + this._maximumBodySize = typeof options.maximumBodySize === 'number' ? options.maximumBodySize : Number.POSITIVE_INFINITY; + + this.element = $('.pane'); + } + + isExpanded(): boolean { + return this._expanded; + } + + setExpanded(expanded: boolean): boolean { + if (!expanded && !this.collapsible) { + return false; + } + + if (this._expanded === !!expanded) { + return false; + } + + this.element?.classList.toggle('expanded', expanded); + + this._expanded = !!expanded; + this.updateHeader(); + + if (expanded) { + if (!this._bodyRendered) { + this.renderBody(this.body); + this._bodyRendered = true; + } + + if (typeof this.animationTimer === 'number') { + getWindow(this.element).clearTimeout(this.animationTimer); + } + append(this.element, this.body); + } else { + this.animationTimer = getWindow(this.element).setTimeout(() => { + this.body.remove(); + }, 200); + } + + this._onDidChangeExpansionState.fire(expanded); + this._onDidChange.fire(expanded ? this.expandedSize : undefined); + return true; + } + + get headerVisible(): boolean { + return this._headerVisible; + } + + set headerVisible(visible: boolean) { + if (this._headerVisible === !!visible) { + return; + } + + this._headerVisible = !!visible; + this.updateHeader(); + this._onDidChange.fire(undefined); + } + + get collapsible(): boolean { + return this._collapsible; + } + + set collapsible(collapsible: boolean) { + if (this._collapsible === !!collapsible) { + return; + } + + this._collapsible = !!collapsible; + this.updateHeader(); + } + + get orientation(): Orientation { + return this._orientation; + } + + set orientation(orientation: Orientation) { + if (this._orientation === orientation) { + return; + } + + this._orientation = orientation; + + if (this.element) { + this.element.classList.toggle('horizontal', this.orientation === Orientation.HORIZONTAL); + this.element.classList.toggle('vertical', this.orientation === Orientation.VERTICAL); + } + + if (this.header) { + this.updateHeader(); + } + } + + render(): void { + this.element.classList.toggle('expanded', this.isExpanded()); + this.element.classList.toggle('horizontal', this.orientation === Orientation.HORIZONTAL); + this.element.classList.toggle('vertical', this.orientation === Orientation.VERTICAL); + + this.header = $('.pane-header'); + append(this.element, this.header); + this.header.setAttribute('tabindex', '0'); + // Use role button so the aria-expanded state gets read https://github.com/microsoft/vscode/issues/95996 + this.header.setAttribute('role', 'button'); + this.header.setAttribute('aria-label', this.ariaHeaderLabel); + this.renderHeader(this.header); + + const focusTracker = trackFocus(this.header); + this._register(focusTracker); + this._register(focusTracker.onDidFocus(() => this.header.classList.add('focused'), null)); + this._register(focusTracker.onDidBlur(() => this.header.classList.remove('focused'), null)); + + this.updateHeader(); + + const eventDisposables = this._register(new DisposableStore()); + const onKeyDown = this._register(new DomEmitter(this.header, 'keydown')); + const onHeaderKeyDown = Event.map(onKeyDown.event, e => new StandardKeyboardEvent(e), eventDisposables); + + this._register(Event.filter(onHeaderKeyDown, e => e.keyCode === KeyCode.Enter || e.keyCode === KeyCode.Space, eventDisposables)(() => this.setExpanded(!this.isExpanded()), null)); + + this._register(Event.filter(onHeaderKeyDown, e => e.keyCode === KeyCode.LeftArrow, eventDisposables)(() => this.setExpanded(false), null)); + + this._register(Event.filter(onHeaderKeyDown, e => e.keyCode === KeyCode.RightArrow, eventDisposables)(() => this.setExpanded(true), null)); + + this._register(Gesture.addTarget(this.header)); + + [EventType.CLICK, TouchEventType.Tap].forEach(eventType => { + this._register(addDisposableListener(this.header, eventType, e => { + if (!e.defaultPrevented) { + this.setExpanded(!this.isExpanded()); + } + })); + }); + + this.body = append(this.element, $('.pane-body')); + + // Only render the body if it will be visible + // Otherwise, render it when the pane is expanded + if (!this._bodyRendered && this.isExpanded()) { + this.renderBody(this.body); + this._bodyRendered = true; + } + + if (!this.isExpanded()) { + this.body.remove(); + } + } + + layout(size: number): void { + const headerSize = this.headerVisible ? Pane.HEADER_SIZE : 0; + + const width = this._orientation === Orientation.VERTICAL ? this.orthogonalSize : size; + const height = this._orientation === Orientation.VERTICAL ? size - headerSize : this.orthogonalSize - headerSize; + + if (this.isExpanded()) { + this.body.classList.toggle('wide', width >= 600); + this.layoutBody(height, width); + this.expandedSize = size; + } + } + + style(styles: IPaneStyles): void { + this.styles = styles; + + if (!this.header) { + return; + } + + this.updateHeader(); + } + + protected updateHeader(): void { + const expanded = !this.headerVisible || this.isExpanded(); + + if (this.collapsible) { + this.header.setAttribute('tabindex', '0'); + this.header.setAttribute('role', 'button'); + } else { + this.header.removeAttribute('tabindex'); + this.header.removeAttribute('role'); + } + + this.header.style.lineHeight = `${this.headerSize}px`; + this.header.classList.toggle('hidden', !this.headerVisible); + this.header.classList.toggle('expanded', expanded); + this.header.classList.toggle('not-collapsible', !this.collapsible); + this.header.setAttribute('aria-expanded', String(expanded)); + + this.header.style.color = this.collapsible ? this.styles.headerForeground ?? '' : ''; + this.header.style.backgroundColor = (this.collapsible ? this.styles.headerBackground : 'transparent') ?? ''; + this.header.style.borderTop = this.styles.headerBorder && this.orientation === Orientation.VERTICAL ? `1px solid ${this.styles.headerBorder}` : ''; + this.element.style.borderLeft = this.styles.leftBorder && this.orientation === Orientation.HORIZONTAL ? `1px solid ${this.styles.leftBorder}` : ''; + } + + protected abstract renderHeader(container: HTMLElement): void; + protected abstract renderBody(container: HTMLElement): void; + protected abstract layoutBody(height: number, width: number): void; +} + +interface IDndContext { + draggable: PaneDraggable | null; +} + +class PaneDraggable extends Disposable { + + private static readonly DefaultDragOverBackgroundColor = new Color(new RGBA(128, 128, 128, 0.5)); + + private dragOverCounter = 0; // see https://github.com/microsoft/vscode/issues/14470 + + private _onDidDrop = this._register(new Emitter<{ from: Pane; to: Pane }>()); + readonly onDidDrop = this._onDidDrop.event; + + constructor(private pane: Pane, private dnd: IPaneDndController, private context: IDndContext) { + super(); + + pane.draggableElement.draggable = true; + this._register(addDisposableListener(pane.draggableElement, 'dragstart', e => this.onDragStart(e))); + this._register(addDisposableListener(pane.dropTargetElement, 'dragenter', e => this.onDragEnter(e))); + this._register(addDisposableListener(pane.dropTargetElement, 'dragleave', e => this.onDragLeave(e))); + this._register(addDisposableListener(pane.dropTargetElement, 'dragend', e => this.onDragEnd(e))); + this._register(addDisposableListener(pane.dropTargetElement, 'drop', e => this.onDrop(e))); + } + + private onDragStart(e: DragEvent): void { + if (!this.dnd.canDrag(this.pane) || !e.dataTransfer) { + e.preventDefault(); + e.stopPropagation(); + return; + } + + e.dataTransfer.effectAllowed = 'move'; + + if (isFirefox) { + // Firefox: requires to set a text data transfer to get going + e.dataTransfer?.setData(DataTransfers.TEXT, this.pane.draggableElement.textContent || ''); + } + + const dragImage = append(this.pane.element.ownerDocument.body, $('.monaco-drag-image', {}, this.pane.draggableElement.textContent || '')); + e.dataTransfer.setDragImage(dragImage, -10, -10); + setTimeout(() => dragImage.remove(), 0); + + this.context.draggable = this; + } + + private onDragEnter(e: DragEvent): void { + if (!this.context.draggable || this.context.draggable === this) { + return; + } + + if (!this.dnd.canDrop(this.context.draggable.pane, this.pane)) { + return; + } + + this.dragOverCounter++; + this.render(); + } + + private onDragLeave(e: DragEvent): void { + if (!this.context.draggable || this.context.draggable === this) { + return; + } + + if (!this.dnd.canDrop(this.context.draggable.pane, this.pane)) { + return; + } + + this.dragOverCounter--; + + if (this.dragOverCounter === 0) { + this.render(); + } + } + + private onDragEnd(e: DragEvent): void { + if (!this.context.draggable) { + return; + } + + this.dragOverCounter = 0; + this.render(); + this.context.draggable = null; + } + + private onDrop(e: DragEvent): void { + if (!this.context.draggable) { + return; + } + + EventHelper.stop(e); + + this.dragOverCounter = 0; + this.render(); + + if (this.dnd.canDrop(this.context.draggable.pane, this.pane) && this.context.draggable !== this) { + this._onDidDrop.fire({ from: this.context.draggable.pane, to: this.pane }); + } + + this.context.draggable = null; + } + + private render(): void { + let backgroundColor: string | null = null; + + if (this.dragOverCounter > 0) { + backgroundColor = this.pane.dropBackground ?? PaneDraggable.DefaultDragOverBackgroundColor.toString(); + } + + this.pane.dropTargetElement.style.backgroundColor = backgroundColor || ''; + } +} + +export interface IPaneDndController { + canDrag(pane: Pane): boolean; + canDrop(pane: Pane, overPane: Pane): boolean; +} + +export class DefaultPaneDndController implements IPaneDndController { + + canDrag(pane: Pane): boolean { + return true; + } + + canDrop(pane: Pane, overPane: Pane): boolean { + return true; + } +} + +export interface IPaneViewOptions { + dnd?: IPaneDndController; + orientation?: Orientation; +} + +interface IPaneItem { + pane: Pane; + disposable: IDisposable; +} + +export class PaneView extends Disposable { + + private dnd: IPaneDndController | undefined; + private dndContext: IDndContext = { draggable: null }; + readonly element: HTMLElement; + private paneItems: IPaneItem[] = []; + private orthogonalSize: number = 0; + private size: number = 0; + private splitview: SplitView; + private animationTimer: number | undefined = undefined; + + private _onDidDrop = this._register(new Emitter<{ from: Pane; to: Pane }>()); + readonly onDidDrop: Event<{ from: Pane; to: Pane }> = this._onDidDrop.event; + + orientation: Orientation; + private boundarySashes: IBoundarySashes | undefined; + readonly onDidSashChange: Event; + readonly onDidSashReset: Event; + readonly onDidScroll: Event; + + constructor(container: HTMLElement, options: IPaneViewOptions = {}) { + super(); + + this.dnd = options.dnd; + this.orientation = options.orientation ?? Orientation.VERTICAL; + this.element = append(container, $('.monaco-pane-view')); + this.splitview = this._register(new SplitView(this.element, { orientation: this.orientation })); + this.onDidSashReset = this.splitview.onDidSashReset; + this.onDidSashChange = this.splitview.onDidSashChange; + this.onDidScroll = this.splitview.onDidScroll; + + const eventDisposables = this._register(new DisposableStore()); + const onKeyDown = this._register(new DomEmitter(this.element, 'keydown')); + const onHeaderKeyDown = Event.map(Event.filter(onKeyDown.event, e => isHTMLElement(e.target) && e.target.classList.contains('pane-header'), eventDisposables), e => new StandardKeyboardEvent(e), eventDisposables); + + this._register(Event.filter(onHeaderKeyDown, e => e.keyCode === KeyCode.UpArrow, eventDisposables)(() => this.focusPrevious())); + this._register(Event.filter(onHeaderKeyDown, e => e.keyCode === KeyCode.DownArrow, eventDisposables)(() => this.focusNext())); + } + + addPane(pane: Pane, size: number, index = this.splitview.length): void { + const disposables = new DisposableStore(); + pane.onDidChangeExpansionState(this.setupAnimation, this, disposables); + + const paneItem = { pane: pane, disposable: disposables }; + this.paneItems.splice(index, 0, paneItem); + pane.orientation = this.orientation; + pane.orthogonalSize = this.orthogonalSize; + this.splitview.addView(pane, size, index); + + if (this.dnd) { + const draggable = new PaneDraggable(pane, this.dnd, this.dndContext); + disposables.add(draggable); + disposables.add(draggable.onDidDrop(this._onDidDrop.fire, this._onDidDrop)); + } + } + + removePane(pane: Pane): void { + const index = this.paneItems.findIndex(item => item.pane === pane); + + if (index === -1) { + return; + } + + this.splitview.removeView(index, pane.isExpanded() ? Sizing.Distribute : undefined); + const paneItem = this.paneItems.splice(index, 1)[0]; + paneItem.disposable.dispose(); + } + + movePane(from: Pane, to: Pane): void { + const fromIndex = this.paneItems.findIndex(item => item.pane === from); + const toIndex = this.paneItems.findIndex(item => item.pane === to); + + if (fromIndex === -1 || toIndex === -1) { + return; + } + + const [paneItem] = this.paneItems.splice(fromIndex, 1); + this.paneItems.splice(toIndex, 0, paneItem); + + this.splitview.moveView(fromIndex, toIndex); + } + + resizePane(pane: Pane, size: number): void { + const index = this.paneItems.findIndex(item => item.pane === pane); + + if (index === -1) { + return; + } + + this.splitview.resizeView(index, size); + } + + getPaneSize(pane: Pane): number { + const index = this.paneItems.findIndex(item => item.pane === pane); + + if (index === -1) { + return -1; + } + + return this.splitview.getViewSize(index); + } + + layout(height: number, width: number): void { + this.orthogonalSize = this.orientation === Orientation.VERTICAL ? width : height; + this.size = this.orientation === Orientation.HORIZONTAL ? width : height; + + for (const paneItem of this.paneItems) { + paneItem.pane.orthogonalSize = this.orthogonalSize; + } + + this.splitview.layout(this.size); + } + + setBoundarySashes(sashes: IBoundarySashes) { + this.boundarySashes = sashes; + this.updateSplitviewOrthogonalSashes(sashes); + } + + private updateSplitviewOrthogonalSashes(sashes: IBoundarySashes | undefined) { + if (this.orientation === Orientation.VERTICAL) { + this.splitview.orthogonalStartSash = sashes?.left; + this.splitview.orthogonalEndSash = sashes?.right; + } else { + this.splitview.orthogonalEndSash = sashes?.bottom; + } + } + + flipOrientation(height: number, width: number): void { + this.orientation = this.orientation === Orientation.VERTICAL ? Orientation.HORIZONTAL : Orientation.VERTICAL; + const paneSizes = this.paneItems.map(pane => this.getPaneSize(pane.pane)); + + this.splitview.dispose(); + clearNode(this.element); + + this.splitview = this._register(new SplitView(this.element, { orientation: this.orientation })); + this.updateSplitviewOrthogonalSashes(this.boundarySashes); + + const newOrthogonalSize = this.orientation === Orientation.VERTICAL ? width : height; + const newSize = this.orientation === Orientation.HORIZONTAL ? width : height; + + this.paneItems.forEach((pane, index) => { + pane.pane.orthogonalSize = newOrthogonalSize; + pane.pane.orientation = this.orientation; + + const viewSize = this.size === 0 ? 0 : (newSize * paneSizes[index]) / this.size; + this.splitview.addView(pane.pane, viewSize, index); + }); + + this.size = newSize; + this.orthogonalSize = newOrthogonalSize; + + this.splitview.layout(this.size); + } + + private setupAnimation(): void { + if (typeof this.animationTimer === 'number') { + getWindow(this.element).clearTimeout(this.animationTimer); + } + + this.element.classList.add('animated'); + + this.animationTimer = getWindow(this.element).setTimeout(() => { + this.animationTimer = undefined; + this.element.classList.remove('animated'); + }, 200); + } + + private getPaneHeaderElements(): HTMLElement[] { + return [...this.element.querySelectorAll('.pane-header')] as HTMLElement[]; + } + + private focusPrevious(): void { + const headers = this.getPaneHeaderElements(); + const index = headers.indexOf(this.element.ownerDocument.activeElement as HTMLElement); + + if (index === -1) { + return; + } + + headers[Math.max(index - 1, 0)].focus(); + } + + private focusNext(): void { + const headers = this.getPaneHeaderElements(); + const index = headers.indexOf(this.element.ownerDocument.activeElement as HTMLElement); + + if (index === -1) { + return; + } + + headers[Math.min(index + 1, headers.length - 1)].focus(); + } + + override dispose(): void { + super.dispose(); + + this.paneItems.forEach(i => i.disposable.dispose()); + } +} diff --git a/src/vs/base/browser/ui/splitview/splitview.css b/src/vs/base/browser/ui/splitview/splitview.css new file mode 100644 index 0000000000..3af3e9062d --- /dev/null +++ b/src/vs/base/browser/ui/splitview/splitview.css @@ -0,0 +1,70 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.monaco-split-view2 { + position: relative; + width: 100%; + height: 100%; +} + +.monaco-split-view2 > .sash-container { + position: absolute; + width: 100%; + height: 100%; + pointer-events: none; +} + +.monaco-split-view2 > .sash-container > .monaco-sash { + pointer-events: initial; +} + +.monaco-split-view2 > .monaco-scrollable-element { + width: 100%; + height: 100%; +} + +.monaco-split-view2 > .monaco-scrollable-element > .split-view-container { + width: 100%; + height: 100%; + white-space: nowrap; + position: relative; +} + +.monaco-split-view2 > .monaco-scrollable-element > .split-view-container > .split-view-view { + white-space: initial; + position: absolute; +} + +.monaco-split-view2 > .monaco-scrollable-element > .split-view-container > .split-view-view:not(.visible) { + display: none; +} + +.monaco-split-view2.vertical > .monaco-scrollable-element > .split-view-container > .split-view-view { + width: 100%; +} + +.monaco-split-view2.horizontal > .monaco-scrollable-element > .split-view-container > .split-view-view { + height: 100%; +} + +.monaco-split-view2.separator-border > .monaco-scrollable-element > .split-view-container > .split-view-view:not(:first-child)::before { + content: ' '; + position: absolute; + top: 0; + left: 0; + z-index: 5; + pointer-events: none; + background-color: var(--separator-border); +} + +.monaco-split-view2.separator-border.horizontal > .monaco-scrollable-element > .split-view-container > .split-view-view:not(:first-child)::before { + height: 100%; + width: 1px; +} + +.monaco-split-view2.separator-border.vertical > .monaco-scrollable-element > .split-view-container > .split-view-view:not(:first-child)::before { + height: 1px; + width: 100%; +} diff --git a/src/vs/base/browser/ui/splitview/splitview.ts b/src/vs/base/browser/ui/splitview/splitview.ts new file mode 100644 index 0000000000..e07e76e776 --- /dev/null +++ b/src/vs/base/browser/ui/splitview/splitview.ts @@ -0,0 +1,1504 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { $, addDisposableListener, append, getWindow, scheduleAtNextAnimationFrame } from 'vs/base/browser/dom'; +import { DomEmitter } from 'vs/base/browser/event'; +import { ISashEvent as IBaseSashEvent, Orientation, Sash, SashState } from 'vs/base/browser/ui/sash/sash'; +import { SmoothScrollableElement } from 'vs/base/browser/ui/scrollbar/scrollableElement'; +import { pushToEnd, pushToStart, range } from 'vs/base/common/arrays'; +import { Color } from 'vs/base/common/color'; +import { Emitter, Event } from 'vs/base/common/event'; +import { combinedDisposable, Disposable, dispose, IDisposable, toDisposable } from 'vs/base/common/lifecycle'; +import { clamp } from 'vs/base/common/numbers'; +import { Scrollable, ScrollbarVisibility, ScrollEvent } from 'vs/base/common/scrollable'; +import * as types from 'vs/base/common/types'; +// import 'vs/css!./splitview'; +export { Orientation } from 'vs/base/browser/ui/sash/sash'; + +export interface ISplitViewStyles { + readonly separatorBorder: Color; +} + +const defaultStyles: ISplitViewStyles = { + separatorBorder: Color.transparent +}; + +export const enum LayoutPriority { + Normal, + Low, + High +} + +/** + * The interface to implement for views within a {@link SplitView}. + * + * An optional {@link TLayoutContext layout context type} may be used in order to + * pass along layout contextual data from the {@link SplitView.layout} method down + * to each view's {@link IView.layout} calls. + */ +export interface IView { + + /** + * The DOM element for this view. + */ + readonly element: HTMLElement; + + /** + * A minimum size for this view. + * + * @remarks If none, set it to `0`. + */ + readonly minimumSize: number; + + /** + * A maximum size for this view. + * + * @remarks If none, set it to `Number.POSITIVE_INFINITY`. + */ + readonly maximumSize: number; + + /** + * The priority of the view when the {@link SplitView.resize layout} algorithm + * runs. Views with higher priority will be resized first. + * + * @remarks Only used when `proportionalLayout` is false. + */ + readonly priority?: LayoutPriority; + + /** + * If the {@link SplitView} supports {@link ISplitViewOptions.proportionalLayout proportional layout}, + * this property allows for finer control over the proportional layout algorithm, per view. + * + * @defaultValue `true` + */ + readonly proportionalLayout?: boolean; + + /** + * Whether the view will snap whenever the user reaches its minimum size or + * attempts to grow it beyond the minimum size. + * + * @defaultValue `false` + */ + readonly snap?: boolean; + + /** + * View instances are supposed to fire the {@link IView.onDidChange} event whenever + * any of the constraint properties have changed: + * + * - {@link IView.minimumSize} + * - {@link IView.maximumSize} + * - {@link IView.priority} + * - {@link IView.snap} + * + * The SplitView will relayout whenever that happens. The event can optionally emit + * the view's preferred size for that relayout. + */ + readonly onDidChange: Event; + + /** + * This will be called by the {@link SplitView} during layout. A view meant to + * pass along the layout information down to its descendants. + * + * @param size The size of this view, in pixels. + * @param offset The offset of this view, relative to the start of the {@link SplitView}. + * @param context The optional {@link IView layout context} passed to {@link SplitView.layout}. + */ + layout(size: number, offset: number, context: TLayoutContext | undefined): void; + + /** + * This will be called by the {@link SplitView} whenever this view is made + * visible or hidden. + * + * @param visible Whether the view becomes visible. + */ + setVisible?(visible: boolean): void; +} + +/** + * A descriptor for a {@link SplitView} instance. + */ +export interface ISplitViewDescriptor = IView> { + + /** + * The layout size of the {@link SplitView}. + */ + readonly size: number; + + /** + * Descriptors for each {@link IView view}. + */ + readonly views: { + + /** + * Whether the {@link IView view} is visible. + * + * @defaultValue `true` + */ + readonly visible?: boolean; + + /** + * The size of the {@link IView view}. + * + * @defaultValue `true` + */ + readonly size: number; + + /** + * The size of the {@link IView view}. + * + * @defaultValue `true` + */ + readonly view: TView; + }[]; +} + +export interface ISplitViewOptions = IView> { + + /** + * Which axis the views align on. + * + * @defaultValue `Orientation.VERTICAL` + */ + readonly orientation?: Orientation; + + /** + * Styles overriding the {@link defaultStyles default ones}. + */ + readonly styles?: ISplitViewStyles; + + /** + * Make Alt-drag the default drag operation. + */ + readonly inverseAltBehavior?: boolean; + + /** + * Resize each view proportionally when resizing the SplitView. + * + * @defaultValue `true` + */ + readonly proportionalLayout?: boolean; + + /** + * An initial description of this {@link SplitView} instance, allowing + * to initialze all views within the ctor. + */ + readonly descriptor?: ISplitViewDescriptor; + + /** + * The scrollbar visibility setting for whenever the views within + * the {@link SplitView} overflow. + */ + readonly scrollbarVisibility?: ScrollbarVisibility; + + /** + * Override the orthogonal size of sashes. + */ + readonly getSashOrthogonalSize?: () => number; +} + +interface ISashEvent { + readonly sash: Sash; + readonly start: number; + readonly current: number; + readonly alt: boolean; +} + +type ViewItemSize = number | { cachedVisibleSize: number }; + +abstract class ViewItem> { + + private _size: number; + set size(size: number) { + this._size = size; + } + + get size(): number { + return this._size; + } + + private _cachedVisibleSize: number | undefined = undefined; + get cachedVisibleSize(): number | undefined { return this._cachedVisibleSize; } + + get visible(): boolean { + return typeof this._cachedVisibleSize === 'undefined'; + } + + setVisible(visible: boolean, size?: number): void { + if (visible === this.visible) { + return; + } + + if (visible) { + this.size = clamp(this._cachedVisibleSize!, this.viewMinimumSize, this.viewMaximumSize); + this._cachedVisibleSize = undefined; + } else { + this._cachedVisibleSize = typeof size === 'number' ? size : this.size; + this.size = 0; + } + + this.container.classList.toggle('visible', visible); + + try { + this.view.setVisible?.(visible); + } catch (e) { + console.error('Splitview: Failed to set visible view'); + console.error(e); + } + } + + get minimumSize(): number { return this.visible ? this.view.minimumSize : 0; } + get viewMinimumSize(): number { return this.view.minimumSize; } + + get maximumSize(): number { return this.visible ? this.view.maximumSize : 0; } + get viewMaximumSize(): number { return this.view.maximumSize; } + + get priority(): LayoutPriority | undefined { return this.view.priority; } + get proportionalLayout(): boolean { return this.view.proportionalLayout ?? true; } + get snap(): boolean { return !!this.view.snap; } + + set enabled(enabled: boolean) { + this.container.style.pointerEvents = enabled ? '' : 'none'; + } + + constructor( + protected container: HTMLElement, + readonly view: TView, + size: ViewItemSize, + private disposable: IDisposable + ) { + if (typeof size === 'number') { + this._size = size; + this._cachedVisibleSize = undefined; + container.classList.add('visible'); + } else { + this._size = 0; + this._cachedVisibleSize = size.cachedVisibleSize; + } + } + + layout(offset: number, layoutContext: TLayoutContext | undefined): void { + this.layoutContainer(offset); + + try { + this.view.layout(this.size, offset, layoutContext); + } catch (e) { + console.error('Splitview: Failed to layout view'); + console.error(e); + } + } + + abstract layoutContainer(offset: number): void; + + dispose(): void { + this.disposable.dispose(); + } +} + +class VerticalViewItem> extends ViewItem { + + layoutContainer(offset: number): void { + this.container.style.top = `${offset}px`; + this.container.style.height = `${this.size}px`; + } +} + +class HorizontalViewItem> extends ViewItem { + + layoutContainer(offset: number): void { + this.container.style.left = `${offset}px`; + this.container.style.width = `${this.size}px`; + } +} + +interface ISashItem { + sash: Sash; + disposable: IDisposable; +} + +interface ISashDragSnapState { + readonly index: number; + readonly limitDelta: number; + readonly size: number; +} + +interface ISashDragState { + index: number; + start: number; + current: number; + sizes: number[]; + minDelta: number; + maxDelta: number; + alt: boolean; + snapBefore: ISashDragSnapState | undefined; + snapAfter: ISashDragSnapState | undefined; + disposable: IDisposable; +} + +enum State { + Idle, + Busy +} + +/** + * When adding or removing views, uniformly distribute the entire split view space among + * all views. + */ +export type DistributeSizing = { type: 'distribute' }; + +/** + * When adding a view, make space for it by reducing the size of another view, + * indexed by the provided `index`. + */ +export type SplitSizing = { type: 'split'; index: number }; + +/** + * When adding a view, use DistributeSizing when all pre-existing views are + * distributed evenly, otherwise use SplitSizing. + */ +export type AutoSizing = { type: 'auto'; index: number }; + +/** + * When adding or removing views, assume the view is invisible. + */ +export type InvisibleSizing = { type: 'invisible'; cachedVisibleSize: number }; + +/** + * When adding or removing views, the sizing provides fine grained + * control over how other views get resized. + */ +export type Sizing = DistributeSizing | SplitSizing | AutoSizing | InvisibleSizing; + +export namespace Sizing { + + /** + * When adding or removing views, distribute the delta space among + * all other views. + */ + export const Distribute: DistributeSizing = { type: 'distribute' }; + + /** + * When adding or removing views, split the delta space with another + * specific view, indexed by the provided `index`. + */ + export function Split(index: number): SplitSizing { return { type: 'split', index }; } + + /** + * When adding a view, use DistributeSizing when all pre-existing views are + * distributed evenly, otherwise use SplitSizing. + */ + export function Auto(index: number): AutoSizing { return { type: 'auto', index }; } + + /** + * When adding or removing views, assume the view is invisible. + */ + export function Invisible(cachedVisibleSize: number): InvisibleSizing { return { type: 'invisible', cachedVisibleSize }; } +} + +/** + * The {@link SplitView} is the UI component which implements a one dimensional + * flex-like layout algorithm for a collection of {@link IView} instances, which + * are essentially HTMLElement instances with the following size constraints: + * + * - {@link IView.minimumSize} + * - {@link IView.maximumSize} + * - {@link IView.priority} + * - {@link IView.snap} + * + * In case the SplitView doesn't have enough size to fit all views, it will overflow + * its content with a scrollbar. + * + * In between each pair of views there will be a {@link Sash} allowing the user + * to resize the views, making sure the constraints are respected. + * + * An optional {@link TLayoutContext layout context type} may be used in order to + * pass along layout contextual data from the {@link SplitView.layout} method down + * to each view's {@link IView.layout} calls. + * + * Features: + * - Flex-like layout algorithm + * - Snap support + * - Orthogonal sash support, for corner sashes + * - View hide/show support + * - View swap/move support + * - Alt key modifier behavior, macOS style + */ +export class SplitView = IView> extends Disposable { + + /** + * This {@link SplitView}'s orientation. + */ + readonly orientation: Orientation; + + /** + * The DOM element representing this {@link SplitView}. + */ + readonly el: HTMLElement; + + private sashContainer: HTMLElement; + private viewContainer: HTMLElement; + private scrollable: Scrollable; + private scrollableElement: SmoothScrollableElement; + private size = 0; + private layoutContext: TLayoutContext | undefined; + private _contentSize = 0; + private proportions: (number | undefined)[] | undefined = undefined; + private viewItems: ViewItem[] = []; + sashItems: ISashItem[] = []; // used in tests + private sashDragState: ISashDragState | undefined; + private state: State = State.Idle; + private inverseAltBehavior: boolean; + private proportionalLayout: boolean; + private readonly getSashOrthogonalSize: { (): number } | undefined; + + private _onDidSashChange = this._register(new Emitter()); + private _onDidSashReset = this._register(new Emitter()); + private _orthogonalStartSash: Sash | undefined; + private _orthogonalEndSash: Sash | undefined; + private _startSnappingEnabled = true; + private _endSnappingEnabled = true; + + /** + * The sum of all views' sizes. + */ + get contentSize(): number { return this._contentSize; } + + /** + * Fires whenever the user resizes a {@link Sash sash}. + */ + readonly onDidSashChange = this._onDidSashChange.event; + + /** + * Fires whenever the user double clicks a {@link Sash sash}. + */ + readonly onDidSashReset = this._onDidSashReset.event; + + /** + * Fires whenever the split view is scrolled. + */ + readonly onDidScroll: Event; + + /** + * The amount of views in this {@link SplitView}. + */ + get length(): number { + return this.viewItems.length; + } + + /** + * The minimum size of this {@link SplitView}. + */ + get minimumSize(): number { + return this.viewItems.reduce((r, item) => r + item.minimumSize, 0); + } + + /** + * The maximum size of this {@link SplitView}. + */ + get maximumSize(): number { + return this.length === 0 ? Number.POSITIVE_INFINITY : this.viewItems.reduce((r, item) => r + item.maximumSize, 0); + } + + get orthogonalStartSash(): Sash | undefined { return this._orthogonalStartSash; } + get orthogonalEndSash(): Sash | undefined { return this._orthogonalEndSash; } + get startSnappingEnabled(): boolean { return this._startSnappingEnabled; } + get endSnappingEnabled(): boolean { return this._endSnappingEnabled; } + + /** + * A reference to a sash, perpendicular to all sashes in this {@link SplitView}, + * located at the left- or top-most side of the SplitView. + * Corner sashes will be created automatically at the intersections. + */ + set orthogonalStartSash(sash: Sash | undefined) { + for (const sashItem of this.sashItems) { + sashItem.sash.orthogonalStartSash = sash; + } + + this._orthogonalStartSash = sash; + } + + /** + * A reference to a sash, perpendicular to all sashes in this {@link SplitView}, + * located at the right- or bottom-most side of the SplitView. + * Corner sashes will be created automatically at the intersections. + */ + set orthogonalEndSash(sash: Sash | undefined) { + for (const sashItem of this.sashItems) { + sashItem.sash.orthogonalEndSash = sash; + } + + this._orthogonalEndSash = sash; + } + + /** + * The internal sashes within this {@link SplitView}. + */ + get sashes(): readonly Sash[] { + return this.sashItems.map(s => s.sash); + } + + /** + * Enable/disable snapping at the beginning of this {@link SplitView}. + */ + set startSnappingEnabled(startSnappingEnabled: boolean) { + if (this._startSnappingEnabled === startSnappingEnabled) { + return; + } + + this._startSnappingEnabled = startSnappingEnabled; + this.updateSashEnablement(); + } + + /** + * Enable/disable snapping at the end of this {@link SplitView}. + */ + set endSnappingEnabled(endSnappingEnabled: boolean) { + if (this._endSnappingEnabled === endSnappingEnabled) { + return; + } + + this._endSnappingEnabled = endSnappingEnabled; + this.updateSashEnablement(); + } + + /** + * Create a new {@link SplitView} instance. + */ + constructor(container: HTMLElement, options: ISplitViewOptions = {}) { + super(); + + this.orientation = options.orientation ?? Orientation.VERTICAL; + this.inverseAltBehavior = options.inverseAltBehavior ?? false; + this.proportionalLayout = options.proportionalLayout ?? true; + this.getSashOrthogonalSize = options.getSashOrthogonalSize; + + this.el = document.createElement('div'); + this.el.classList.add('monaco-split-view2'); + this.el.classList.add(this.orientation === Orientation.VERTICAL ? 'vertical' : 'horizontal'); + container.appendChild(this.el); + + this.sashContainer = append(this.el, $('.sash-container')); + this.viewContainer = $('.split-view-container'); + + this.scrollable = this._register(new Scrollable({ + forceIntegerValues: true, + smoothScrollDuration: 125, + scheduleAtNextAnimationFrame: callback => scheduleAtNextAnimationFrame(getWindow(this.el), callback), + })); + this.scrollableElement = this._register(new SmoothScrollableElement(this.viewContainer, { + vertical: this.orientation === Orientation.VERTICAL ? (options.scrollbarVisibility ?? ScrollbarVisibility.Auto) : ScrollbarVisibility.Hidden, + horizontal: this.orientation === Orientation.HORIZONTAL ? (options.scrollbarVisibility ?? ScrollbarVisibility.Auto) : ScrollbarVisibility.Hidden + }, this.scrollable)); + + // https://github.com/microsoft/vscode/issues/157737 + const onDidScrollViewContainer = this._register(new DomEmitter(this.viewContainer, 'scroll')).event; + this._register(onDidScrollViewContainer(_ => { + const position = this.scrollableElement.getScrollPosition(); + const scrollLeft = Math.abs(this.viewContainer.scrollLeft - position.scrollLeft) <= 1 ? undefined : this.viewContainer.scrollLeft; + const scrollTop = Math.abs(this.viewContainer.scrollTop - position.scrollTop) <= 1 ? undefined : this.viewContainer.scrollTop; + + if (scrollLeft !== undefined || scrollTop !== undefined) { + this.scrollableElement.setScrollPosition({ scrollLeft, scrollTop }); + } + })); + + this.onDidScroll = this.scrollableElement.onScroll; + this._register(this.onDidScroll(e => { + if (e.scrollTopChanged) { + this.viewContainer.scrollTop = e.scrollTop; + } + + if (e.scrollLeftChanged) { + this.viewContainer.scrollLeft = e.scrollLeft; + } + })); + + append(this.el, this.scrollableElement.getDomNode()); + + this.style(options.styles || defaultStyles); + + // We have an existing set of view, add them now + if (options.descriptor) { + this.size = options.descriptor.size; + options.descriptor.views.forEach((viewDescriptor, index) => { + const sizing = types.isUndefined(viewDescriptor.visible) || viewDescriptor.visible ? viewDescriptor.size : { type: 'invisible', cachedVisibleSize: viewDescriptor.size } satisfies InvisibleSizing; + + const view = viewDescriptor.view; + this.doAddView(view, sizing, index, true); + }); + + // Initialize content size and proportions for first layout + this._contentSize = this.viewItems.reduce((r, i) => r + i.size, 0); + this.saveProportions(); + } + } + + style(styles: ISplitViewStyles): void { + if (styles.separatorBorder.isTransparent()) { + this.el.classList.remove('separator-border'); + this.el.style.removeProperty('--separator-border'); + } else { + this.el.classList.add('separator-border'); + this.el.style.setProperty('--separator-border', styles.separatorBorder.toString()); + } + } + + /** + * Add a {@link IView view} to this {@link SplitView}. + * + * @param view The view to add. + * @param size Either a fixed size, or a dynamic {@link Sizing} strategy. + * @param index The index to insert the view on. + * @param skipLayout Whether layout should be skipped. + */ + addView(view: TView, size: number | Sizing, index = this.viewItems.length, skipLayout?: boolean): void { + this.doAddView(view, size, index, skipLayout); + } + + /** + * Remove a {@link IView view} from this {@link SplitView}. + * + * @param index The index where the {@link IView view} is located. + * @param sizing Whether to distribute other {@link IView view}'s sizes. + */ + removeView(index: number, sizing?: Sizing): TView { + if (index < 0 || index >= this.viewItems.length) { + throw new Error('Index out of bounds'); + } + + if (this.state !== State.Idle) { + throw new Error('Cant modify splitview'); + } + + this.state = State.Busy; + + try { + if (sizing?.type === 'auto') { + if (this.areViewsDistributed()) { + sizing = { type: 'distribute' }; + } else { + sizing = { type: 'split', index: sizing.index }; + } + } + + // Save referene view, in case of `split` sizing + const referenceViewItem = sizing?.type === 'split' ? this.viewItems[sizing.index] : undefined; + + // Remove view + const viewItemToRemove = this.viewItems.splice(index, 1)[0]; + + // Resize reference view, in case of `split` sizing + if (referenceViewItem) { + referenceViewItem.size += viewItemToRemove.size; + } + + // Remove sash + if (this.viewItems.length >= 1) { + const sashIndex = Math.max(index - 1, 0); + const sashItem = this.sashItems.splice(sashIndex, 1)[0]; + sashItem.disposable.dispose(); + } + + this.relayout(); + + if (sizing?.type === 'distribute') { + this.distributeViewSizes(); + } + + const result = viewItemToRemove.view; + viewItemToRemove.dispose(); + return result; + + } finally { + this.state = State.Idle; + } + } + + removeAllViews(): TView[] { + if (this.state !== State.Idle) { + throw new Error('Cant modify splitview'); + } + + this.state = State.Busy; + + try { + const viewItems = this.viewItems.splice(0, this.viewItems.length); + + for (const viewItem of viewItems) { + viewItem.dispose(); + } + + const sashItems = this.sashItems.splice(0, this.sashItems.length); + + for (const sashItem of sashItems) { + sashItem.disposable.dispose(); + } + + this.relayout(); + return viewItems.map(i => i.view); + + } finally { + this.state = State.Idle; + } + } + + /** + * Move a {@link IView view} to a different index. + * + * @param from The source index. + * @param to The target index. + */ + moveView(from: number, to: number): void { + if (this.state !== State.Idle) { + throw new Error('Cant modify splitview'); + } + + const cachedVisibleSize = this.getViewCachedVisibleSize(from); + const sizing = typeof cachedVisibleSize === 'undefined' ? this.getViewSize(from) : Sizing.Invisible(cachedVisibleSize); + const view = this.removeView(from); + this.addView(view, sizing, to); + } + + + /** + * Swap two {@link IView views}. + * + * @param from The source index. + * @param to The target index. + */ + swapViews(from: number, to: number): void { + if (this.state !== State.Idle) { + throw new Error('Cant modify splitview'); + } + + if (from > to) { + return this.swapViews(to, from); + } + + const fromSize = this.getViewSize(from); + const toSize = this.getViewSize(to); + const toView = this.removeView(to); + const fromView = this.removeView(from); + + this.addView(toView, fromSize, from); + this.addView(fromView, toSize, to); + } + + /** + * Returns whether the {@link IView view} is visible. + * + * @param index The {@link IView view} index. + */ + isViewVisible(index: number): boolean { + if (index < 0 || index >= this.viewItems.length) { + throw new Error('Index out of bounds'); + } + + const viewItem = this.viewItems[index]; + return viewItem.visible; + } + + /** + * Set a {@link IView view}'s visibility. + * + * @param index The {@link IView view} index. + * @param visible Whether the {@link IView view} should be visible. + */ + setViewVisible(index: number, visible: boolean): void { + if (index < 0 || index >= this.viewItems.length) { + throw new Error('Index out of bounds'); + } + + const viewItem = this.viewItems[index]; + viewItem.setVisible(visible); + + this.distributeEmptySpace(index); + this.layoutViews(); + this.saveProportions(); + } + + /** + * Returns the {@link IView view}'s size previously to being hidden. + * + * @param index The {@link IView view} index. + */ + getViewCachedVisibleSize(index: number): number | undefined { + if (index < 0 || index >= this.viewItems.length) { + throw new Error('Index out of bounds'); + } + + const viewItem = this.viewItems[index]; + return viewItem.cachedVisibleSize; + } + + /** + * Layout the {@link SplitView}. + * + * @param size The entire size of the {@link SplitView}. + * @param layoutContext An optional layout context to pass along to {@link IView views}. + */ + layout(size: number, layoutContext?: TLayoutContext): void { + const previousSize = Math.max(this.size, this._contentSize); + this.size = size; + this.layoutContext = layoutContext; + + if (!this.proportions) { + const indexes = range(this.viewItems.length); + const lowPriorityIndexes = indexes.filter(i => this.viewItems[i].priority === LayoutPriority.Low); + const highPriorityIndexes = indexes.filter(i => this.viewItems[i].priority === LayoutPriority.High); + + this.resize(this.viewItems.length - 1, size - previousSize, undefined, lowPriorityIndexes, highPriorityIndexes); + } else { + let total = 0; + + for (let i = 0; i < this.viewItems.length; i++) { + const item = this.viewItems[i]; + const proportion = this.proportions[i]; + + if (typeof proportion === 'number') { + total += proportion; + } else { + size -= item.size; + } + } + + for (let i = 0; i < this.viewItems.length; i++) { + const item = this.viewItems[i]; + const proportion = this.proportions[i]; + + if (typeof proportion === 'number' && total > 0) { + item.size = clamp(Math.round(proportion * size / total), item.minimumSize, item.maximumSize); + } + } + } + + this.distributeEmptySpace(); + this.layoutViews(); + } + + private saveProportions(): void { + if (this.proportionalLayout && this._contentSize > 0) { + this.proportions = this.viewItems.map(v => v.proportionalLayout && v.visible ? v.size / this._contentSize : undefined); + } + } + + private onSashStart({ sash, start, alt }: ISashEvent): void { + for (const item of this.viewItems) { + item.enabled = false; + } + + const index = this.sashItems.findIndex(item => item.sash === sash); + + // This way, we can press Alt while we resize a sash, macOS style! + const disposable = combinedDisposable( + addDisposableListener(this.el.ownerDocument.body, 'keydown', e => resetSashDragState(this.sashDragState!.current, e.altKey)), + addDisposableListener(this.el.ownerDocument.body, 'keyup', () => resetSashDragState(this.sashDragState!.current, false)) + ); + + const resetSashDragState = (start: number, alt: boolean) => { + const sizes = this.viewItems.map(i => i.size); + let minDelta = Number.NEGATIVE_INFINITY; + let maxDelta = Number.POSITIVE_INFINITY; + + if (this.inverseAltBehavior) { + alt = !alt; + } + + if (alt) { + // When we're using the last sash with Alt, we're resizing + // the view to the left/up, instead of right/down as usual + // Thus, we must do the inverse of the usual + const isLastSash = index === this.sashItems.length - 1; + + if (isLastSash) { + const viewItem = this.viewItems[index]; + minDelta = (viewItem.minimumSize - viewItem.size) / 2; + maxDelta = (viewItem.maximumSize - viewItem.size) / 2; + } else { + const viewItem = this.viewItems[index + 1]; + minDelta = (viewItem.size - viewItem.maximumSize) / 2; + maxDelta = (viewItem.size - viewItem.minimumSize) / 2; + } + } + + let snapBefore: ISashDragSnapState | undefined; + let snapAfter: ISashDragSnapState | undefined; + + if (!alt) { + const upIndexes = range(index, -1); + const downIndexes = range(index + 1, this.viewItems.length); + const minDeltaUp = upIndexes.reduce((r, i) => r + (this.viewItems[i].minimumSize - sizes[i]), 0); + const maxDeltaUp = upIndexes.reduce((r, i) => r + (this.viewItems[i].viewMaximumSize - sizes[i]), 0); + const maxDeltaDown = downIndexes.length === 0 ? Number.POSITIVE_INFINITY : downIndexes.reduce((r, i) => r + (sizes[i] - this.viewItems[i].minimumSize), 0); + const minDeltaDown = downIndexes.length === 0 ? Number.NEGATIVE_INFINITY : downIndexes.reduce((r, i) => r + (sizes[i] - this.viewItems[i].viewMaximumSize), 0); + const minDelta = Math.max(minDeltaUp, minDeltaDown); + const maxDelta = Math.min(maxDeltaDown, maxDeltaUp); + const snapBeforeIndex = this.findFirstSnapIndex(upIndexes); + const snapAfterIndex = this.findFirstSnapIndex(downIndexes); + + if (typeof snapBeforeIndex === 'number') { + const viewItem = this.viewItems[snapBeforeIndex]; + const halfSize = Math.floor(viewItem.viewMinimumSize / 2); + + snapBefore = { + index: snapBeforeIndex, + limitDelta: viewItem.visible ? minDelta - halfSize : minDelta + halfSize, + size: viewItem.size + }; + } + + if (typeof snapAfterIndex === 'number') { + const viewItem = this.viewItems[snapAfterIndex]; + const halfSize = Math.floor(viewItem.viewMinimumSize / 2); + + snapAfter = { + index: snapAfterIndex, + limitDelta: viewItem.visible ? maxDelta + halfSize : maxDelta - halfSize, + size: viewItem.size + }; + } + } + + this.sashDragState = { start, current: start, index, sizes, minDelta, maxDelta, alt, snapBefore, snapAfter, disposable }; + }; + + resetSashDragState(start, alt); + } + + private onSashChange({ current }: ISashEvent): void { + const { index, start, sizes, alt, minDelta, maxDelta, snapBefore, snapAfter } = this.sashDragState!; + this.sashDragState!.current = current; + + const delta = current - start; + const newDelta = this.resize(index, delta, sizes, undefined, undefined, minDelta, maxDelta, snapBefore, snapAfter); + + if (alt) { + const isLastSash = index === this.sashItems.length - 1; + const newSizes = this.viewItems.map(i => i.size); + const viewItemIndex = isLastSash ? index : index + 1; + const viewItem = this.viewItems[viewItemIndex]; + const newMinDelta = viewItem.size - viewItem.maximumSize; + const newMaxDelta = viewItem.size - viewItem.minimumSize; + const resizeIndex = isLastSash ? index - 1 : index + 1; + + this.resize(resizeIndex, -newDelta, newSizes, undefined, undefined, newMinDelta, newMaxDelta); + } + + this.distributeEmptySpace(); + this.layoutViews(); + } + + private onSashEnd(index: number): void { + this._onDidSashChange.fire(index); + this.sashDragState!.disposable.dispose(); + this.saveProportions(); + + for (const item of this.viewItems) { + item.enabled = true; + } + } + + private onViewChange(item: ViewItem, size: number | undefined): void { + const index = this.viewItems.indexOf(item); + + if (index < 0 || index >= this.viewItems.length) { + return; + } + + size = typeof size === 'number' ? size : item.size; + size = clamp(size, item.minimumSize, item.maximumSize); + + if (this.inverseAltBehavior && index > 0) { + // In this case, we want the view to grow or shrink both sides equally + // so we just resize the "left" side by half and let `resize` do the clamping magic + this.resize(index - 1, Math.floor((item.size - size) / 2)); + this.distributeEmptySpace(); + this.layoutViews(); + } else { + item.size = size; + this.relayout([index], undefined); + } + } + + /** + * Resize a {@link IView view} within the {@link SplitView}. + * + * @param index The {@link IView view} index. + * @param size The {@link IView view} size. + */ + resizeView(index: number, size: number): void { + if (index < 0 || index >= this.viewItems.length) { + return; + } + + if (this.state !== State.Idle) { + throw new Error('Cant modify splitview'); + } + + this.state = State.Busy; + + try { + const indexes = range(this.viewItems.length).filter(i => i !== index); + const lowPriorityIndexes = [...indexes.filter(i => this.viewItems[i].priority === LayoutPriority.Low), index]; + const highPriorityIndexes = indexes.filter(i => this.viewItems[i].priority === LayoutPriority.High); + + const item = this.viewItems[index]; + size = Math.round(size); + size = clamp(size, item.minimumSize, Math.min(item.maximumSize, this.size)); + + item.size = size; + this.relayout(lowPriorityIndexes, highPriorityIndexes); + } finally { + this.state = State.Idle; + } + } + + /** + * Returns whether all other {@link IView views} are at their minimum size. + */ + isViewExpanded(index: number): boolean { + if (index < 0 || index >= this.viewItems.length) { + return false; + } + + for (const item of this.viewItems) { + if (item !== this.viewItems[index] && item.size > item.minimumSize) { + return false; + } + } + + return true; + } + + /** + * Distribute the entire {@link SplitView} size among all {@link IView views}. + */ + distributeViewSizes(): void { + const flexibleViewItems: ViewItem[] = []; + let flexibleSize = 0; + + for (const item of this.viewItems) { + if (item.maximumSize - item.minimumSize > 0) { + flexibleViewItems.push(item); + flexibleSize += item.size; + } + } + + const size = Math.floor(flexibleSize / flexibleViewItems.length); + + for (const item of flexibleViewItems) { + item.size = clamp(size, item.minimumSize, item.maximumSize); + } + + const indexes = range(this.viewItems.length); + const lowPriorityIndexes = indexes.filter(i => this.viewItems[i].priority === LayoutPriority.Low); + const highPriorityIndexes = indexes.filter(i => this.viewItems[i].priority === LayoutPriority.High); + + this.relayout(lowPriorityIndexes, highPriorityIndexes); + } + + /** + * Returns the size of a {@link IView view}. + */ + getViewSize(index: number): number { + if (index < 0 || index >= this.viewItems.length) { + return -1; + } + + return this.viewItems[index].size; + } + + private doAddView(view: TView, size: number | Sizing, index = this.viewItems.length, skipLayout?: boolean): void { + if (this.state !== State.Idle) { + throw new Error('Cant modify splitview'); + } + + this.state = State.Busy; + + try { + // Add view + const container = $('.split-view-view'); + + if (index === this.viewItems.length) { + this.viewContainer.appendChild(container); + } else { + this.viewContainer.insertBefore(container, this.viewContainer.children.item(index)); + } + + const onChangeDisposable = view.onDidChange(size => this.onViewChange(item, size)); + const containerDisposable = toDisposable(() => container.remove()); + const disposable = combinedDisposable(onChangeDisposable, containerDisposable); + + let viewSize: ViewItemSize; + + if (typeof size === 'number') { + viewSize = size; + } else { + if (size.type === 'auto') { + if (this.areViewsDistributed()) { + size = { type: 'distribute' }; + } else { + size = { type: 'split', index: size.index }; + } + } + + if (size.type === 'split') { + viewSize = this.getViewSize(size.index) / 2; + } else if (size.type === 'invisible') { + viewSize = { cachedVisibleSize: size.cachedVisibleSize }; + } else { + viewSize = view.minimumSize; + } + } + + const item = this.orientation === Orientation.VERTICAL + ? new VerticalViewItem(container, view, viewSize, disposable) + : new HorizontalViewItem(container, view, viewSize, disposable); + + this.viewItems.splice(index, 0, item); + + // Add sash + if (this.viewItems.length > 1) { + const opts = { orthogonalStartSash: this.orthogonalStartSash, orthogonalEndSash: this.orthogonalEndSash }; + + const sash = this.orientation === Orientation.VERTICAL + ? new Sash(this.sashContainer, { getHorizontalSashTop: s => this.getSashPosition(s), getHorizontalSashWidth: this.getSashOrthogonalSize }, { ...opts, orientation: Orientation.HORIZONTAL }) + : new Sash(this.sashContainer, { getVerticalSashLeft: s => this.getSashPosition(s), getVerticalSashHeight: this.getSashOrthogonalSize }, { ...opts, orientation: Orientation.VERTICAL }); + + const sashEventMapper = this.orientation === Orientation.VERTICAL + ? (e: IBaseSashEvent) => ({ sash, start: e.startY, current: e.currentY, alt: e.altKey }) + : (e: IBaseSashEvent) => ({ sash, start: e.startX, current: e.currentX, alt: e.altKey }); + + const onStart = Event.map(sash.onDidStart, sashEventMapper); + const onStartDisposable = onStart(this.onSashStart, this); + const onChange = Event.map(sash.onDidChange, sashEventMapper); + const onChangeDisposable = onChange(this.onSashChange, this); + const onEnd = Event.map(sash.onDidEnd, () => this.sashItems.findIndex(item => item.sash === sash)); + const onEndDisposable = onEnd(this.onSashEnd, this); + + const onDidResetDisposable = sash.onDidReset(() => { + const index = this.sashItems.findIndex(item => item.sash === sash); + const upIndexes = range(index, -1); + const downIndexes = range(index + 1, this.viewItems.length); + const snapBeforeIndex = this.findFirstSnapIndex(upIndexes); + const snapAfterIndex = this.findFirstSnapIndex(downIndexes); + + if (typeof snapBeforeIndex === 'number' && !this.viewItems[snapBeforeIndex].visible) { + return; + } + + if (typeof snapAfterIndex === 'number' && !this.viewItems[snapAfterIndex].visible) { + return; + } + + this._onDidSashReset.fire(index); + }); + + const disposable = combinedDisposable(onStartDisposable, onChangeDisposable, onEndDisposable, onDidResetDisposable, sash); + const sashItem: ISashItem = { sash, disposable }; + + this.sashItems.splice(index - 1, 0, sashItem); + } + + container.appendChild(view.element); + + let highPriorityIndexes: number[] | undefined; + + if (typeof size !== 'number' && size.type === 'split') { + highPriorityIndexes = [size.index]; + } + + if (!skipLayout) { + this.relayout([index], highPriorityIndexes); + } + + + if (!skipLayout && typeof size !== 'number' && size.type === 'distribute') { + this.distributeViewSizes(); + } + + } finally { + this.state = State.Idle; + } + } + + private relayout(lowPriorityIndexes?: number[], highPriorityIndexes?: number[]): void { + const contentSize = this.viewItems.reduce((r, i) => r + i.size, 0); + + this.resize(this.viewItems.length - 1, this.size - contentSize, undefined, lowPriorityIndexes, highPriorityIndexes); + this.distributeEmptySpace(); + this.layoutViews(); + this.saveProportions(); + } + + private resize( + index: number, + delta: number, + sizes = this.viewItems.map(i => i.size), + lowPriorityIndexes?: number[], + highPriorityIndexes?: number[], + overloadMinDelta: number = Number.NEGATIVE_INFINITY, + overloadMaxDelta: number = Number.POSITIVE_INFINITY, + snapBefore?: ISashDragSnapState, + snapAfter?: ISashDragSnapState + ): number { + if (index < 0 || index >= this.viewItems.length) { + return 0; + } + + const upIndexes = range(index, -1); + const downIndexes = range(index + 1, this.viewItems.length); + + if (highPriorityIndexes) { + for (const index of highPriorityIndexes) { + pushToStart(upIndexes, index); + pushToStart(downIndexes, index); + } + } + + if (lowPriorityIndexes) { + for (const index of lowPriorityIndexes) { + pushToEnd(upIndexes, index); + pushToEnd(downIndexes, index); + } + } + + const upItems = upIndexes.map(i => this.viewItems[i]); + const upSizes = upIndexes.map(i => sizes[i]); + + const downItems = downIndexes.map(i => this.viewItems[i]); + const downSizes = downIndexes.map(i => sizes[i]); + + const minDeltaUp = upIndexes.reduce((r, i) => r + (this.viewItems[i].minimumSize - sizes[i]), 0); + const maxDeltaUp = upIndexes.reduce((r, i) => r + (this.viewItems[i].maximumSize - sizes[i]), 0); + const maxDeltaDown = downIndexes.length === 0 ? Number.POSITIVE_INFINITY : downIndexes.reduce((r, i) => r + (sizes[i] - this.viewItems[i].minimumSize), 0); + const minDeltaDown = downIndexes.length === 0 ? Number.NEGATIVE_INFINITY : downIndexes.reduce((r, i) => r + (sizes[i] - this.viewItems[i].maximumSize), 0); + const minDelta = Math.max(minDeltaUp, minDeltaDown, overloadMinDelta); + const maxDelta = Math.min(maxDeltaDown, maxDeltaUp, overloadMaxDelta); + + let snapped = false; + + if (snapBefore) { + const snapView = this.viewItems[snapBefore.index]; + const visible = delta >= snapBefore.limitDelta; + snapped = visible !== snapView.visible; + snapView.setVisible(visible, snapBefore.size); + } + + if (!snapped && snapAfter) { + const snapView = this.viewItems[snapAfter.index]; + const visible = delta < snapAfter.limitDelta; + snapped = visible !== snapView.visible; + snapView.setVisible(visible, snapAfter.size); + } + + if (snapped) { + return this.resize(index, delta, sizes, lowPriorityIndexes, highPriorityIndexes, overloadMinDelta, overloadMaxDelta); + } + + delta = clamp(delta, minDelta, maxDelta); + + for (let i = 0, deltaUp = delta; i < upItems.length; i++) { + const item = upItems[i]; + const size = clamp(upSizes[i] + deltaUp, item.minimumSize, item.maximumSize); + const viewDelta = size - upSizes[i]; + + deltaUp -= viewDelta; + item.size = size; + } + + for (let i = 0, deltaDown = delta; i < downItems.length; i++) { + const item = downItems[i]; + const size = clamp(downSizes[i] - deltaDown, item.minimumSize, item.maximumSize); + const viewDelta = size - downSizes[i]; + + deltaDown += viewDelta; + item.size = size; + } + + return delta; + } + + private distributeEmptySpace(lowPriorityIndex?: number): void { + const contentSize = this.viewItems.reduce((r, i) => r + i.size, 0); + let emptyDelta = this.size - contentSize; + + const indexes = range(this.viewItems.length - 1, -1); + const lowPriorityIndexes = indexes.filter(i => this.viewItems[i].priority === LayoutPriority.Low); + const highPriorityIndexes = indexes.filter(i => this.viewItems[i].priority === LayoutPriority.High); + + for (const index of highPriorityIndexes) { + pushToStart(indexes, index); + } + + for (const index of lowPriorityIndexes) { + pushToEnd(indexes, index); + } + + if (typeof lowPriorityIndex === 'number') { + pushToEnd(indexes, lowPriorityIndex); + } + + for (let i = 0; emptyDelta !== 0 && i < indexes.length; i++) { + const item = this.viewItems[indexes[i]]; + const size = clamp(item.size + emptyDelta, item.minimumSize, item.maximumSize); + const viewDelta = size - item.size; + + emptyDelta -= viewDelta; + item.size = size; + } + } + + private layoutViews(): void { + // Save new content size + this._contentSize = this.viewItems.reduce((r, i) => r + i.size, 0); + + // Layout views + let offset = 0; + + for (const viewItem of this.viewItems) { + viewItem.layout(offset, this.layoutContext); + offset += viewItem.size; + } + + // Layout sashes + this.sashItems.forEach(item => item.sash.layout()); + this.updateSashEnablement(); + this.updateScrollableElement(); + } + + private updateScrollableElement(): void { + if (this.orientation === Orientation.VERTICAL) { + this.scrollableElement.setScrollDimensions({ + height: this.size, + scrollHeight: this._contentSize + }); + } else { + this.scrollableElement.setScrollDimensions({ + width: this.size, + scrollWidth: this._contentSize + }); + } + } + + private updateSashEnablement(): void { + let previous = false; + const collapsesDown = this.viewItems.map(i => previous = (i.size - i.minimumSize > 0) || previous); + + previous = false; + const expandsDown = this.viewItems.map(i => previous = (i.maximumSize - i.size > 0) || previous); + + const reverseViews = [...this.viewItems].reverse(); + previous = false; + const collapsesUp = reverseViews.map(i => previous = (i.size - i.minimumSize > 0) || previous).reverse(); + + previous = false; + const expandsUp = reverseViews.map(i => previous = (i.maximumSize - i.size > 0) || previous).reverse(); + + let position = 0; + for (let index = 0; index < this.sashItems.length; index++) { + const { sash } = this.sashItems[index]; + const viewItem = this.viewItems[index]; + position += viewItem.size; + + const min = !(collapsesDown[index] && expandsUp[index + 1]); + const max = !(expandsDown[index] && collapsesUp[index + 1]); + + if (min && max) { + const upIndexes = range(index, -1); + const downIndexes = range(index + 1, this.viewItems.length); + const snapBeforeIndex = this.findFirstSnapIndex(upIndexes); + const snapAfterIndex = this.findFirstSnapIndex(downIndexes); + + const snappedBefore = typeof snapBeforeIndex === 'number' && !this.viewItems[snapBeforeIndex].visible; + const snappedAfter = typeof snapAfterIndex === 'number' && !this.viewItems[snapAfterIndex].visible; + + if (snappedBefore && collapsesUp[index] && (position > 0 || this.startSnappingEnabled)) { + sash.state = SashState.AtMinimum; + } else if (snappedAfter && collapsesDown[index] && (position < this._contentSize || this.endSnappingEnabled)) { + sash.state = SashState.AtMaximum; + } else { + sash.state = SashState.Disabled; + } + } else if (min && !max) { + sash.state = SashState.AtMinimum; + } else if (!min && max) { + sash.state = SashState.AtMaximum; + } else { + sash.state = SashState.Enabled; + } + } + } + + private getSashPosition(sash: Sash): number { + let position = 0; + + for (let i = 0; i < this.sashItems.length; i++) { + position += this.viewItems[i].size; + + if (this.sashItems[i].sash === sash) { + return position; + } + } + + return 0; + } + + private findFirstSnapIndex(indexes: number[]): number | undefined { + // visible views first + for (const index of indexes) { + const viewItem = this.viewItems[index]; + + if (!viewItem.visible) { + continue; + } + + if (viewItem.snap) { + return index; + } + } + + // then, hidden views + for (const index of indexes) { + const viewItem = this.viewItems[index]; + + if (viewItem.visible && viewItem.maximumSize - viewItem.minimumSize > 0) { + return undefined; + } + + if (!viewItem.visible && viewItem.snap) { + return index; + } + } + + return undefined; + } + + private areViewsDistributed() { + let min = undefined, max = undefined; + + for (const view of this.viewItems) { + min = min === undefined ? view.size : Math.min(min, view.size); + max = max === undefined ? view.size : Math.max(max, view.size); + + if (max - min > 2) { + return false; + } + } + + return true; + } + + override dispose(): void { + this.sashDragState?.disposable.dispose(); + + dispose(this.viewItems); + this.viewItems = []; + + this.sashItems.forEach(i => i.disposable.dispose()); + this.sashItems = []; + + super.dispose(); + } +} diff --git a/src/vs/base/browser/ui/widget.ts b/src/vs/base/browser/ui/widget.ts new file mode 100644 index 0000000000..440a9ace25 --- /dev/null +++ b/src/vs/base/browser/ui/widget.ts @@ -0,0 +1,57 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as dom from 'vs/base/browser/dom'; +import { IKeyboardEvent, StandardKeyboardEvent } from 'vs/base/browser/keyboardEvent'; +import { IMouseEvent, StandardMouseEvent } from 'vs/base/browser/mouseEvent'; +import { Gesture } from 'vs/base/browser/touch'; +import { Disposable, IDisposable } from 'vs/base/common/lifecycle'; + +export abstract class Widget extends Disposable { + + protected onclick(domNode: HTMLElement, listener: (e: IMouseEvent) => void): void { + this._register(dom.addDisposableListener(domNode, dom.EventType.CLICK, (e: MouseEvent) => listener(new StandardMouseEvent(dom.getWindow(domNode), e)))); + } + + protected onmousedown(domNode: HTMLElement, listener: (e: IMouseEvent) => void): void { + this._register(dom.addDisposableListener(domNode, dom.EventType.MOUSE_DOWN, (e: MouseEvent) => listener(new StandardMouseEvent(dom.getWindow(domNode), e)))); + } + + protected onmouseover(domNode: HTMLElement, listener: (e: IMouseEvent) => void): void { + this._register(dom.addDisposableListener(domNode, dom.EventType.MOUSE_OVER, (e: MouseEvent) => listener(new StandardMouseEvent(dom.getWindow(domNode), e)))); + } + + protected onmouseleave(domNode: HTMLElement, listener: (e: IMouseEvent) => void): void { + this._register(dom.addDisposableListener(domNode, dom.EventType.MOUSE_LEAVE, (e: MouseEvent) => listener(new StandardMouseEvent(dom.getWindow(domNode), e)))); + } + + protected onkeydown(domNode: HTMLElement, listener: (e: IKeyboardEvent) => void): void { + this._register(dom.addDisposableListener(domNode, dom.EventType.KEY_DOWN, (e: KeyboardEvent) => listener(new StandardKeyboardEvent(e)))); + } + + protected onkeyup(domNode: HTMLElement, listener: (e: IKeyboardEvent) => void): void { + this._register(dom.addDisposableListener(domNode, dom.EventType.KEY_UP, (e: KeyboardEvent) => listener(new StandardKeyboardEvent(e)))); + } + + protected oninput(domNode: HTMLElement, listener: (e: Event) => void): void { + this._register(dom.addDisposableListener(domNode, dom.EventType.INPUT, listener)); + } + + protected onblur(domNode: HTMLElement, listener: (e: Event) => void): void { + this._register(dom.addDisposableListener(domNode, dom.EventType.BLUR, listener)); + } + + protected onfocus(domNode: HTMLElement, listener: (e: Event) => void): void { + this._register(dom.addDisposableListener(domNode, dom.EventType.FOCUS, listener)); + } + + protected onchange(domNode: HTMLElement, listener: (e: Event) => void): void { + this._register(dom.addDisposableListener(domNode, dom.EventType.CHANGE, listener)); + } + + protected ignoreGesture(domNode: HTMLElement): IDisposable { + return Gesture.ignoreTarget(domNode); + } +} diff --git a/src/vs/base/browser/window.ts b/src/vs/base/browser/window.ts new file mode 100644 index 0000000000..3351c701d7 --- /dev/null +++ b/src/vs/base/browser/window.ts @@ -0,0 +1,14 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export type CodeWindow = Window & typeof globalThis & { + readonly vscodeWindowId: number; +}; + +export function ensureCodeWindow(targetWindow: Window, fallbackWindowId: number): asserts targetWindow is CodeWindow { +} + +// eslint-disable-next-line no-restricted-globals +export const mainWindow = window as CodeWindow; diff --git a/src/vs/base/common/actions.ts b/src/vs/base/common/actions.ts new file mode 100644 index 0000000000..a1fd3249a1 --- /dev/null +++ b/src/vs/base/common/actions.ts @@ -0,0 +1,271 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Emitter, Event } from 'vs/base/common/event'; +import { Disposable, IDisposable } from 'vs/base/common/lifecycle'; +import * as nls from 'vs/nls'; + +export interface ITelemetryData { + readonly from?: string; + readonly target?: string; + [key: string]: unknown; +} + +export type WorkbenchActionExecutedClassification = { + id: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The identifier of the action that was run.' }; + from: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The name of the component the action was run from.' }; + detail?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Optional details about how the action was run, e.g which keybinding was used.' }; + owner: 'bpasero'; + comment: 'Provides insight into actions that are executed within the workbench.'; +}; + +export type WorkbenchActionExecutedEvent = { + id: string; + from: string; + detail?: string; +}; + +export interface IAction { + readonly id: string; + label: string; + tooltip: string; + class: string | undefined; + enabled: boolean; + checked?: boolean; + run(...args: unknown[]): unknown; +} + +export interface IActionRunner extends IDisposable { + readonly onDidRun: Event; + readonly onWillRun: Event; + + run(action: IAction, context?: unknown): unknown; +} + +export interface IActionChangeEvent { + readonly label?: string; + readonly tooltip?: string; + readonly class?: string; + readonly enabled?: boolean; + readonly checked?: boolean; +} + +export class Action extends Disposable implements IAction { + + protected _onDidChange = this._register(new Emitter()); + readonly onDidChange = this._onDidChange.event; + + protected readonly _id: string; + protected _label: string; + protected _tooltip: string | undefined; + protected _cssClass: string | undefined; + protected _enabled: boolean = true; + protected _checked?: boolean; + protected readonly _actionCallback?: (event?: unknown) => unknown; + + constructor(id: string, label: string = '', cssClass: string = '', enabled: boolean = true, actionCallback?: (event?: unknown) => unknown) { + super(); + this._id = id; + this._label = label; + this._cssClass = cssClass; + this._enabled = enabled; + this._actionCallback = actionCallback; + } + + get id(): string { + return this._id; + } + + get label(): string { + return this._label; + } + + set label(value: string) { + this._setLabel(value); + } + + private _setLabel(value: string): void { + if (this._label !== value) { + this._label = value; + this._onDidChange.fire({ label: value }); + } + } + + get tooltip(): string { + return this._tooltip || ''; + } + + set tooltip(value: string) { + this._setTooltip(value); + } + + protected _setTooltip(value: string): void { + if (this._tooltip !== value) { + this._tooltip = value; + this._onDidChange.fire({ tooltip: value }); + } + } + + get class(): string | undefined { + return this._cssClass; + } + + set class(value: string | undefined) { + this._setClass(value); + } + + protected _setClass(value: string | undefined): void { + if (this._cssClass !== value) { + this._cssClass = value; + this._onDidChange.fire({ class: value }); + } + } + + get enabled(): boolean { + return this._enabled; + } + + set enabled(value: boolean) { + this._setEnabled(value); + } + + protected _setEnabled(value: boolean): void { + if (this._enabled !== value) { + this._enabled = value; + this._onDidChange.fire({ enabled: value }); + } + } + + get checked(): boolean | undefined { + return this._checked; + } + + set checked(value: boolean | undefined) { + this._setChecked(value); + } + + protected _setChecked(value: boolean | undefined): void { + if (this._checked !== value) { + this._checked = value; + this._onDidChange.fire({ checked: value }); + } + } + + async run(event?: unknown, data?: ITelemetryData): Promise { + if (this._actionCallback) { + await this._actionCallback(event); + } + } +} + +export interface IRunEvent { + readonly action: IAction; + readonly error?: Error; +} + +export class ActionRunner extends Disposable implements IActionRunner { + + private readonly _onWillRun = this._register(new Emitter()); + readonly onWillRun = this._onWillRun.event; + + private readonly _onDidRun = this._register(new Emitter()); + readonly onDidRun = this._onDidRun.event; + + async run(action: IAction, context?: unknown): Promise { + if (!action.enabled) { + return; + } + + this._onWillRun.fire({ action }); + + let error: Error | undefined = undefined; + try { + await this.runAction(action, context); + } catch (e) { + error = e; + } + + this._onDidRun.fire({ action, error }); + } + + protected async runAction(action: IAction, context?: unknown): Promise { + await action.run(context); + } +} + +export class Separator implements IAction { + + /** + * Joins all non-empty lists of actions with separators. + */ + public static join(...actionLists: readonly IAction[][]) { + let out: IAction[] = []; + for (const list of actionLists) { + if (!list.length) { + // skip + } else if (out.length) { + out = [...out, new Separator(), ...list]; + } else { + out = list; + } + } + + return out; + } + + static readonly ID = 'vs.actions.separator'; + + readonly id: string = Separator.ID; + + readonly label: string = ''; + readonly tooltip: string = ''; + readonly class: string = 'separator'; + readonly enabled: boolean = false; + readonly checked: boolean = false; + async run() { } +} + +export class SubmenuAction implements IAction { + + readonly id: string; + readonly label: string; + readonly class: string | undefined; + readonly tooltip: string = ''; + readonly enabled: boolean = true; + readonly checked: undefined = undefined; + + private readonly _actions: readonly IAction[]; + get actions(): readonly IAction[] { return this._actions; } + + constructor(id: string, label: string, actions: readonly IAction[], cssClass?: string) { + this.id = id; + this.label = label; + this.class = cssClass; + this._actions = actions; + } + + async run(): Promise { } +} + +export class EmptySubmenuAction extends Action { + + static readonly ID = 'vs.actions.empty'; + + constructor() { + super(EmptySubmenuAction.ID, nls.localize('submenu.empty', '(empty)'), undefined, false); + } +} + +export function toAction(props: { id: string; label: string; tooltip?: string; enabled?: boolean; checked?: boolean; class?: string; run: Function }): IAction { + return { + id: props.id, + label: props.label, + tooltip: props.tooltip ?? props.label, + class: props.class, + enabled: props.enabled ?? true, + checked: props.checked, + run: async (...args: unknown[]) => props.run(...args), + }; +} diff --git a/src/vs/base/common/amd.ts b/src/vs/base/common/amd.ts new file mode 100644 index 0000000000..6d22884033 --- /dev/null +++ b/src/vs/base/common/amd.ts @@ -0,0 +1,163 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// ESM-comment-begin +export const isESM = false; +// ESM-comment-end +// ESM-uncomment-begin +// export const isESM = true; +// ESM-uncomment-end + +export const enum LoaderEventType { + LoaderAvailable = 1, + + BeginLoadingScript = 10, + EndLoadingScriptOK = 11, + EndLoadingScriptError = 12, + + BeginInvokeFactory = 21, + EndInvokeFactory = 22, + + NodeBeginEvaluatingScript = 31, + NodeEndEvaluatingScript = 32, + + NodeBeginNativeRequire = 33, + NodeEndNativeRequire = 34, + + CachedDataFound = 60, + CachedDataMissed = 61, + CachedDataRejected = 62, + CachedDataCreated = 63, +} + +export abstract class LoaderStats { + abstract get amdLoad(): [string, number][]; + abstract get amdInvoke(): [string, number][]; + abstract get nodeRequire(): [string, number][]; + abstract get nodeEval(): [string, number][]; + abstract get nodeRequireTotal(): number; + + static get(): LoaderStats { + const amdLoadScript = new Map(); + const amdInvokeFactory = new Map(); + const nodeRequire = new Map(); + const nodeEval = new Map(); + + function mark(map: Map, stat: LoaderEvent) { + if (map.has(stat.detail)) { + // console.warn('BAD events, DOUBLE start', stat); + // map.delete(stat.detail); + return; + } + map.set(stat.detail, -stat.timestamp); + } + + function diff(map: Map, stat: LoaderEvent) { + const duration = map.get(stat.detail); + if (!duration) { + // console.warn('BAD events, end WITHOUT start', stat); + // map.delete(stat.detail); + return; + } + if (duration >= 0) { + // console.warn('BAD events, DOUBLE end', stat); + // map.delete(stat.detail); + return; + } + map.set(stat.detail, duration + stat.timestamp); + } + + let stats: readonly LoaderEvent[] = []; + if (typeof require === 'function' && typeof require.getStats === 'function') { + stats = require.getStats().slice(0).sort((a, b) => a.timestamp - b.timestamp); + } + + for (const stat of stats) { + switch (stat.type) { + case LoaderEventType.BeginLoadingScript: + mark(amdLoadScript, stat); + break; + case LoaderEventType.EndLoadingScriptOK: + case LoaderEventType.EndLoadingScriptError: + diff(amdLoadScript, stat); + break; + + case LoaderEventType.BeginInvokeFactory: + mark(amdInvokeFactory, stat); + break; + case LoaderEventType.EndInvokeFactory: + diff(amdInvokeFactory, stat); + break; + + case LoaderEventType.NodeBeginNativeRequire: + mark(nodeRequire, stat); + break; + case LoaderEventType.NodeEndNativeRequire: + diff(nodeRequire, stat); + break; + + case LoaderEventType.NodeBeginEvaluatingScript: + mark(nodeEval, stat); + break; + case LoaderEventType.NodeEndEvaluatingScript: + diff(nodeEval, stat); + break; + } + } + + let nodeRequireTotal = 0; + nodeRequire.forEach(value => nodeRequireTotal += value); + + function to2dArray(map: Map): [string, number][] { + const res: [string, number][] = []; + map.forEach((value, index) => res.push([index, value])); + return res; + } + + return { + amdLoad: to2dArray(amdLoadScript), + amdInvoke: to2dArray(amdInvokeFactory), + nodeRequire: to2dArray(nodeRequire), + nodeEval: to2dArray(nodeEval), + nodeRequireTotal + }; + } + + static toMarkdownTable(header: string[], rows: Array>): string { + let result = ''; + + const lengths: number[] = []; + header.forEach((cell, ci) => { + lengths[ci] = cell.length; + }); + rows.forEach(row => { + row.forEach((cell, ci) => { + if (typeof cell === 'undefined') { + cell = row[ci] = '-'; + } + const len = cell.toString().length; + lengths[ci] = Math.max(len, lengths[ci]); + }); + }); + + // header + header.forEach((cell, ci) => { result += `| ${cell + ' '.repeat(lengths[ci] - cell.toString().length)} `; }); + result += '|\n'; + header.forEach((_cell, ci) => { result += `| ${'-'.repeat(lengths[ci])} `; }); + result += '|\n'; + + // cells + rows.forEach(row => { + row.forEach((cell, ci) => { + if (typeof cell !== 'undefined') { + result += `| ${cell + ' '.repeat(lengths[ci] - cell.toString().length)} `; + } + }); + result += '|\n'; + }); + + return result; + } +} diff --git a/src/vs/base/common/arrays.ts b/src/vs/base/common/arrays.ts new file mode 100644 index 0000000000..52e542c0eb --- /dev/null +++ b/src/vs/base/common/arrays.ts @@ -0,0 +1,887 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CancellationToken } from 'vs/base/common/cancellation'; +import { CancellationError } from 'vs/base/common/errors'; +import { ISplice } from 'vs/base/common/sequence'; +import { findFirstIdxMonotonousOrArrLen } from './arraysFind'; + +/** + * Returns the last element of an array. + * @param array The array. + * @param n Which element from the end (default is zero). + */ +export function tail(array: ArrayLike, n: number = 0): T | undefined { + return array[array.length - (1 + n)]; +} + +export function tail2(arr: T[]): [T[], T] { + if (arr.length === 0) { + throw new Error('Invalid tail call'); + } + + return [arr.slice(0, arr.length - 1), arr[arr.length - 1]]; +} + +export function equals(one: ReadonlyArray | undefined, other: ReadonlyArray | undefined, itemEquals: (a: T, b: T) => boolean = (a, b) => a === b): boolean { + if (one === other) { + return true; + } + + if (!one || !other) { + return false; + } + + if (one.length !== other.length) { + return false; + } + + for (let i = 0, len = one.length; i < len; i++) { + if (!itemEquals(one[i], other[i])) { + return false; + } + } + + return true; +} + +/** + * Remove the element at `index` by replacing it with the last element. This is faster than `splice` + * but changes the order of the array + */ +export function removeFastWithoutKeepingOrder(array: T[], index: number) { + const last = array.length - 1; + if (index < last) { + array[index] = array[last]; + } + array.pop(); +} + +/** + * Performs a binary search algorithm over a sorted array. + * + * @param array The array being searched. + * @param key The value we search for. + * @param comparator A function that takes two array elements and returns zero + * if they are equal, a negative number if the first element precedes the + * second one in the sorting order, or a positive number if the second element + * precedes the first one. + * @return See {@link binarySearch2} + */ +export function binarySearch(array: ReadonlyArray, key: T, comparator: (op1: T, op2: T) => number): number { + return binarySearch2(array.length, i => comparator(array[i], key)); +} + +/** + * Performs a binary search algorithm over a sorted collection. Useful for cases + * when we need to perform a binary search over something that isn't actually an + * array, and converting data to an array would defeat the use of binary search + * in the first place. + * + * @param length The collection length. + * @param compareToKey A function that takes an index of an element in the + * collection and returns zero if the value at this index is equal to the + * search key, a negative number if the value precedes the search key in the + * sorting order, or a positive number if the search key precedes the value. + * @return A non-negative index of an element, if found. If not found, the + * result is -(n+1) (or ~n, using bitwise notation), where n is the index + * where the key should be inserted to maintain the sorting order. + */ +export function binarySearch2(length: number, compareToKey: (index: number) => number): number { + let low = 0, + high = length - 1; + + while (low <= high) { + const mid = ((low + high) / 2) | 0; + const comp = compareToKey(mid); + if (comp < 0) { + low = mid + 1; + } else if (comp > 0) { + high = mid - 1; + } else { + return mid; + } + } + return -(low + 1); +} + +type Compare = (a: T, b: T) => number; + + +export function quickSelect(nth: number, data: T[], compare: Compare): T { + + nth = nth | 0; + + if (nth >= data.length) { + throw new TypeError('invalid index'); + } + + const pivotValue = data[Math.floor(data.length * Math.random())]; + const lower: T[] = []; + const higher: T[] = []; + const pivots: T[] = []; + + for (const value of data) { + const val = compare(value, pivotValue); + if (val < 0) { + lower.push(value); + } else if (val > 0) { + higher.push(value); + } else { + pivots.push(value); + } + } + + if (nth < lower.length) { + return quickSelect(nth, lower, compare); + } else if (nth < lower.length + pivots.length) { + return pivots[0]; + } else { + return quickSelect(nth - (lower.length + pivots.length), higher, compare); + } +} + +export function groupBy(data: ReadonlyArray, compare: (a: T, b: T) => number): T[][] { + const result: T[][] = []; + let currentGroup: T[] | undefined = undefined; + for (const element of data.slice(0).sort(compare)) { + if (!currentGroup || compare(currentGroup[0], element) !== 0) { + currentGroup = [element]; + result.push(currentGroup); + } else { + currentGroup.push(element); + } + } + return result; +} + +/** + * Splits the given items into a list of (non-empty) groups. + * `shouldBeGrouped` is used to decide if two consecutive items should be in the same group. + * The order of the items is preserved. + */ +export function* groupAdjacentBy(items: Iterable, shouldBeGrouped: (item1: T, item2: T) => boolean): Iterable { + let currentGroup: T[] | undefined; + let last: T | undefined; + for (const item of items) { + if (last !== undefined && shouldBeGrouped(last, item)) { + currentGroup!.push(item); + } else { + if (currentGroup) { + yield currentGroup; + } + currentGroup = [item]; + } + last = item; + } + if (currentGroup) { + yield currentGroup; + } +} + +export function forEachAdjacent(arr: T[], f: (item1: T | undefined, item2: T | undefined) => void): void { + for (let i = 0; i <= arr.length; i++) { + f(i === 0 ? undefined : arr[i - 1], i === arr.length ? undefined : arr[i]); + } +} + +export function forEachWithNeighbors(arr: T[], f: (before: T | undefined, element: T, after: T | undefined) => void): void { + for (let i = 0; i < arr.length; i++) { + f(i === 0 ? undefined : arr[i - 1], arr[i], i + 1 === arr.length ? undefined : arr[i + 1]); + } +} + +interface IMutableSplice extends ISplice { + readonly toInsert: T[]; + deleteCount: number; +} + +/** + * Diffs two *sorted* arrays and computes the splices which apply the diff. + */ +export function sortedDiff(before: ReadonlyArray, after: ReadonlyArray, compare: (a: T, b: T) => number): ISplice[] { + const result: IMutableSplice[] = []; + + function pushSplice(start: number, deleteCount: number, toInsert: T[]): void { + if (deleteCount === 0 && toInsert.length === 0) { + return; + } + + const latest = result[result.length - 1]; + + if (latest && latest.start + latest.deleteCount === start) { + latest.deleteCount += deleteCount; + latest.toInsert.push(...toInsert); + } else { + result.push({ start, deleteCount, toInsert }); + } + } + + let beforeIdx = 0; + let afterIdx = 0; + + while (true) { + if (beforeIdx === before.length) { + pushSplice(beforeIdx, 0, after.slice(afterIdx)); + break; + } + if (afterIdx === after.length) { + pushSplice(beforeIdx, before.length - beforeIdx, []); + break; + } + + const beforeElement = before[beforeIdx]; + const afterElement = after[afterIdx]; + const n = compare(beforeElement, afterElement); + if (n === 0) { + // equal + beforeIdx += 1; + afterIdx += 1; + } else if (n < 0) { + // beforeElement is smaller -> before element removed + pushSplice(beforeIdx, 1, []); + beforeIdx += 1; + } else if (n > 0) { + // beforeElement is greater -> after element added + pushSplice(beforeIdx, 0, [afterElement]); + afterIdx += 1; + } + } + + return result; +} + +/** + * Takes two *sorted* arrays and computes their delta (removed, added elements). + * Finishes in `Math.min(before.length, after.length)` steps. + */ +export function delta(before: ReadonlyArray, after: ReadonlyArray, compare: (a: T, b: T) => number): { removed: T[]; added: T[] } { + const splices = sortedDiff(before, after, compare); + const removed: T[] = []; + const added: T[] = []; + + for (const splice of splices) { + removed.push(...before.slice(splice.start, splice.start + splice.deleteCount)); + added.push(...splice.toInsert); + } + + return { removed, added }; +} + +/** + * Returns the top N elements from the array. + * + * Faster than sorting the entire array when the array is a lot larger than N. + * + * @param array The unsorted array. + * @param compare A sort function for the elements. + * @param n The number of elements to return. + * @return The first n elements from array when sorted with compare. + */ +export function top(array: ReadonlyArray, compare: (a: T, b: T) => number, n: number): T[] { + if (n === 0) { + return []; + } + const result = array.slice(0, n).sort(compare); + topStep(array, compare, result, n, array.length); + return result; +} + +/** + * Asynchronous variant of `top()` allowing for splitting up work in batches between which the event loop can run. + * + * Returns the top N elements from the array. + * + * Faster than sorting the entire array when the array is a lot larger than N. + * + * @param array The unsorted array. + * @param compare A sort function for the elements. + * @param n The number of elements to return. + * @param batch The number of elements to examine before yielding to the event loop. + * @return The first n elements from array when sorted with compare. + */ +export function topAsync(array: T[], compare: (a: T, b: T) => number, n: number, batch: number, token?: CancellationToken): Promise { + if (n === 0) { + return Promise.resolve([]); + } + + return new Promise((resolve, reject) => { + (async () => { + const o = array.length; + const result = array.slice(0, n).sort(compare); + for (let i = n, m = Math.min(n + batch, o); i < o; i = m, m = Math.min(m + batch, o)) { + if (i > n) { + await new Promise(resolve => setTimeout(resolve)); // any other delay function would starve I/O + } + if (token && token.isCancellationRequested) { + throw new CancellationError(); + } + topStep(array, compare, result, i, m); + } + return result; + })() + .then(resolve, reject); + }); +} + +function topStep(array: ReadonlyArray, compare: (a: T, b: T) => number, result: T[], i: number, m: number): void { + for (const n = result.length; i < m; i++) { + const element = array[i]; + if (compare(element, result[n - 1]) < 0) { + result.pop(); + const j = findFirstIdxMonotonousOrArrLen(result, e => compare(element, e) < 0); + result.splice(j, 0, element); + } + } +} + +/** + * @returns New array with all falsy values removed. The original array IS NOT modified. + */ +export function coalesce(array: ReadonlyArray): T[] { + return array.filter((e): e is T => !!e); +} + +/** + * Remove all falsy values from `array`. The original array IS modified. + */ +export function coalesceInPlace(array: Array): asserts array is Array { + let to = 0; + for (let i = 0; i < array.length; i++) { + if (!!array[i]) { + array[to] = array[i]; + to += 1; + } + } + array.length = to; +} + +/** + * @deprecated Use `Array.copyWithin` instead + */ +export function move(array: any[], from: number, to: number): void { + array.splice(to, 0, array.splice(from, 1)[0]); +} + +/** + * @returns false if the provided object is an array and not empty. + */ +export function isFalsyOrEmpty(obj: any): boolean { + return !Array.isArray(obj) || obj.length === 0; +} + +/** + * @returns True if the provided object is an array and has at least one element. + */ +export function isNonEmptyArray(obj: T[] | undefined | null): obj is T[]; +export function isNonEmptyArray(obj: readonly T[] | undefined | null): obj is readonly T[]; +export function isNonEmptyArray(obj: T[] | readonly T[] | undefined | null): obj is T[] | readonly T[] { + return Array.isArray(obj) && obj.length > 0; +} + +/** + * Removes duplicates from the given array. The optional keyFn allows to specify + * how elements are checked for equality by returning an alternate value for each. + */ +export function distinct(array: ReadonlyArray, keyFn: (value: T) => any = value => value): T[] { + const seen = new Set(); + + return array.filter(element => { + const key = keyFn!(element); + if (seen.has(key)) { + return false; + } + seen.add(key); + return true; + }); +} + +export function uniqueFilter(keyFn: (t: T) => R): (t: T) => boolean { + const seen = new Set(); + + return element => { + const key = keyFn(element); + + if (seen.has(key)) { + return false; + } + + seen.add(key); + return true; + }; +} + +export function firstOrDefault(array: ReadonlyArray, notFoundValue: NotFound): T | NotFound; +export function firstOrDefault(array: ReadonlyArray): T | undefined; +export function firstOrDefault(array: ReadonlyArray, notFoundValue?: NotFound): T | NotFound | undefined { + return array.length > 0 ? array[0] : notFoundValue; +} + +export function lastOrDefault(array: ReadonlyArray, notFoundValue: NotFound): T | NotFound; +export function lastOrDefault(array: ReadonlyArray): T | undefined; +export function lastOrDefault(array: ReadonlyArray, notFoundValue?: NotFound): T | NotFound | undefined { + return array.length > 0 ? array[array.length - 1] : notFoundValue; +} + +export function commonPrefixLength(one: ReadonlyArray, other: ReadonlyArray, equals: (a: T, b: T) => boolean = (a, b) => a === b): number { + let result = 0; + + for (let i = 0, len = Math.min(one.length, other.length); i < len && equals(one[i], other[i]); i++) { + result++; + } + + return result; +} + +export function range(to: number): number[]; +export function range(from: number, to: number): number[]; +export function range(arg: number, to?: number): number[] { + let from = typeof to === 'number' ? arg : 0; + + if (typeof to === 'number') { + from = arg; + } else { + from = 0; + to = arg; + } + + const result: number[] = []; + + if (from <= to) { + for (let i = from; i < to; i++) { + result.push(i); + } + } else { + for (let i = from; i > to; i--) { + result.push(i); + } + } + + return result; +} + +export function index(array: ReadonlyArray, indexer: (t: T) => string): { [key: string]: T }; +export function index(array: ReadonlyArray, indexer: (t: T) => string, mapper: (t: T) => R): { [key: string]: R }; +export function index(array: ReadonlyArray, indexer: (t: T) => string, mapper?: (t: T) => R): { [key: string]: R } { + return array.reduce((r, t) => { + r[indexer(t)] = mapper ? mapper(t) : t; + return r; + }, Object.create(null)); +} + +/** + * Inserts an element into an array. Returns a function which, when + * called, will remove that element from the array. + * + * @deprecated In almost all cases, use a `Set` instead. + */ +export function insert(array: T[], element: T): () => void { + array.push(element); + + return () => remove(array, element); +} + +/** + * Removes an element from an array if it can be found. + * + * @deprecated In almost all cases, use a `Set` instead. + */ +export function remove(array: T[], element: T): T | undefined { + const index = array.indexOf(element); + if (index > -1) { + array.splice(index, 1); + + return element; + } + + return undefined; +} + +/** + * Insert `insertArr` inside `target` at `insertIndex`. + * Please don't touch unless you understand https://jsperf.com/inserting-an-array-within-an-array + */ +export function arrayInsert(target: T[], insertIndex: number, insertArr: T[]): T[] { + const before = target.slice(0, insertIndex); + const after = target.slice(insertIndex); + return before.concat(insertArr, after); +} + +/** + * Uses Fisher-Yates shuffle to shuffle the given array + */ +export function shuffle(array: T[], _seed?: number): void { + let rand: () => number; + + if (typeof _seed === 'number') { + let seed = _seed; + // Seeded random number generator in JS. Modified from: + // https://stackoverflow.com/questions/521295/seeding-the-random-number-generator-in-javascript + rand = () => { + const x = Math.sin(seed++) * 179426549; // throw away most significant digits and reduce any potential bias + return x - Math.floor(x); + }; + } else { + rand = Math.random; + } + + for (let i = array.length - 1; i > 0; i -= 1) { + const j = Math.floor(rand() * (i + 1)); + const temp = array[i]; + array[i] = array[j]; + array[j] = temp; + } +} + +/** + * Pushes an element to the start of the array, if found. + */ +export function pushToStart(arr: T[], value: T): void { + const index = arr.indexOf(value); + + if (index > -1) { + arr.splice(index, 1); + arr.unshift(value); + } +} + +/** + * Pushes an element to the end of the array, if found. + */ +export function pushToEnd(arr: T[], value: T): void { + const index = arr.indexOf(value); + + if (index > -1) { + arr.splice(index, 1); + arr.push(value); + } +} + +export function pushMany(arr: T[], items: ReadonlyArray): void { + for (const item of items) { + arr.push(item); + } +} + +export function mapArrayOrNot(items: T | T[], fn: (_: T) => U): U | U[] { + return Array.isArray(items) ? + items.map(fn) : + fn(items); +} + +export function asArray(x: T | T[]): T[]; +export function asArray(x: T | readonly T[]): readonly T[]; +export function asArray(x: T | T[]): T[] { + return Array.isArray(x) ? x : [x]; +} + +export function getRandomElement(arr: T[]): T | undefined { + return arr[Math.floor(Math.random() * arr.length)]; +} + +/** + * Insert the new items in the array. + * @param array The original array. + * @param start The zero-based location in the array from which to start inserting elements. + * @param newItems The items to be inserted + */ +export function insertInto(array: T[], start: number, newItems: T[]): void { + const startIdx = getActualStartIndex(array, start); + const originalLength = array.length; + const newItemsLength = newItems.length; + array.length = originalLength + newItemsLength; + // Move the items after the start index, start from the end so that we don't overwrite any value. + for (let i = originalLength - 1; i >= startIdx; i--) { + array[i + newItemsLength] = array[i]; + } + + for (let i = 0; i < newItemsLength; i++) { + array[i + startIdx] = newItems[i]; + } +} + +/** + * Removes elements from an array and inserts new elements in their place, returning the deleted elements. Alternative to the native Array.splice method, it + * can only support limited number of items due to the maximum call stack size limit. + * @param array The original array. + * @param start The zero-based location in the array from which to start removing elements. + * @param deleteCount The number of elements to remove. + * @returns An array containing the elements that were deleted. + */ +export function splice(array: T[], start: number, deleteCount: number, newItems: T[]): T[] { + const index = getActualStartIndex(array, start); + let result = array.splice(index, deleteCount); + if (result === undefined) { + // see https://bugs.webkit.org/show_bug.cgi?id=261140 + result = []; + } + insertInto(array, index, newItems); + return result; +} + +/** + * Determine the actual start index (same logic as the native splice() or slice()) + * If greater than the length of the array, start will be set to the length of the array. In this case, no element will be deleted but the method will behave as an adding function, adding as many element as item[n*] provided. + * If negative, it will begin that many elements from the end of the array. (In this case, the origin -1, meaning -n is the index of the nth last element, and is therefore equivalent to the index of array.length - n.) If array.length + start is less than 0, it will begin from index 0. + * @param array The target array. + * @param start The operation index. + */ +function getActualStartIndex(array: T[], start: number): number { + return start < 0 ? Math.max(start + array.length, 0) : Math.min(start, array.length); +} + +/** + * When comparing two values, + * a negative number indicates that the first value is less than the second, + * a positive number indicates that the first value is greater than the second, + * and zero indicates that neither is the case. +*/ +export type CompareResult = number; + +export namespace CompareResult { + export function isLessThan(result: CompareResult): boolean { + return result < 0; + } + + export function isLessThanOrEqual(result: CompareResult): boolean { + return result <= 0; + } + + export function isGreaterThan(result: CompareResult): boolean { + return result > 0; + } + + export function isNeitherLessOrGreaterThan(result: CompareResult): boolean { + return result === 0; + } + + export const greaterThan = 1; + export const lessThan = -1; + export const neitherLessOrGreaterThan = 0; +} + +/** + * A comparator `c` defines a total order `<=` on `T` as following: + * `c(a, b) <= 0` iff `a` <= `b`. + * We also have `c(a, b) == 0` iff `c(b, a) == 0`. +*/ +export type Comparator = (a: T, b: T) => CompareResult; + +export function compareBy(selector: (item: TItem) => TCompareBy, comparator: Comparator): Comparator { + return (a, b) => comparator(selector(a), selector(b)); +} + +export function tieBreakComparators(...comparators: Comparator[]): Comparator { + return (item1, item2) => { + for (const comparator of comparators) { + const result = comparator(item1, item2); + if (!CompareResult.isNeitherLessOrGreaterThan(result)) { + return result; + } + } + return CompareResult.neitherLessOrGreaterThan; + }; +} + +/** + * The natural order on numbers. +*/ +export const numberComparator: Comparator = (a, b) => a - b; + +export const booleanComparator: Comparator = (a, b) => numberComparator(a ? 1 : 0, b ? 1 : 0); + +export function reverseOrder(comparator: Comparator): Comparator { + return (a, b) => -comparator(a, b); +} + +export class ArrayQueue { + private firstIdx = 0; + private lastIdx = this.items.length - 1; + + /** + * Constructs a queue that is backed by the given array. Runtime is O(1). + */ + constructor(private readonly items: readonly T[]) { } + + get length(): number { + return this.lastIdx - this.firstIdx + 1; + } + + /** + * Consumes elements from the beginning of the queue as long as the predicate returns true. + * If no elements were consumed, `null` is returned. Has a runtime of O(result.length). + */ + takeWhile(predicate: (value: T) => boolean): T[] | null { + // P(k) := k <= this.lastIdx && predicate(this.items[k]) + // Find s := min { k | k >= this.firstIdx && !P(k) } and return this.data[this.firstIdx...s) + + let startIdx = this.firstIdx; + while (startIdx < this.items.length && predicate(this.items[startIdx])) { + startIdx++; + } + const result = startIdx === this.firstIdx ? null : this.items.slice(this.firstIdx, startIdx); + this.firstIdx = startIdx; + return result; + } + + /** + * Consumes elements from the end of the queue as long as the predicate returns true. + * If no elements were consumed, `null` is returned. + * The result has the same order as the underlying array! + */ + takeFromEndWhile(predicate: (value: T) => boolean): T[] | null { + // P(k) := this.firstIdx >= k && predicate(this.items[k]) + // Find s := max { k | k <= this.lastIdx && !P(k) } and return this.data(s...this.lastIdx] + + let endIdx = this.lastIdx; + while (endIdx >= 0 && predicate(this.items[endIdx])) { + endIdx--; + } + const result = endIdx === this.lastIdx ? null : this.items.slice(endIdx + 1, this.lastIdx + 1); + this.lastIdx = endIdx; + return result; + } + + peek(): T | undefined { + if (this.length === 0) { + return undefined; + } + return this.items[this.firstIdx]; + } + + peekLast(): T | undefined { + if (this.length === 0) { + return undefined; + } + return this.items[this.lastIdx]; + } + + dequeue(): T | undefined { + const result = this.items[this.firstIdx]; + this.firstIdx++; + return result; + } + + removeLast(): T | undefined { + const result = this.items[this.lastIdx]; + this.lastIdx--; + return result; + } + + takeCount(count: number): T[] { + const result = this.items.slice(this.firstIdx, this.firstIdx + count); + this.firstIdx += count; + return result; + } +} + +/** + * This class is faster than an iterator and array for lazy computed data. +*/ +export class CallbackIterable { + public static readonly empty = new CallbackIterable(_callback => { }); + + constructor( + /** + * Calls the callback for every item. + * Stops when the callback returns false. + */ + public readonly iterate: (callback: (item: T) => boolean) => void + ) { + } + + forEach(handler: (item: T) => void) { + this.iterate(item => { handler(item); return true; }); + } + + toArray(): T[] { + const result: T[] = []; + this.iterate(item => { result.push(item); return true; }); + return result; + } + + filter(predicate: (item: T) => boolean): CallbackIterable { + return new CallbackIterable(cb => this.iterate(item => predicate(item) ? cb(item) : true)); + } + + map(mapFn: (item: T) => TResult): CallbackIterable { + return new CallbackIterable(cb => this.iterate(item => cb(mapFn(item)))); + } + + some(predicate: (item: T) => boolean): boolean { + let result = false; + this.iterate(item => { result = predicate(item); return !result; }); + return result; + } + + findFirst(predicate: (item: T) => boolean): T | undefined { + let result: T | undefined; + this.iterate(item => { + if (predicate(item)) { + result = item; + return false; + } + return true; + }); + return result; + } + + findLast(predicate: (item: T) => boolean): T | undefined { + let result: T | undefined; + this.iterate(item => { + if (predicate(item)) { + result = item; + } + return true; + }); + return result; + } + + findLastMaxBy(comparator: Comparator): T | undefined { + let result: T | undefined; + let first = true; + this.iterate(item => { + if (first || CompareResult.isGreaterThan(comparator(item, result!))) { + first = false; + result = item; + } + return true; + }); + return result; + } +} + +/** + * Represents a re-arrangement of items in an array. + */ +export class Permutation { + constructor(private readonly _indexMap: readonly number[]) { } + + /** + * Returns a permutation that sorts the given array according to the given compare function. + */ + public static createSortPermutation(arr: readonly T[], compareFn: (a: T, b: T) => number): Permutation { + const sortIndices = Array.from(arr.keys()).sort((index1, index2) => compareFn(arr[index1], arr[index2])); + return new Permutation(sortIndices); + } + + /** + * Returns a new array with the elements of the given array re-arranged according to this permutation. + */ + apply(arr: readonly T[]): T[] { + return arr.map((_, index) => arr[this._indexMap[index]]); + } + + /** + * Returns a new permutation that undoes the re-arrangement of this permutation. + */ + inverse(): Permutation { + const inverseIndexMap = this._indexMap.slice(); + for (let i = 0; i < this._indexMap.length; i++) { + inverseIndexMap[this._indexMap[i]] = i; + } + return new Permutation(inverseIndexMap); + } +} diff --git a/src/vs/base/common/arraysFind.ts b/src/vs/base/common/arraysFind.ts new file mode 100644 index 0000000000..1dd102e9e7 --- /dev/null +++ b/src/vs/base/common/arraysFind.ts @@ -0,0 +1,202 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Comparator } from './arrays'; + +export function findLast(array: readonly T[], predicate: (item: T) => boolean): T | undefined { + const idx = findLastIdx(array, predicate); + if (idx === -1) { + return undefined; + } + return array[idx]; +} + +export function findLastIdx(array: readonly T[], predicate: (item: T) => boolean, fromIndex = array.length - 1): number { + for (let i = fromIndex; i >= 0; i--) { + const element = array[i]; + + if (predicate(element)) { + return i; + } + } + + return -1; +} + +/** + * Finds the last item where predicate is true using binary search. + * `predicate` must be monotonous, i.e. `arr.map(predicate)` must be like `[true, ..., true, false, ..., false]`! + * + * @returns `undefined` if no item matches, otherwise the last item that matches the predicate. + */ +export function findLastMonotonous(array: readonly T[], predicate: (item: T) => boolean): T | undefined { + const idx = findLastIdxMonotonous(array, predicate); + return idx === -1 ? undefined : array[idx]; +} + +/** + * Finds the last item where predicate is true using binary search. + * `predicate` must be monotonous, i.e. `arr.map(predicate)` must be like `[true, ..., true, false, ..., false]`! + * + * @returns `startIdx - 1` if predicate is false for all items, otherwise the index of the last item that matches the predicate. + */ +export function findLastIdxMonotonous(array: readonly T[], predicate: (item: T) => boolean, startIdx = 0, endIdxEx = array.length): number { + let i = startIdx; + let j = endIdxEx; + while (i < j) { + const k = Math.floor((i + j) / 2); + if (predicate(array[k])) { + i = k + 1; + } else { + j = k; + } + } + return i - 1; +} + +/** + * Finds the first item where predicate is true using binary search. + * `predicate` must be monotonous, i.e. `arr.map(predicate)` must be like `[false, ..., false, true, ..., true]`! + * + * @returns `undefined` if no item matches, otherwise the first item that matches the predicate. + */ +export function findFirstMonotonous(array: readonly T[], predicate: (item: T) => boolean): T | undefined { + const idx = findFirstIdxMonotonousOrArrLen(array, predicate); + return idx === array.length ? undefined : array[idx]; +} + +/** + * Finds the first item where predicate is true using binary search. + * `predicate` must be monotonous, i.e. `arr.map(predicate)` must be like `[false, ..., false, true, ..., true]`! + * + * @returns `endIdxEx` if predicate is false for all items, otherwise the index of the first item that matches the predicate. + */ +export function findFirstIdxMonotonousOrArrLen(array: readonly T[], predicate: (item: T) => boolean, startIdx = 0, endIdxEx = array.length): number { + let i = startIdx; + let j = endIdxEx; + while (i < j) { + const k = Math.floor((i + j) / 2); + if (predicate(array[k])) { + j = k; + } else { + i = k + 1; + } + } + return i; +} + +export function findFirstIdxMonotonous(array: readonly T[], predicate: (item: T) => boolean, startIdx = 0, endIdxEx = array.length): number { + const idx = findFirstIdxMonotonousOrArrLen(array, predicate, startIdx, endIdxEx); + return idx === array.length ? -1 : idx; +} + +/** + * Use this when + * * You have a sorted array + * * You query this array with a monotonous predicate to find the last item that has a certain property. + * * You query this array multiple times with monotonous predicates that get weaker and weaker. + */ +export class MonotonousArray { + public static assertInvariants = false; + + private _findLastMonotonousLastIdx = 0; + private _prevFindLastPredicate: ((item: T) => boolean) | undefined; + + constructor(private readonly _array: readonly T[]) { + } + + /** + * The predicate must be monotonous, i.e. `arr.map(predicate)` must be like `[true, ..., true, false, ..., false]`! + * For subsequent calls, current predicate must be weaker than (or equal to) the previous predicate, i.e. more entries must be `true`. + */ + findLastMonotonous(predicate: (item: T) => boolean): T | undefined { + if (MonotonousArray.assertInvariants) { + if (this._prevFindLastPredicate) { + for (const item of this._array) { + if (this._prevFindLastPredicate(item) && !predicate(item)) { + throw new Error('MonotonousArray: current predicate must be weaker than (or equal to) the previous predicate.'); + } + } + } + this._prevFindLastPredicate = predicate; + } + + const idx = findLastIdxMonotonous(this._array, predicate, this._findLastMonotonousLastIdx); + this._findLastMonotonousLastIdx = idx + 1; + return idx === -1 ? undefined : this._array[idx]; + } +} + +/** + * Returns the first item that is equal to or greater than every other item. +*/ +export function findFirstMax(array: readonly T[], comparator: Comparator): T | undefined { + if (array.length === 0) { + return undefined; + } + + let max = array[0]; + for (let i = 1; i < array.length; i++) { + const item = array[i]; + if (comparator(item, max) > 0) { + max = item; + } + } + return max; +} + +/** + * Returns the last item that is equal to or greater than every other item. +*/ +export function findLastMax(array: readonly T[], comparator: Comparator): T | undefined { + if (array.length === 0) { + return undefined; + } + + let max = array[0]; + for (let i = 1; i < array.length; i++) { + const item = array[i]; + if (comparator(item, max) >= 0) { + max = item; + } + } + return max; +} + +/** + * Returns the first item that is equal to or less than every other item. +*/ +export function findFirstMin(array: readonly T[], comparator: Comparator): T | undefined { + return findFirstMax(array, (a, b) => -comparator(a, b)); +} + +export function findMaxIdx(array: readonly T[], comparator: Comparator): number { + if (array.length === 0) { + return -1; + } + + let maxIdx = 0; + for (let i = 1; i < array.length; i++) { + const item = array[i]; + if (comparator(item, array[maxIdx]) > 0) { + maxIdx = i; + } + } + return maxIdx; +} + +/** + * Returns the first mapped value of the array which is not undefined. + */ +export function mapFindFirst(items: Iterable, mapFn: (value: T) => R | undefined): R | undefined { + for (const value of items) { + const mapped = mapFn(value); + if (mapped !== undefined) { + return mapped; + } + } + + return undefined; +} diff --git a/src/vs/base/common/assert.ts b/src/vs/base/common/assert.ts new file mode 100644 index 0000000000..bbd344d55c --- /dev/null +++ b/src/vs/base/common/assert.ts @@ -0,0 +1,71 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { BugIndicatingError, onUnexpectedError } from 'vs/base/common/errors'; + +/** + * Throws an error with the provided message if the provided value does not evaluate to a true Javascript value. + * + * @deprecated Use `assert(...)` instead. + * This method is usually used like this: + * ```ts + * import * as assert from 'vs/base/common/assert'; + * assert.ok(...); + * ``` + * + * However, `assert` in that example is a user chosen name. + * There is no tooling for generating such an import statement. + * Thus, the `assert(...)` function should be used instead. + */ +export function ok(value?: unknown, message?: string) { + if (!value) { + throw new Error(message ? `Assertion failed (${message})` : 'Assertion Failed'); + } +} + +export function assertNever(value: never, message = 'Unreachable'): never { + throw new Error(message); +} + +export function assert(condition: boolean, message = 'unexpected state'): asserts condition { + if (!condition) { + throw new BugIndicatingError(`Assertion Failed: ${message}`); + } +} + +/** + * Like assert, but doesn't throw. + */ +export function softAssert(condition: boolean): void { + if (!condition) { + onUnexpectedError(new BugIndicatingError('Soft Assertion Failed')); + } +} + +/** + * condition must be side-effect free! + */ +export function assertFn(condition: () => boolean): void { + if (!condition()) { + // eslint-disable-next-line no-debugger + debugger; + // Reevaluate `condition` again to make debugging easier + condition(); + onUnexpectedError(new BugIndicatingError('Assertion Failed')); + } +} + +export function checkAdjacentItems(items: readonly T[], predicate: (item1: T, item2: T) => boolean): boolean { + let i = 0; + while (i < items.length - 1) { + const a = items[i]; + const b = items[i + 1]; + if (!predicate(a, b)) { + return false; + } + i++; + } + return true; +} diff --git a/src/vs/base/common/async.ts b/src/vs/base/common/async.ts new file mode 100644 index 0000000000..20f15f3254 --- /dev/null +++ b/src/vs/base/common/async.ts @@ -0,0 +1,1992 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CancellationToken, CancellationTokenSource } from 'vs/base/common/cancellation'; +import { BugIndicatingError, CancellationError } from 'vs/base/common/errors'; +import { Emitter, Event } from 'vs/base/common/event'; +import { Disposable, DisposableStore, IDisposable, MutableDisposable, toDisposable } from 'vs/base/common/lifecycle'; +import { setTimeout0 } from 'vs/base/common/platform'; +import { MicrotaskDelay } from './symbols'; +import { Lazy } from 'vs/base/common/lazy'; + +export function isThenable(obj: unknown): obj is Promise { + return !!obj && typeof (obj as unknown as Promise).then === 'function'; +} + +export interface CancelablePromise extends Promise { + cancel(): void; +} + +export function createCancelablePromise(callback: (token: CancellationToken) => Promise): CancelablePromise { + const source = new CancellationTokenSource(); + + const thenable = callback(source.token); + const promise = new Promise((resolve, reject) => { + const subscription = source.token.onCancellationRequested(() => { + subscription.dispose(); + reject(new CancellationError()); + }); + Promise.resolve(thenable).then(value => { + subscription.dispose(); + source.dispose(); + resolve(value); + }, err => { + subscription.dispose(); + source.dispose(); + reject(err); + }); + }); + + return >new class { + cancel() { + source.cancel(); + source.dispose(); + } + then(resolve?: ((value: T) => TResult1 | Promise) | undefined | null, reject?: ((reason: any) => TResult2 | Promise) | undefined | null): Promise { + return promise.then(resolve, reject); + } + catch(reject?: ((reason: any) => TResult | Promise) | undefined | null): Promise { + return this.then(undefined, reject); + } + finally(onfinally?: (() => void) | undefined | null): Promise { + return promise.finally(onfinally); + } + }; +} + +/** + * Returns a promise that resolves with `undefined` as soon as the passed token is cancelled. + * @see {@link raceCancellationError} + */ +export function raceCancellation(promise: Promise, token: CancellationToken): Promise; + +/** + * Returns a promise that resolves with `defaultValue` as soon as the passed token is cancelled. + * @see {@link raceCancellationError} + */ +export function raceCancellation(promise: Promise, token: CancellationToken, defaultValue: T): Promise; + +export function raceCancellation(promise: Promise, token: CancellationToken, defaultValue?: T): Promise { + return new Promise((resolve, reject) => { + const ref = token.onCancellationRequested(() => { + ref.dispose(); + resolve(defaultValue); + }); + promise.then(resolve, reject).finally(() => ref.dispose()); + }); +} + +/** + * Returns a promise that rejects with an {@CancellationError} as soon as the passed token is cancelled. + * @see {@link raceCancellation} + */ +export function raceCancellationError(promise: Promise, token: CancellationToken): Promise { + return new Promise((resolve, reject) => { + const ref = token.onCancellationRequested(() => { + ref.dispose(); + reject(new CancellationError()); + }); + promise.then(resolve, reject).finally(() => ref.dispose()); + }); +} + +/** + * Returns as soon as one of the promises resolves or rejects and cancels remaining promises + */ +export async function raceCancellablePromises(cancellablePromises: CancelablePromise[]): Promise { + let resolvedPromiseIndex = -1; + const promises = cancellablePromises.map((promise, index) => promise.then(result => { resolvedPromiseIndex = index; return result; })); + try { + const result = await Promise.race(promises); + return result; + } finally { + cancellablePromises.forEach((cancellablePromise, index) => { + if (index !== resolvedPromiseIndex) { + cancellablePromise.cancel(); + } + }); + } +} + +export function raceTimeout(promise: Promise, timeout: number, onTimeout?: () => void): Promise { + let promiseResolve: ((value: T | undefined) => void) | undefined = undefined; + + const timer = setTimeout(() => { + promiseResolve?.(undefined); + onTimeout?.(); + }, timeout); + + return Promise.race([ + promise.finally(() => clearTimeout(timer)), + new Promise(resolve => promiseResolve = resolve) + ]); +} + +export function asPromise(callback: () => T | Thenable): Promise { + return new Promise((resolve, reject) => { + const item = callback(); + if (isThenable(item)) { + item.then(resolve, reject); + } else { + resolve(item); + } + }); +} + +/** + * Creates and returns a new promise, plus its `resolve` and `reject` callbacks. + * + * Replace with standardized [`Promise.withResolvers`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/withResolvers) once it is supported + */ +export function promiseWithResolvers(): { promise: Promise; resolve: (value: T | PromiseLike) => void; reject: (err?: any) => void } { + let resolve: (value: T | PromiseLike) => void; + let reject: (reason?: any) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve: resolve!, reject: reject! }; +} + +export interface ITask { + (): T; +} + +/** + * A helper to prevent accumulation of sequential async tasks. + * + * Imagine a mail man with the sole task of delivering letters. As soon as + * a letter submitted for delivery, he drives to the destination, delivers it + * and returns to his base. Imagine that during the trip, N more letters were submitted. + * When the mail man returns, he picks those N letters and delivers them all in a + * single trip. Even though N+1 submissions occurred, only 2 deliveries were made. + * + * The throttler implements this via the queue() method, by providing it a task + * factory. Following the example: + * + * const throttler = new Throttler(); + * const letters = []; + * + * function deliver() { + * const lettersToDeliver = letters; + * letters = []; + * return makeTheTrip(lettersToDeliver); + * } + * + * function onLetterReceived(l) { + * letters.push(l); + * throttler.queue(deliver); + * } + */ +export class Throttler implements IDisposable { + + private activePromise: Promise | null; + private queuedPromise: Promise | null; + private queuedPromiseFactory: ITask> | null; + + private isDisposed = false; + + constructor() { + this.activePromise = null; + this.queuedPromise = null; + this.queuedPromiseFactory = null; + } + + queue(promiseFactory: ITask>): Promise { + if (this.isDisposed) { + return Promise.reject(new Error('Throttler is disposed')); + } + + if (this.activePromise) { + this.queuedPromiseFactory = promiseFactory; + + if (!this.queuedPromise) { + const onComplete = () => { + this.queuedPromise = null; + + if (this.isDisposed) { + return; + } + + const result = this.queue(this.queuedPromiseFactory!); + this.queuedPromiseFactory = null; + + return result; + }; + + this.queuedPromise = new Promise(resolve => { + this.activePromise!.then(onComplete, onComplete).then(resolve); + }); + } + + return new Promise((resolve, reject) => { + this.queuedPromise!.then(resolve, reject); + }); + } + + this.activePromise = promiseFactory(); + + return new Promise((resolve, reject) => { + this.activePromise!.then((result: T) => { + this.activePromise = null; + resolve(result); + }, (err: unknown) => { + this.activePromise = null; + reject(err); + }); + }); + } + + dispose(): void { + this.isDisposed = true; + } +} + +export class Sequencer { + + private current: Promise = Promise.resolve(null); + + queue(promiseTask: ITask>): Promise { + return this.current = this.current.then(() => promiseTask(), () => promiseTask()); + } +} + +export class SequencerByKey { + + private promiseMap = new Map>(); + + queue(key: TKey, promiseTask: ITask>): Promise { + const runningPromise = this.promiseMap.get(key) ?? Promise.resolve(); + const newPromise = runningPromise + .catch(() => { }) + .then(promiseTask) + .finally(() => { + if (this.promiseMap.get(key) === newPromise) { + this.promiseMap.delete(key); + } + }); + this.promiseMap.set(key, newPromise); + return newPromise; + } +} + +interface IScheduledLater extends IDisposable { + isTriggered(): boolean; +} + +const timeoutDeferred = (timeout: number, fn: () => void): IScheduledLater => { + let scheduled = true; + const handle = setTimeout(() => { + scheduled = false; + fn(); + }, timeout); + return { + isTriggered: () => scheduled, + dispose: () => { + clearTimeout(handle); + scheduled = false; + }, + }; +}; + +const microtaskDeferred = (fn: () => void): IScheduledLater => { + let scheduled = true; + queueMicrotask(() => { + if (scheduled) { + scheduled = false; + fn(); + } + }); + + return { + isTriggered: () => scheduled, + dispose: () => { scheduled = false; }, + }; +}; + +/** + * A helper to delay (debounce) execution of a task that is being requested often. + * + * Following the throttler, now imagine the mail man wants to optimize the number of + * trips proactively. The trip itself can be long, so he decides not to make the trip + * as soon as a letter is submitted. Instead he waits a while, in case more + * letters are submitted. After said waiting period, if no letters were submitted, he + * decides to make the trip. Imagine that N more letters were submitted after the first + * one, all within a short period of time between each other. Even though N+1 + * submissions occurred, only 1 delivery was made. + * + * The delayer offers this behavior via the trigger() method, into which both the task + * to be executed and the waiting period (delay) must be passed in as arguments. Following + * the example: + * + * const delayer = new Delayer(WAITING_PERIOD); + * const letters = []; + * + * function letterReceived(l) { + * letters.push(l); + * delayer.trigger(() => { return makeTheTrip(); }); + * } + */ +export class Delayer implements IDisposable { + + private deferred: IScheduledLater | null; + private completionPromise: Promise | null; + private doResolve: ((value?: any | Promise) => void) | null; + private doReject: ((err: any) => void) | null; + private task: ITask> | null; + + constructor(public defaultDelay: number | typeof MicrotaskDelay) { + this.deferred = null; + this.completionPromise = null; + this.doResolve = null; + this.doReject = null; + this.task = null; + } + + trigger(task: ITask>, delay = this.defaultDelay): Promise { + this.task = task; + this.cancelTimeout(); + + if (!this.completionPromise) { + this.completionPromise = new Promise((resolve, reject) => { + this.doResolve = resolve; + this.doReject = reject; + }).then(() => { + this.completionPromise = null; + this.doResolve = null; + if (this.task) { + const task = this.task; + this.task = null; + return task(); + } + return undefined; + }); + } + + const fn = () => { + this.deferred = null; + this.doResolve?.(null); + }; + + this.deferred = delay === MicrotaskDelay ? microtaskDeferred(fn) : timeoutDeferred(delay, fn); + + return this.completionPromise; + } + + isTriggered(): boolean { + return !!this.deferred?.isTriggered(); + } + + cancel(): void { + this.cancelTimeout(); + + if (this.completionPromise) { + this.doReject?.(new CancellationError()); + this.completionPromise = null; + } + } + + private cancelTimeout(): void { + this.deferred?.dispose(); + this.deferred = null; + } + + dispose(): void { + this.cancel(); + } +} + +/** + * A helper to delay execution of a task that is being requested often, while + * preventing accumulation of consecutive executions, while the task runs. + * + * The mail man is clever and waits for a certain amount of time, before going + * out to deliver letters. While the mail man is going out, more letters arrive + * and can only be delivered once he is back. Once he is back the mail man will + * do one more trip to deliver the letters that have accumulated while he was out. + */ +export class ThrottledDelayer { + + private delayer: Delayer>; + private throttler: Throttler; + + constructor(defaultDelay: number) { + this.delayer = new Delayer(defaultDelay); + this.throttler = new Throttler(); + } + + trigger(promiseFactory: ITask>, delay?: number): Promise { + return this.delayer.trigger(() => this.throttler.queue(promiseFactory), delay) as unknown as Promise; + } + + isTriggered(): boolean { + return this.delayer.isTriggered(); + } + + cancel(): void { + this.delayer.cancel(); + } + + dispose(): void { + this.delayer.dispose(); + this.throttler.dispose(); + } +} + +/** + * A barrier that is initially closed and then becomes opened permanently. + */ +export class Barrier { + private _isOpen: boolean; + private _promise: Promise; + private _completePromise!: (v: boolean) => void; + + constructor() { + this._isOpen = false; + this._promise = new Promise((c, e) => { + this._completePromise = c; + }); + } + + isOpen(): boolean { + return this._isOpen; + } + + open(): void { + this._isOpen = true; + this._completePromise(true); + } + + wait(): Promise { + return this._promise; + } +} + +/** + * A barrier that is initially closed and then becomes opened permanently after a certain period of + * time or when open is called explicitly + */ +export class AutoOpenBarrier extends Barrier { + + private readonly _timeout: any; + + constructor(autoOpenTimeMs: number) { + super(); + this._timeout = setTimeout(() => this.open(), autoOpenTimeMs); + } + + override open(): void { + clearTimeout(this._timeout); + super.open(); + } +} + +export function timeout(millis: number): CancelablePromise; +export function timeout(millis: number, token: CancellationToken): Promise; +export function timeout(millis: number, token?: CancellationToken): CancelablePromise | Promise { + if (!token) { + return createCancelablePromise(token => timeout(millis, token)); + } + + return new Promise((resolve, reject) => { + const handle = setTimeout(() => { + disposable.dispose(); + resolve(); + }, millis); + const disposable = token.onCancellationRequested(() => { + clearTimeout(handle); + disposable.dispose(); + reject(new CancellationError()); + }); + }); +} + +/** + * Creates a timeout that can be disposed using its returned value. + * @param handler The timeout handler. + * @param timeout An optional timeout in milliseconds. + * @param store An optional {@link DisposableStore} that will have the timeout disposable managed automatically. + * + * @example + * const store = new DisposableStore; + * // Call the timeout after 1000ms at which point it will be automatically + * // evicted from the store. + * const timeoutDisposable = disposableTimeout(() => {}, 1000, store); + * + * if (foo) { + * // Cancel the timeout and evict it from store. + * timeoutDisposable.dispose(); + * } + */ +export function disposableTimeout(handler: () => void, timeout = 0, store?: DisposableStore): IDisposable { + const timer = setTimeout(() => { + handler(); + if (store) { + disposable.dispose(); + } + }, timeout); + const disposable = toDisposable(() => { + clearTimeout(timer); + store?.deleteAndLeak(disposable); + }); + store?.add(disposable); + return disposable; +} + +/** + * Runs the provided list of promise factories in sequential order. The returned + * promise will complete to an array of results from each promise. + */ + +export function sequence(promiseFactories: ITask>[]): Promise { + const results: T[] = []; + let index = 0; + const len = promiseFactories.length; + + function next(): Promise | null { + return index < len ? promiseFactories[index++]() : null; + } + + function thenHandler(result: any): Promise { + if (result !== undefined && result !== null) { + results.push(result); + } + + const n = next(); + if (n) { + return n.then(thenHandler); + } + + return Promise.resolve(results); + } + + return Promise.resolve(null).then(thenHandler); +} + +export function first(promiseFactories: ITask>[], shouldStop: (t: T) => boolean = t => !!t, defaultValue: T | null = null): Promise { + let index = 0; + const len = promiseFactories.length; + + const loop: () => Promise = () => { + if (index >= len) { + return Promise.resolve(defaultValue); + } + + const factory = promiseFactories[index++]; + const promise = Promise.resolve(factory()); + + return promise.then(result => { + if (shouldStop(result)) { + return Promise.resolve(result); + } + + return loop(); + }); + }; + + return loop(); +} + +/** + * Returns the result of the first promise that matches the "shouldStop", + * running all promises in parallel. Supports cancelable promises. + */ +export function firstParallel(promiseList: Promise[], shouldStop?: (t: T) => boolean, defaultValue?: T | null): Promise; +export function firstParallel(promiseList: Promise[], shouldStop: (t: T) => t is R, defaultValue?: R | null): Promise; +export function firstParallel(promiseList: Promise[], shouldStop: (t: T) => boolean = t => !!t, defaultValue: T | null = null) { + if (promiseList.length === 0) { + return Promise.resolve(defaultValue); + } + + let todo = promiseList.length; + const finish = () => { + todo = -1; + for (const promise of promiseList) { + (promise as Partial>).cancel?.(); + } + }; + + return new Promise((resolve, reject) => { + for (const promise of promiseList) { + promise.then(result => { + if (--todo >= 0 && shouldStop(result)) { + finish(); + resolve(result); + } else if (todo === 0) { + resolve(defaultValue); + } + }) + .catch(err => { + if (--todo >= 0) { + finish(); + reject(err); + } + }); + } + }); +} + +interface ILimitedTaskFactory { + factory: ITask>; + c: (value: T | Promise) => void; + e: (error?: unknown) => void; +} + +export interface ILimiter { + + readonly size: number; + + queue(factory: ITask>): Promise; + + clear(): void; +} + +/** + * A helper to queue N promises and run them all with a max degree of parallelism. The helper + * ensures that at any time no more than M promises are running at the same time. + */ +export class Limiter implements ILimiter { + + private _size = 0; + private _isDisposed = false; + private runningPromises: number; + private readonly maxDegreeOfParalellism: number; + private readonly outstandingPromises: ILimitedTaskFactory[]; + private readonly _onDrained: Emitter; + + constructor(maxDegreeOfParalellism: number) { + this.maxDegreeOfParalellism = maxDegreeOfParalellism; + this.outstandingPromises = []; + this.runningPromises = 0; + this._onDrained = new Emitter(); + } + + /** + * + * @returns A promise that resolved when all work is done (onDrained) or when + * there is nothing to do + */ + whenIdle(): Promise { + return this.size > 0 + ? Event.toPromise(this.onDrained) + : Promise.resolve(); + } + + get onDrained(): Event { + return this._onDrained.event; + } + + get size(): number { + return this._size; + } + + queue(factory: ITask>): Promise { + if (this._isDisposed) { + throw new Error('Object has been disposed'); + } + this._size++; + + return new Promise((c, e) => { + this.outstandingPromises.push({ factory, c, e }); + this.consume(); + }); + } + + private consume(): void { + while (this.outstandingPromises.length && this.runningPromises < this.maxDegreeOfParalellism) { + const iLimitedTask = this.outstandingPromises.shift()!; + this.runningPromises++; + + const promise = iLimitedTask.factory(); + promise.then(iLimitedTask.c, iLimitedTask.e); + promise.then(() => this.consumed(), () => this.consumed()); + } + } + + private consumed(): void { + if (this._isDisposed) { + return; + } + this.runningPromises--; + if (--this._size === 0) { + this._onDrained.fire(); + } + + if (this.outstandingPromises.length > 0) { + this.consume(); + } + } + + clear(): void { + if (this._isDisposed) { + throw new Error('Object has been disposed'); + } + this.outstandingPromises.length = 0; + this._size = this.runningPromises; + } + + dispose(): void { + this._isDisposed = true; + this.outstandingPromises.length = 0; // stop further processing + this._size = 0; + this._onDrained.dispose(); + } +} + +/** + * A queue is handles one promise at a time and guarantees that at any time only one promise is executing. + */ +export class Queue extends Limiter { + + constructor() { + super(1); + } +} + +/** + * Same as `Queue`, ensures that only 1 task is executed at the same time. The difference to `Queue` is that + * there is only 1 task about to be scheduled next. As such, calling `queue` while a task is executing will + * replace the currently queued task until it executes. + * + * As such, the returned promise may not be from the factory that is passed in but from the next factory that + * is running after having called `queue`. + */ +export class LimitedQueue { + + private readonly sequentializer = new TaskSequentializer(); + + private tasks = 0; + + queue(factory: ITask>): Promise { + if (!this.sequentializer.isRunning()) { + return this.sequentializer.run(this.tasks++, factory()); + } + + return this.sequentializer.queue(() => { + return this.sequentializer.run(this.tasks++, factory()); + }); + } +} + +export class TimeoutTimer implements IDisposable { + private _token: any; + private _isDisposed = false; + + constructor(); + constructor(runner: () => void, timeout: number); + constructor(runner?: () => void, timeout?: number) { + this._token = -1; + + if (typeof runner === 'function' && typeof timeout === 'number') { + this.setIfNotSet(runner, timeout); + } + } + + dispose(): void { + this.cancel(); + this._isDisposed = true; + } + + cancel(): void { + if (this._token !== -1) { + clearTimeout(this._token); + this._token = -1; + } + } + + cancelAndSet(runner: () => void, timeout: number): void { + if (this._isDisposed) { + throw new BugIndicatingError(`Calling 'cancelAndSet' on a disposed TimeoutTimer`); + } + + this.cancel(); + this._token = setTimeout(() => { + this._token = -1; + runner(); + }, timeout); + } + + setIfNotSet(runner: () => void, timeout: number): void { + if (this._isDisposed) { + throw new BugIndicatingError(`Calling 'setIfNotSet' on a disposed TimeoutTimer`); + } + + if (this._token !== -1) { + // timer is already set + return; + } + this._token = setTimeout(() => { + this._token = -1; + runner(); + }, timeout); + } +} + +export class IntervalTimer implements IDisposable { + + private disposable: IDisposable | undefined = undefined; + private isDisposed = false; + + cancel(): void { + this.disposable?.dispose(); + this.disposable = undefined; + } + + cancelAndSet(runner: () => void, interval: number, context = globalThis): void { + if (this.isDisposed) { + throw new BugIndicatingError(`Calling 'cancelAndSet' on a disposed IntervalTimer`); + } + + this.cancel(); + const handle = context.setInterval(() => { + runner(); + }, interval); + + this.disposable = toDisposable(() => { + context.clearInterval(handle); + this.disposable = undefined; + }); + } + + dispose(): void { + this.cancel(); + this.isDisposed = true; + } +} + +export class RunOnceScheduler implements IDisposable { + + protected runner: ((...args: unknown[]) => void) | null; + + private timeoutToken: any; + private timeout: number; + private timeoutHandler: () => void; + + constructor(runner: (...args: any[]) => void, delay: number) { + this.timeoutToken = -1; + this.runner = runner; + this.timeout = delay; + this.timeoutHandler = this.onTimeout.bind(this); + } + + /** + * Dispose RunOnceScheduler + */ + dispose(): void { + this.cancel(); + this.runner = null; + } + + /** + * Cancel current scheduled runner (if any). + */ + cancel(): void { + if (this.isScheduled()) { + clearTimeout(this.timeoutToken); + this.timeoutToken = -1; + } + } + + /** + * Cancel previous runner (if any) & schedule a new runner. + */ + schedule(delay = this.timeout): void { + this.cancel(); + this.timeoutToken = setTimeout(this.timeoutHandler, delay); + } + + get delay(): number { + return this.timeout; + } + + set delay(value: number) { + this.timeout = value; + } + + /** + * Returns true if scheduled. + */ + isScheduled(): boolean { + return this.timeoutToken !== -1; + } + + flush(): void { + if (this.isScheduled()) { + this.cancel(); + this.doRun(); + } + } + + private onTimeout() { + this.timeoutToken = -1; + if (this.runner) { + this.doRun(); + } + } + + protected doRun(): void { + this.runner?.(); + } +} + +/** + * Same as `RunOnceScheduler`, but doesn't count the time spent in sleep mode. + * > **NOTE**: Only offers 1s resolution. + * + * When calling `setTimeout` with 3hrs, and putting the computer immediately to sleep + * for 8hrs, `setTimeout` will fire **as soon as the computer wakes from sleep**. But + * this scheduler will execute 3hrs **after waking the computer from sleep**. + */ +export class ProcessTimeRunOnceScheduler { + + private runner: (() => void) | null; + private timeout: number; + + private counter: number; + private intervalToken: any; + private intervalHandler: () => void; + + constructor(runner: () => void, delay: number) { + if (delay % 1000 !== 0) { + console.warn(`ProcessTimeRunOnceScheduler resolution is 1s, ${delay}ms is not a multiple of 1000ms.`); + } + this.runner = runner; + this.timeout = delay; + this.counter = 0; + this.intervalToken = -1; + this.intervalHandler = this.onInterval.bind(this); + } + + dispose(): void { + this.cancel(); + this.runner = null; + } + + cancel(): void { + if (this.isScheduled()) { + clearInterval(this.intervalToken); + this.intervalToken = -1; + } + } + + /** + * Cancel previous runner (if any) & schedule a new runner. + */ + schedule(delay = this.timeout): void { + if (delay % 1000 !== 0) { + console.warn(`ProcessTimeRunOnceScheduler resolution is 1s, ${delay}ms is not a multiple of 1000ms.`); + } + this.cancel(); + this.counter = Math.ceil(delay / 1000); + this.intervalToken = setInterval(this.intervalHandler, 1000); + } + + /** + * Returns true if scheduled. + */ + isScheduled(): boolean { + return this.intervalToken !== -1; + } + + private onInterval() { + this.counter--; + if (this.counter > 0) { + // still need to wait + return; + } + + // time elapsed + clearInterval(this.intervalToken); + this.intervalToken = -1; + this.runner?.(); + } +} + +export class RunOnceWorker extends RunOnceScheduler { + + private units: T[] = []; + + constructor(runner: (units: T[]) => void, timeout: number) { + super(runner, timeout); + } + + work(unit: T): void { + this.units.push(unit); + + if (!this.isScheduled()) { + this.schedule(); + } + } + + protected override doRun(): void { + const units = this.units; + this.units = []; + + this.runner?.(units); + } + + override dispose(): void { + this.units = []; + + super.dispose(); + } +} + +export interface IThrottledWorkerOptions { + + /** + * maximum of units the worker will pass onto handler at once + */ + maxWorkChunkSize: number; + + /** + * maximum of units the worker will keep in memory for processing + */ + maxBufferedWork: number | undefined; + + /** + * delay before processing the next round of chunks when chunk size exceeds limits + */ + throttleDelay: number; +} + +/** + * The `ThrottledWorker` will accept units of work `T` + * to handle. The contract is: + * * there is a maximum of units the worker can handle at once (via `maxWorkChunkSize`) + * * there is a maximum of units the worker will keep in memory for processing (via `maxBufferedWork`) + * * after having handled `maxWorkChunkSize` units, the worker needs to rest (via `throttleDelay`) + */ +export class ThrottledWorker extends Disposable { + + private readonly pendingWork: T[] = []; + + private readonly throttler = this._register(new MutableDisposable()); + private disposed = false; + + constructor( + private options: IThrottledWorkerOptions, + private readonly handler: (units: T[]) => void + ) { + super(); + } + + /** + * The number of work units that are pending to be processed. + */ + get pending(): number { return this.pendingWork.length; } + + /** + * Add units to be worked on. Use `pending` to figure out + * how many units are not yet processed after this method + * was called. + * + * @returns whether the work was accepted or not. If the + * worker is disposed, it will not accept any more work. + * If the number of pending units would become larger + * than `maxPendingWork`, more work will also not be accepted. + */ + work(units: readonly T[]): boolean { + if (this.disposed) { + return false; // work not accepted: disposed + } + + // Check for reaching maximum of pending work + if (typeof this.options.maxBufferedWork === 'number') { + + // Throttled: simple check if pending + units exceeds max pending + if (this.throttler.value) { + if (this.pending + units.length > this.options.maxBufferedWork) { + return false; // work not accepted: too much pending work + } + } + + // Unthrottled: same as throttled, but account for max chunk getting + // worked on directly without being pending + else { + if (this.pending + units.length - this.options.maxWorkChunkSize > this.options.maxBufferedWork) { + return false; // work not accepted: too much pending work + } + } + } + + // Add to pending units first + for (const unit of units) { + this.pendingWork.push(unit); + } + + // If not throttled, start working directly + // Otherwise, when the throttle delay has + // past, pending work will be worked again. + if (!this.throttler.value) { + this.doWork(); + } + + return true; // work accepted + } + + private doWork(): void { + + // Extract chunk to handle and handle it + this.handler(this.pendingWork.splice(0, this.options.maxWorkChunkSize)); + + // If we have remaining work, schedule it after a delay + if (this.pendingWork.length > 0) { + this.throttler.value = new RunOnceScheduler(() => { + this.throttler.clear(); + + this.doWork(); + }, this.options.throttleDelay); + this.throttler.value.schedule(); + } + } + + override dispose(): void { + super.dispose(); + + this.disposed = true; + } +} + +//#region -- run on idle tricks ------------ + +export interface IdleDeadline { + readonly didTimeout: boolean; + timeRemaining(): number; +} + +type IdleApi = Pick; + + +/** + * Execute the callback the next time the browser is idle, returning an + * {@link IDisposable} that will cancel the callback when disposed. This wraps + * [requestIdleCallback] so it will fallback to [setTimeout] if the environment + * doesn't support it. + * + * @param callback The callback to run when idle, this includes an + * [IdleDeadline] that provides the time alloted for the idle callback by the + * browser. Not respecting this deadline will result in a degraded user + * experience. + * @param timeout A timeout at which point to queue no longer wait for an idle + * callback but queue it on the regular event loop (like setTimeout). Typically + * this should not be used. + * + * [IdleDeadline]: https://developer.mozilla.org/en-US/docs/Web/API/IdleDeadline + * [requestIdleCallback]: https://developer.mozilla.org/en-US/docs/Web/API/Window/requestIdleCallback + * [setTimeout]: https://developer.mozilla.org/en-US/docs/Web/API/Window/setTimeout + * + * **Note** that there is `dom.ts#runWhenWindowIdle` which is better suited when running inside a browser + * context + */ +export let runWhenGlobalIdle: (callback: (idle: IdleDeadline) => void, timeout?: number) => IDisposable; + +export let _runWhenIdle: (targetWindow: IdleApi, callback: (idle: IdleDeadline) => void, timeout?: number) => IDisposable; + +(function () { + if (typeof globalThis.requestIdleCallback !== 'function' || typeof globalThis.cancelIdleCallback !== 'function') { + _runWhenIdle = (_targetWindow, runner) => { + setTimeout0(() => { + if (disposed) { + return; + } + const end = Date.now() + 15; // one frame at 64fps + const deadline: IdleDeadline = { + didTimeout: true, + timeRemaining() { + return Math.max(0, end - Date.now()); + } + }; + runner(Object.freeze(deadline)); + }); + let disposed = false; + return { + dispose() { + if (disposed) { + return; + } + disposed = true; + } + }; + }; + } else { + _runWhenIdle = (targetWindow: IdleApi, runner, timeout?) => { + const handle: number = targetWindow.requestIdleCallback(runner, typeof timeout === 'number' ? { timeout } : undefined); + let disposed = false; + return { + dispose() { + if (disposed) { + return; + } + disposed = true; + targetWindow.cancelIdleCallback(handle); + } + }; + }; + } + runWhenGlobalIdle = (runner) => _runWhenIdle(globalThis, runner); +})(); + +export abstract class AbstractIdleValue { + + private readonly _executor: () => void; + private readonly _handle: IDisposable; + + private _didRun: boolean = false; + private _value?: T; + private _error: unknown; + + constructor(targetWindow: IdleApi, executor: () => T) { + this._executor = () => { + try { + this._value = executor(); + } catch (err) { + this._error = err; + } finally { + this._didRun = true; + } + }; + this._handle = _runWhenIdle(targetWindow, () => this._executor()); + } + + dispose(): void { + this._handle.dispose(); + } + + get value(): T { + if (!this._didRun) { + this._handle.dispose(); + this._executor(); + } + if (this._error) { + throw this._error; + } + return this._value!; + } + + get isInitialized(): boolean { + return this._didRun; + } +} + +/** + * An `IdleValue` that always uses the current window (which might be throttled or inactive) + * + * **Note** that there is `dom.ts#WindowIdleValue` which is better suited when running inside a browser + * context + */ +export class GlobalIdleValue extends AbstractIdleValue { + + constructor(executor: () => T) { + super(globalThis, executor); + } +} + +//#endregion + +export async function retry(task: ITask>, delay: number, retries: number): Promise { + let lastError: Error | undefined; + + for (let i = 0; i < retries; i++) { + try { + return await task(); + } catch (error) { + lastError = error; + + await timeout(delay); + } + } + + throw lastError; +} + +//#region Task Sequentializer + +interface IRunningTask { + readonly taskId: number; + readonly cancel: () => void; + readonly promise: Promise; +} + +interface IQueuedTask { + readonly promise: Promise; + readonly promiseResolve: () => void; + readonly promiseReject: (error: Error) => void; + run: ITask>; +} + +export interface ITaskSequentializerWithRunningTask { + readonly running: Promise; +} + +export interface ITaskSequentializerWithQueuedTask { + readonly queued: IQueuedTask; +} + +/** + * @deprecated use `LimitedQueue` instead for an easier to use API + */ +export class TaskSequentializer { + + private _running?: IRunningTask; + private _queued?: IQueuedTask; + + isRunning(taskId?: number): this is ITaskSequentializerWithRunningTask { + if (typeof taskId === 'number') { + return this._running?.taskId === taskId; + } + + return !!this._running; + } + + get running(): Promise | undefined { + return this._running?.promise; + } + + cancelRunning(): void { + this._running?.cancel(); + } + + run(taskId: number, promise: Promise, onCancel?: () => void,): Promise { + this._running = { taskId, cancel: () => onCancel?.(), promise }; + + promise.then(() => this.doneRunning(taskId), () => this.doneRunning(taskId)); + + return promise; + } + + private doneRunning(taskId: number): void { + if (this._running && taskId === this._running.taskId) { + + // only set running to done if the promise finished that is associated with that taskId + this._running = undefined; + + // schedule the queued task now that we are free if we have any + this.runQueued(); + } + } + + private runQueued(): void { + if (this._queued) { + const queued = this._queued; + this._queued = undefined; + + // Run queued task and complete on the associated promise + queued.run().then(queued.promiseResolve, queued.promiseReject); + } + } + + /** + * Note: the promise to schedule as next run MUST itself call `run`. + * Otherwise, this sequentializer will report `false` for `isRunning` + * even when this task is running. Missing this detail means that + * suddenly multiple tasks will run in parallel. + */ + queue(run: ITask>): Promise { + + // this is our first queued task, so we create associated promise with it + // so that we can return a promise that completes when the task has + // completed. + if (!this._queued) { + const { promise, resolve: promiseResolve, reject: promiseReject } = promiseWithResolvers(); + this._queued = { + run, + promise, + promiseResolve: promiseResolve!, + promiseReject: promiseReject! + }; + } + + // we have a previous queued task, just overwrite it + else { + this._queued.run = run; + } + + return this._queued.promise; + } + + hasQueued(): this is ITaskSequentializerWithQueuedTask { + return !!this._queued; + } + + async join(): Promise { + return this._queued?.promise ?? this._running?.promise; + } +} + +//#endregion + +//#region + +/** + * The `IntervalCounter` allows to count the number + * of calls to `increment()` over a duration of + * `interval`. This utility can be used to conditionally + * throttle a frequent task when a certain threshold + * is reached. + */ +export class IntervalCounter { + + private lastIncrementTime = 0; + + private value = 0; + + constructor(private readonly interval: number, private readonly nowFn = () => Date.now()) { } + + increment(): number { + const now = this.nowFn(); + + // We are outside of the range of `interval` and as such + // start counting from 0 and remember the time + if (now - this.lastIncrementTime > this.interval) { + this.lastIncrementTime = now; + this.value = 0; + } + + this.value++; + + return this.value; + } +} + +//#endregion + +//#region + +export type ValueCallback = (value: T | Promise) => void; + +const enum DeferredOutcome { + Resolved, + Rejected +} + +/** + * Creates a promise whose resolution or rejection can be controlled imperatively. + */ +export class DeferredPromise { + + private completeCallback!: ValueCallback; + private errorCallback!: (err: unknown) => void; + private outcome?: { outcome: DeferredOutcome.Rejected; value: any } | { outcome: DeferredOutcome.Resolved; value: T }; + + public get isRejected() { + return this.outcome?.outcome === DeferredOutcome.Rejected; + } + + public get isResolved() { + return this.outcome?.outcome === DeferredOutcome.Resolved; + } + + public get isSettled() { + return !!this.outcome; + } + + public get value() { + return this.outcome?.outcome === DeferredOutcome.Resolved ? this.outcome?.value : undefined; + } + + public readonly p: Promise; + + constructor() { + this.p = new Promise((c, e) => { + this.completeCallback = c; + this.errorCallback = e; + }); + } + + public complete(value: T) { + return new Promise(resolve => { + this.completeCallback(value); + this.outcome = { outcome: DeferredOutcome.Resolved, value }; + resolve(); + }); + } + + public error(err: unknown) { + return new Promise(resolve => { + this.errorCallback(err); + this.outcome = { outcome: DeferredOutcome.Rejected, value: err }; + resolve(); + }); + } + + public cancel() { + return this.error(new CancellationError()); + } +} + +//#endregion + +//#region Promises + +export namespace Promises { + + /** + * A drop-in replacement for `Promise.all` with the only difference + * that the method awaits every promise to either fulfill or reject. + * + * Similar to `Promise.all`, only the first error will be returned + * if any. + */ + export async function settled(promises: Promise[]): Promise { + let firstError: Error | undefined = undefined; + + const result = await Promise.all(promises.map(promise => promise.then(value => value, error => { + if (!firstError) { + firstError = error; + } + + return undefined; // do not rethrow so that other promises can settle + }))); + + if (typeof firstError !== 'undefined') { + throw firstError; + } + + return result as unknown as T[]; // cast is needed and protected by the `throw` above + } + + /** + * A helper to create a new `Promise` with a body that is a promise + * itself. By default, an error that raises from the async body will + * end up as a unhandled rejection, so this utility properly awaits the + * body and rejects the promise as a normal promise does without async + * body. + * + * This method should only be used in rare cases where otherwise `async` + * cannot be used (e.g. when callbacks are involved that require this). + */ + export function withAsyncBody(bodyFn: (resolve: (value: T) => unknown, reject: (error: E) => unknown) => Promise): Promise { + // eslint-disable-next-line no-async-promise-executor + return new Promise(async (resolve, reject) => { + try { + await bodyFn(resolve, reject); + } catch (error) { + reject(error); + } + }); + } +} + +export class StatefulPromise { + private _value: T | undefined = undefined; + get value(): T | undefined { return this._value; } + + private _error: unknown = undefined; + get error(): unknown { return this._error; } + + private _isResolved = false; + get isResolved() { return this._isResolved; } + + public readonly promise: Promise; + + constructor(promise: Promise) { + this.promise = promise.then( + value => { + this._value = value; + this._isResolved = true; + return value; + }, + error => { + this._error = error; + this._isResolved = true; + throw error; + } + ); + } + + /** + * Returns the resolved value. + * Throws if the promise is not resolved yet. + */ + public requireValue(): T { + if (!this._isResolved) { + throw new BugIndicatingError('Promise is not resolved yet'); + } + if (this._error) { + throw this._error; + } + return this._value!; + } +} + +export class LazyStatefulPromise { + private readonly _promise = new Lazy(() => new StatefulPromise(this._compute())); + + constructor( + private readonly _compute: () => Promise, + ) { } + + /** + * Returns the resolved value. + * Throws if the promise is not resolved yet. + */ + public requireValue(): T { + return this._promise.value.requireValue(); + } + + /** + * Returns the promise (and triggers a computation of the promise if not yet done so). + */ + public getPromise(): Promise { + return this._promise.value.promise; + } + + /** + * Reads the current value without triggering a computation of the promise. + */ + public get currentValue(): T | undefined { + return this._promise.rawValue?.value; + } +} + +//#endregion + +//#region + +const enum AsyncIterableSourceState { + Initial, + DoneOK, + DoneError, +} + +/** + * An object that allows to emit async values asynchronously or bring the iterable to an error state using `reject()`. + * This emitter is valid only for the duration of the executor (until the promise returned by the executor settles). + */ +export interface AsyncIterableEmitter { + /** + * The value will be appended at the end. + * + * **NOTE** If `reject()` has already been called, this method has no effect. + */ + emitOne(value: T): void; + /** + * The values will be appended at the end. + * + * **NOTE** If `reject()` has already been called, this method has no effect. + */ + emitMany(values: T[]): void; + /** + * Writing an error will permanently invalidate this iterable. + * The current users will receive an error thrown, as will all future users. + * + * **NOTE** If `reject()` have already been called, this method has no effect. + */ + reject(error: Error): void; +} + +/** + * An executor for the `AsyncIterableObject` that has access to an emitter. + */ +export interface AsyncIterableExecutor { + /** + * @param emitter An object that allows to emit async values valid only for the duration of the executor. + */ + (emitter: AsyncIterableEmitter): void | Promise; +} + +/** + * A rich implementation for an `AsyncIterable`. + */ +export class AsyncIterableObject implements AsyncIterable { + + public static fromArray(items: T[]): AsyncIterableObject { + return new AsyncIterableObject((writer) => { + writer.emitMany(items); + }); + } + + public static fromPromise(promise: Promise): AsyncIterableObject { + return new AsyncIterableObject(async (emitter) => { + emitter.emitMany(await promise); + }); + } + + public static fromPromises(promises: Promise[]): AsyncIterableObject { + return new AsyncIterableObject(async (emitter) => { + await Promise.all(promises.map(async (p) => emitter.emitOne(await p))); + }); + } + + public static merge(iterables: AsyncIterable[]): AsyncIterableObject { + return new AsyncIterableObject(async (emitter) => { + await Promise.all(iterables.map(async (iterable) => { + for await (const item of iterable) { + emitter.emitOne(item); + } + })); + }); + } + + public static EMPTY = AsyncIterableObject.fromArray([]); + + private _state: AsyncIterableSourceState; + private _results: T[]; + private _error: Error | null; + private readonly _onReturn?: () => void | Promise; + private readonly _onStateChanged: Emitter; + + constructor(executor: AsyncIterableExecutor, onReturn?: () => void | Promise) { + this._state = AsyncIterableSourceState.Initial; + this._results = []; + this._error = null; + this._onReturn = onReturn; + this._onStateChanged = new Emitter(); + + queueMicrotask(async () => { + const writer: AsyncIterableEmitter = { + emitOne: (item) => this.emitOne(item), + emitMany: (items) => this.emitMany(items), + reject: (error) => this.reject(error) + }; + try { + await Promise.resolve(executor(writer)); + this.resolve(); + } catch (err) { + this.reject(err); + } finally { + writer.emitOne = undefined!; + writer.emitMany = undefined!; + writer.reject = undefined!; + } + }); + } + + [Symbol.asyncIterator](): AsyncIterator { + let i = 0; + return { + next: async () => { + do { + if (this._state === AsyncIterableSourceState.DoneError) { + throw this._error; + } + if (i < this._results.length) { + return { done: false, value: this._results[i++] }; + } + if (this._state === AsyncIterableSourceState.DoneOK) { + return { done: true, value: undefined }; + } + await Event.toPromise(this._onStateChanged.event); + } while (true); + }, + return: async () => { + this._onReturn?.(); + return { done: true, value: undefined }; + } + }; + } + + public static map(iterable: AsyncIterable, mapFn: (item: T) => R): AsyncIterableObject { + return new AsyncIterableObject(async (emitter) => { + for await (const item of iterable) { + emitter.emitOne(mapFn(item)); + } + }); + } + + public map(mapFn: (item: T) => R): AsyncIterableObject { + return AsyncIterableObject.map(this, mapFn); + } + + public static filter(iterable: AsyncIterable, filterFn: (item: T) => boolean): AsyncIterableObject { + return new AsyncIterableObject(async (emitter) => { + for await (const item of iterable) { + if (filterFn(item)) { + emitter.emitOne(item); + } + } + }); + } + + public filter(filterFn: (item: T) => boolean): AsyncIterableObject { + return AsyncIterableObject.filter(this, filterFn); + } + + public static coalesce(iterable: AsyncIterable): AsyncIterableObject { + return >AsyncIterableObject.filter(iterable, item => !!item); + } + + public coalesce(): AsyncIterableObject> { + return AsyncIterableObject.coalesce(this) as AsyncIterableObject>; + } + + public static async toPromise(iterable: AsyncIterable): Promise { + const result: T[] = []; + for await (const item of iterable) { + result.push(item); + } + return result; + } + + public toPromise(): Promise { + return AsyncIterableObject.toPromise(this); + } + + /** + * The value will be appended at the end. + * + * **NOTE** If `resolve()` or `reject()` have already been called, this method has no effect. + */ + private emitOne(value: T): void { + if (this._state !== AsyncIterableSourceState.Initial) { + return; + } + // it is important to add new values at the end, + // as we may have iterators already running on the array + this._results.push(value); + this._onStateChanged.fire(); + } + + /** + * The values will be appended at the end. + * + * **NOTE** If `resolve()` or `reject()` have already been called, this method has no effect. + */ + private emitMany(values: T[]): void { + if (this._state !== AsyncIterableSourceState.Initial) { + return; + } + // it is important to add new values at the end, + // as we may have iterators already running on the array + this._results = this._results.concat(values); + this._onStateChanged.fire(); + } + + /** + * Calling `resolve()` will mark the result array as complete. + * + * **NOTE** `resolve()` must be called, otherwise all consumers of this iterable will hang indefinitely, similar to a non-resolved promise. + * **NOTE** If `resolve()` or `reject()` have already been called, this method has no effect. + */ + private resolve(): void { + if (this._state !== AsyncIterableSourceState.Initial) { + return; + } + this._state = AsyncIterableSourceState.DoneOK; + this._onStateChanged.fire(); + } + + /** + * Writing an error will permanently invalidate this iterable. + * The current users will receive an error thrown, as will all future users. + * + * **NOTE** If `resolve()` or `reject()` have already been called, this method has no effect. + */ + private reject(error: Error) { + if (this._state !== AsyncIterableSourceState.Initial) { + return; + } + this._state = AsyncIterableSourceState.DoneError; + this._error = error; + this._onStateChanged.fire(); + } +} + +export class CancelableAsyncIterableObject extends AsyncIterableObject { + constructor( + private readonly _source: CancellationTokenSource, + executor: AsyncIterableExecutor + ) { + super(executor); + } + + cancel(): void { + this._source.cancel(); + } +} + +export function createCancelableAsyncIterable(callback: (token: CancellationToken) => AsyncIterable): CancelableAsyncIterableObject { + const source = new CancellationTokenSource(); + const innerIterable = callback(source.token); + + return new CancelableAsyncIterableObject(source, async (emitter) => { + const subscription = source.token.onCancellationRequested(() => { + subscription.dispose(); + source.dispose(); + emitter.reject(new CancellationError()); + }); + try { + for await (const item of innerIterable) { + if (source.token.isCancellationRequested) { + // canceled in the meantime + return; + } + emitter.emitOne(item); + } + subscription.dispose(); + source.dispose(); + } catch (err) { + subscription.dispose(); + source.dispose(); + emitter.reject(err); + } + }); +} + +export class AsyncIterableSource { + + private readonly _deferred = new DeferredPromise(); + private readonly _asyncIterable: AsyncIterableObject; + + private _errorFn: (error: Error) => void; + private _emitFn: (item: T) => void; + + /** + * + * @param onReturn A function that will be called when consuming the async iterable + * has finished by the consumer, e.g the for-await-loop has be existed (break, return) early. + * This is NOT called when resolving this source by its owner. + */ + constructor(onReturn?: () => Promise | void) { + this._asyncIterable = new AsyncIterableObject(emitter => { + + if (earlyError) { + emitter.reject(earlyError); + return; + } + if (earlyItems) { + emitter.emitMany(earlyItems); + } + this._errorFn = (error: Error) => emitter.reject(error); + this._emitFn = (item: T) => emitter.emitOne(item); + return this._deferred.p; + }, onReturn); + + let earlyError: Error | undefined; + let earlyItems: T[] | undefined; + + this._emitFn = (item: T) => { + if (!earlyItems) { + earlyItems = []; + } + earlyItems.push(item); + }; + this._errorFn = (error: Error) => { + if (!earlyError) { + earlyError = error; + } + }; + } + + get asyncIterable(): AsyncIterableObject { + return this._asyncIterable; + } + + resolve(): void { + this._deferred.complete(); + } + + reject(error: Error): void { + this._errorFn(error); + this._deferred.complete(); + } + + emitOne(item: T): void { + this._emitFn(item); + } +} + +//#endregion diff --git a/src/vs/base/common/buffer.ts b/src/vs/base/common/buffer.ts new file mode 100644 index 0000000000..08736ab8c0 --- /dev/null +++ b/src/vs/base/common/buffer.ts @@ -0,0 +1,441 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Lazy } from 'vs/base/common/lazy'; +import * as streams from 'vs/base/common/stream'; + +declare const Buffer: any; + +const hasBuffer = (typeof Buffer !== 'undefined'); +const indexOfTable = new Lazy(() => new Uint8Array(256)); + +let textEncoder: TextEncoder | null; +let textDecoder: TextDecoder | null; + +export class VSBuffer { + + /** + * When running in a nodejs context, the backing store for the returned `VSBuffer` instance + * might use a nodejs Buffer allocated from node's Buffer pool, which is not transferrable. + */ + static alloc(byteLength: number): VSBuffer { + if (hasBuffer) { + return new VSBuffer(Buffer.allocUnsafe(byteLength)); + } else { + return new VSBuffer(new Uint8Array(byteLength)); + } + } + + /** + * When running in a nodejs context, if `actual` is not a nodejs Buffer, the backing store for + * the returned `VSBuffer` instance might use a nodejs Buffer allocated from node's Buffer pool, + * which is not transferrable. + */ + static wrap(actual: Uint8Array): VSBuffer { + if (hasBuffer && !(Buffer.isBuffer(actual))) { + // https://nodejs.org/dist/latest-v10.x/docs/api/buffer.html#buffer_class_method_buffer_from_arraybuffer_byteoffset_length + // Create a zero-copy Buffer wrapper around the ArrayBuffer pointed to by the Uint8Array + actual = Buffer.from(actual.buffer, actual.byteOffset, actual.byteLength); + } + return new VSBuffer(actual); + } + + /** + * When running in a nodejs context, the backing store for the returned `VSBuffer` instance + * might use a nodejs Buffer allocated from node's Buffer pool, which is not transferrable. + */ + static fromString(source: string, options?: { dontUseNodeBuffer?: boolean }): VSBuffer { + const dontUseNodeBuffer = options?.dontUseNodeBuffer || false; + if (!dontUseNodeBuffer && hasBuffer) { + return new VSBuffer(Buffer.from(source)); + } else { + if (!textEncoder) { + textEncoder = new TextEncoder(); + } + return new VSBuffer(textEncoder.encode(source)); + } + } + + /** + * When running in a nodejs context, the backing store for the returned `VSBuffer` instance + * might use a nodejs Buffer allocated from node's Buffer pool, which is not transferrable. + */ + static fromByteArray(source: number[]): VSBuffer { + const result = VSBuffer.alloc(source.length); + for (let i = 0, len = source.length; i < len; i++) { + result.buffer[i] = source[i]; + } + return result; + } + + /** + * When running in a nodejs context, the backing store for the returned `VSBuffer` instance + * might use a nodejs Buffer allocated from node's Buffer pool, which is not transferrable. + */ + static concat(buffers: VSBuffer[], totalLength?: number): VSBuffer { + if (typeof totalLength === 'undefined') { + totalLength = 0; + for (let i = 0, len = buffers.length; i < len; i++) { + totalLength += buffers[i].byteLength; + } + } + + const ret = VSBuffer.alloc(totalLength); + let offset = 0; + for (let i = 0, len = buffers.length; i < len; i++) { + const element = buffers[i]; + ret.set(element, offset); + offset += element.byteLength; + } + + return ret; + } + + readonly buffer: Uint8Array; + readonly byteLength: number; + + private constructor(buffer: Uint8Array) { + this.buffer = buffer; + this.byteLength = this.buffer.byteLength; + } + + /** + * When running in a nodejs context, the backing store for the returned `VSBuffer` instance + * might use a nodejs Buffer allocated from node's Buffer pool, which is not transferrable. + */ + clone(): VSBuffer { + const result = VSBuffer.alloc(this.byteLength); + result.set(this); + return result; + } + + toString(): string { + if (hasBuffer) { + return this.buffer.toString(); + } else { + if (!textDecoder) { + textDecoder = new TextDecoder(); + } + return textDecoder.decode(this.buffer); + } + } + + slice(start?: number, end?: number): VSBuffer { + // IMPORTANT: use subarray instead of slice because TypedArray#slice + // creates shallow copy and NodeBuffer#slice doesn't. The use of subarray + // ensures the same, performance, behaviour. + return new VSBuffer(this.buffer.subarray(start, end)); + } + + set(array: VSBuffer, offset?: number): void; + set(array: Uint8Array, offset?: number): void; + set(array: ArrayBuffer, offset?: number): void; + set(array: ArrayBufferView, offset?: number): void; + set(array: VSBuffer | Uint8Array | ArrayBuffer | ArrayBufferView, offset?: number): void; + set(array: VSBuffer | Uint8Array | ArrayBuffer | ArrayBufferView, offset?: number): void { + if (array instanceof VSBuffer) { + this.buffer.set(array.buffer, offset); + } else if (array instanceof Uint8Array) { + this.buffer.set(array, offset); + } else if (array instanceof ArrayBuffer) { + this.buffer.set(new Uint8Array(array), offset); + } else if (ArrayBuffer.isView(array)) { + this.buffer.set(new Uint8Array(array.buffer, array.byteOffset, array.byteLength), offset); + } else { + throw new Error(`Unknown argument 'array'`); + } + } + + readUInt32BE(offset: number): number { + return readUInt32BE(this.buffer, offset); + } + + writeUInt32BE(value: number, offset: number): void { + writeUInt32BE(this.buffer, value, offset); + } + + readUInt32LE(offset: number): number { + return readUInt32LE(this.buffer, offset); + } + + writeUInt32LE(value: number, offset: number): void { + writeUInt32LE(this.buffer, value, offset); + } + + readUInt8(offset: number): number { + return readUInt8(this.buffer, offset); + } + + writeUInt8(value: number, offset: number): void { + writeUInt8(this.buffer, value, offset); + } + + indexOf(subarray: VSBuffer | Uint8Array, offset = 0) { + return binaryIndexOf(this.buffer, subarray instanceof VSBuffer ? subarray.buffer : subarray, offset); + } +} + +/** + * Like String.indexOf, but works on Uint8Arrays. + * Uses the boyer-moore-horspool algorithm to be reasonably speedy. + */ +export function binaryIndexOf(haystack: Uint8Array, needle: Uint8Array, offset = 0): number { + const needleLen = needle.byteLength; + const haystackLen = haystack.byteLength; + + if (needleLen === 0) { + return 0; + } + + if (needleLen === 1) { + return haystack.indexOf(needle[0]); + } + + if (needleLen > haystackLen - offset) { + return -1; + } + + // find index of the subarray using boyer-moore-horspool algorithm + const table = indexOfTable.value; + table.fill(needle.length); + for (let i = 0; i < needle.length; i++) { + table[needle[i]] = needle.length - i - 1; + } + + let i = offset + needle.length - 1; + let j = i; + let result = -1; + while (i < haystackLen) { + if (haystack[i] === needle[j]) { + if (j === 0) { + result = i; + break; + } + + i--; + j--; + } else { + i += Math.max(needle.length - j, table[haystack[i]]); + j = needle.length - 1; + } + } + + return result; +} + +export function readUInt16LE(source: Uint8Array, offset: number): number { + return ( + ((source[offset + 0] << 0) >>> 0) | + ((source[offset + 1] << 8) >>> 0) + ); +} + +export function writeUInt16LE(destination: Uint8Array, value: number, offset: number): void { + destination[offset + 0] = (value & 0b11111111); + value = value >>> 8; + destination[offset + 1] = (value & 0b11111111); +} + +export function readUInt32BE(source: Uint8Array, offset: number): number { + return ( + source[offset] * 2 ** 24 + + source[offset + 1] * 2 ** 16 + + source[offset + 2] * 2 ** 8 + + source[offset + 3] + ); +} + +export function writeUInt32BE(destination: Uint8Array, value: number, offset: number): void { + destination[offset + 3] = value; + value = value >>> 8; + destination[offset + 2] = value; + value = value >>> 8; + destination[offset + 1] = value; + value = value >>> 8; + destination[offset] = value; +} + +export function readUInt32LE(source: Uint8Array, offset: number): number { + return ( + ((source[offset + 0] << 0) >>> 0) | + ((source[offset + 1] << 8) >>> 0) | + ((source[offset + 2] << 16) >>> 0) | + ((source[offset + 3] << 24) >>> 0) + ); +} + +export function writeUInt32LE(destination: Uint8Array, value: number, offset: number): void { + destination[offset + 0] = (value & 0b11111111); + value = value >>> 8; + destination[offset + 1] = (value & 0b11111111); + value = value >>> 8; + destination[offset + 2] = (value & 0b11111111); + value = value >>> 8; + destination[offset + 3] = (value & 0b11111111); +} + +export function readUInt8(source: Uint8Array, offset: number): number { + return source[offset]; +} + +export function writeUInt8(destination: Uint8Array, value: number, offset: number): void { + destination[offset] = value; +} + +export interface VSBufferReadable extends streams.Readable { } + +export interface VSBufferReadableStream extends streams.ReadableStream { } + +export interface VSBufferWriteableStream extends streams.WriteableStream { } + +export interface VSBufferReadableBufferedStream extends streams.ReadableBufferedStream { } + +export function readableToBuffer(readable: VSBufferReadable): VSBuffer { + return streams.consumeReadable(readable, chunks => VSBuffer.concat(chunks)); +} + +export function bufferToReadable(buffer: VSBuffer): VSBufferReadable { + return streams.toReadable(buffer); +} + +export function streamToBuffer(stream: streams.ReadableStream): Promise { + return streams.consumeStream(stream, chunks => VSBuffer.concat(chunks)); +} + +export async function bufferedStreamToBuffer(bufferedStream: streams.ReadableBufferedStream): Promise { + if (bufferedStream.ended) { + return VSBuffer.concat(bufferedStream.buffer); + } + + return VSBuffer.concat([ + + // Include already read chunks... + ...bufferedStream.buffer, + + // ...and all additional chunks + await streamToBuffer(bufferedStream.stream) + ]); +} + +export function bufferToStream(buffer: VSBuffer): streams.ReadableStream { + return streams.toStream(buffer, chunks => VSBuffer.concat(chunks)); +} + +export function streamToBufferReadableStream(stream: streams.ReadableStreamEvents): streams.ReadableStream { + return streams.transform(stream, { data: data => typeof data === 'string' ? VSBuffer.fromString(data) : VSBuffer.wrap(data) }, chunks => VSBuffer.concat(chunks)); +} + +export function newWriteableBufferStream(options?: streams.WriteableStreamOptions): streams.WriteableStream { + return streams.newWriteableStream(chunks => VSBuffer.concat(chunks), options); +} + +export function prefixedBufferReadable(prefix: VSBuffer, readable: VSBufferReadable): VSBufferReadable { + return streams.prefixedReadable(prefix, readable, chunks => VSBuffer.concat(chunks)); +} + +export function prefixedBufferStream(prefix: VSBuffer, stream: VSBufferReadableStream): VSBufferReadableStream { + return streams.prefixedStream(prefix, stream, chunks => VSBuffer.concat(chunks)); +} + +/** Decodes base64 to a uint8 array. URL-encoded and unpadded base64 is allowed. */ +export function decodeBase64(encoded: string) { + let building = 0; + let remainder = 0; + let bufi = 0; + + // The simpler way to do this is `Uint8Array.from(atob(str), c => c.charCodeAt(0))`, + // but that's about 10-20x slower than this function in current Chromium versions. + + const buffer = new Uint8Array(Math.floor(encoded.length / 4 * 3)); + const append = (value: number) => { + switch (remainder) { + case 3: + buffer[bufi++] = building | value; + remainder = 0; + break; + case 2: + buffer[bufi++] = building | (value >>> 2); + building = value << 6; + remainder = 3; + break; + case 1: + buffer[bufi++] = building | (value >>> 4); + building = value << 4; + remainder = 2; + break; + default: + building = value << 2; + remainder = 1; + } + }; + + for (let i = 0; i < encoded.length; i++) { + const code = encoded.charCodeAt(i); + // See https://datatracker.ietf.org/doc/html/rfc4648#section-4 + // This branchy code is about 3x faster than an indexOf on a base64 char string. + if (code >= 65 && code <= 90) { + append(code - 65); // A-Z starts ranges from char code 65 to 90 + } else if (code >= 97 && code <= 122) { + append(code - 97 + 26); // a-z starts ranges from char code 97 to 122, starting at byte 26 + } else if (code >= 48 && code <= 57) { + append(code - 48 + 52); // 0-9 starts ranges from char code 48 to 58, starting at byte 52 + } else if (code === 43 || code === 45) { + append(62); // "+" or "-" for URLS + } else if (code === 47 || code === 95) { + append(63); // "/" or "_" for URLS + } else if (code === 61) { + break; // "=" + } else { + throw new SyntaxError(`Unexpected base64 character ${encoded[i]}`); + } + } + + const unpadded = bufi; + while (remainder > 0) { + append(0); + } + + // slice is needed to account for overestimation due to padding + return VSBuffer.wrap(buffer).slice(0, unpadded); +} + +const base64Alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; +const base64UrlSafeAlphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'; + +/** Encodes a buffer to a base64 string. */ +export function encodeBase64({ buffer }: VSBuffer, padded = true, urlSafe = false) { + const dictionary = urlSafe ? base64UrlSafeAlphabet : base64Alphabet; + let output = ''; + + const remainder = buffer.byteLength % 3; + + let i = 0; + for (; i < buffer.byteLength - remainder; i += 3) { + const a = buffer[i + 0]; + const b = buffer[i + 1]; + const c = buffer[i + 2]; + + output += dictionary[a >>> 2]; + output += dictionary[(a << 4 | b >>> 4) & 0b111111]; + output += dictionary[(b << 2 | c >>> 6) & 0b111111]; + output += dictionary[c & 0b111111]; + } + + if (remainder === 1) { + const a = buffer[i + 0]; + output += dictionary[a >>> 2]; + output += dictionary[(a << 4) & 0b111111]; + if (padded) { output += '=='; } + } else if (remainder === 2) { + const a = buffer[i + 0]; + const b = buffer[i + 1]; + output += dictionary[a >>> 2]; + output += dictionary[(a << 4 | b >>> 4) & 0b111111]; + output += dictionary[(b << 2) & 0b111111]; + if (padded) { output += '='; } + } + + return output; +} diff --git a/src/vs/base/common/cache.ts b/src/vs/base/common/cache.ts new file mode 100644 index 0000000000..03abbaf8eb --- /dev/null +++ b/src/vs/base/common/cache.ts @@ -0,0 +1,120 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CancellationToken, CancellationTokenSource } from 'vs/base/common/cancellation'; +import { IDisposable } from 'vs/base/common/lifecycle'; + +export interface CacheResult extends IDisposable { + promise: Promise; +} + +export class Cache { + + private result: CacheResult | null = null; + constructor(private task: (ct: CancellationToken) => Promise) { } + + get(): CacheResult { + if (this.result) { + return this.result; + } + + const cts = new CancellationTokenSource(); + const promise = this.task(cts.token); + + this.result = { + promise, + dispose: () => { + this.result = null; + cts.cancel(); + cts.dispose(); + } + }; + + return this.result; + } +} + +export function identity(t: T): T { + return t; +} + +interface ICacheOptions { + /** + * The cache key is used to identify the cache entry. + * Strict equality is used to compare cache keys. + */ + getCacheKey: (arg: TArg) => unknown; +} + +/** + * Uses a LRU cache to make a given parametrized function cached. + * Caches just the last key/value. +*/ +export class LRUCachedFunction { + private lastCache: TComputed | undefined = undefined; + private lastArgKey: unknown | undefined = undefined; + + private readonly _fn: (arg: TArg) => TComputed; + private readonly _computeKey: (arg: TArg) => unknown; + + constructor(fn: (arg: TArg) => TComputed); + constructor(options: ICacheOptions, fn: (arg: TArg) => TComputed); + constructor(arg1: ICacheOptions | ((arg: TArg) => TComputed), arg2?: (arg: TArg) => TComputed) { + if (typeof arg1 === 'function') { + this._fn = arg1; + this._computeKey = identity; + } else { + this._fn = arg2!; + this._computeKey = arg1.getCacheKey; + } + } + + public get(arg: TArg): TComputed { + const key = this._computeKey(arg); + if (this.lastArgKey !== key) { + this.lastArgKey = key; + this.lastCache = this._fn(arg); + } + return this.lastCache!; + } +} + +/** + * Uses an unbounded cache to memoize the results of the given function. +*/ +export class CachedFunction { + private readonly _map = new Map(); + private readonly _map2 = new Map(); + public get cachedValues(): ReadonlyMap { + return this._map; + } + + private readonly _fn: (arg: TArg) => TComputed; + private readonly _computeKey: (arg: TArg) => unknown; + + constructor(fn: (arg: TArg) => TComputed); + constructor(options: ICacheOptions, fn: (arg: TArg) => TComputed); + constructor(arg1: ICacheOptions | ((arg: TArg) => TComputed), arg2?: (arg: TArg) => TComputed) { + if (typeof arg1 === 'function') { + this._fn = arg1; + this._computeKey = identity; + } else { + this._fn = arg2!; + this._computeKey = arg1.getCacheKey; + } + } + + public get(arg: TArg): TComputed { + const key = this._computeKey(arg); + if (this._map2.has(key)) { + return this._map2.get(key)!; + } + + const value = this._fn(arg); + this._map.set(arg, value); + this._map2.set(key, value); + return value; + } +} diff --git a/src/vs/base/common/cancellation.ts b/src/vs/base/common/cancellation.ts new file mode 100644 index 0000000000..4b45fd2803 --- /dev/null +++ b/src/vs/base/common/cancellation.ts @@ -0,0 +1,148 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Emitter, Event } from 'vs/base/common/event'; +import { DisposableStore, IDisposable } from 'vs/base/common/lifecycle'; + +export interface CancellationToken { + + /** + * A flag signalling is cancellation has been requested. + */ + readonly isCancellationRequested: boolean; + + /** + * An event which fires when cancellation is requested. This event + * only ever fires `once` as cancellation can only happen once. Listeners + * that are registered after cancellation will be called (next event loop run), + * but also only once. + * + * @event + */ + readonly onCancellationRequested: (listener: (e: any) => any, thisArgs?: any, disposables?: IDisposable[]) => IDisposable; +} + +const shortcutEvent: Event = Object.freeze(function (callback, context?): IDisposable { + const handle = setTimeout(callback.bind(context), 0); + return { dispose() { clearTimeout(handle); } }; +}); + +export namespace CancellationToken { + + export function isCancellationToken(thing: unknown): thing is CancellationToken { + if (thing === CancellationToken.None || thing === CancellationToken.Cancelled) { + return true; + } + if (thing instanceof MutableToken) { + return true; + } + if (!thing || typeof thing !== 'object') { + return false; + } + return typeof (thing as CancellationToken).isCancellationRequested === 'boolean' + && typeof (thing as CancellationToken).onCancellationRequested === 'function'; + } + + + export const None = Object.freeze({ + isCancellationRequested: false, + onCancellationRequested: Event.None + }); + + export const Cancelled = Object.freeze({ + isCancellationRequested: true, + onCancellationRequested: shortcutEvent + }); +} + +class MutableToken implements CancellationToken { + + private _isCancelled: boolean = false; + private _emitter: Emitter | null = null; + + public cancel() { + if (!this._isCancelled) { + this._isCancelled = true; + if (this._emitter) { + this._emitter.fire(undefined); + this.dispose(); + } + } + } + + get isCancellationRequested(): boolean { + return this._isCancelled; + } + + get onCancellationRequested(): Event { + if (this._isCancelled) { + return shortcutEvent; + } + if (!this._emitter) { + this._emitter = new Emitter(); + } + return this._emitter.event; + } + + public dispose(): void { + if (this._emitter) { + this._emitter.dispose(); + this._emitter = null; + } + } +} + +export class CancellationTokenSource { + + private _token?: CancellationToken = undefined; + private _parentListener?: IDisposable = undefined; + + constructor(parent?: CancellationToken) { + this._parentListener = parent && parent.onCancellationRequested(this.cancel, this); + } + + get token(): CancellationToken { + if (!this._token) { + // be lazy and create the token only when + // actually needed + this._token = new MutableToken(); + } + return this._token; + } + + cancel(): void { + if (!this._token) { + // save an object by returning the default + // cancelled token when cancellation happens + // before someone asks for the token + this._token = CancellationToken.Cancelled; + + } else if (this._token instanceof MutableToken) { + // actually cancel + this._token.cancel(); + } + } + + dispose(cancel: boolean = false): void { + if (cancel) { + this.cancel(); + } + this._parentListener?.dispose(); + if (!this._token) { + // ensure to initialize with an empty token if we had none + this._token = CancellationToken.None; + + } else if (this._token instanceof MutableToken) { + // actually dispose + this._token.dispose(); + } + } +} + +export function cancelOnDispose(store: DisposableStore): CancellationToken { + const source = new CancellationTokenSource(); + store.add({ dispose() { source.cancel(); } }); + return source.token; +} diff --git a/src/vs/base/common/charCode.ts b/src/vs/base/common/charCode.ts new file mode 100644 index 0000000000..de0a25bade --- /dev/null +++ b/src/vs/base/common/charCode.ts @@ -0,0 +1,450 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// Names from https://blog.codinghorror.com/ascii-pronunciation-rules-for-programmers/ + +/** + * An inlined enum containing useful character codes (to be used with String.charCodeAt). + * Please leave the const keyword such that it gets inlined when compiled to JavaScript! + */ +export const enum CharCode { + Null = 0, + /** + * The `\b` character. + */ + Backspace = 8, + /** + * The `\t` character. + */ + Tab = 9, + /** + * The `\n` character. + */ + LineFeed = 10, + /** + * The `\r` character. + */ + CarriageReturn = 13, + Space = 32, + /** + * The `!` character. + */ + ExclamationMark = 33, + /** + * The `"` character. + */ + DoubleQuote = 34, + /** + * The `#` character. + */ + Hash = 35, + /** + * The `$` character. + */ + DollarSign = 36, + /** + * The `%` character. + */ + PercentSign = 37, + /** + * The `&` character. + */ + Ampersand = 38, + /** + * The `'` character. + */ + SingleQuote = 39, + /** + * The `(` character. + */ + OpenParen = 40, + /** + * The `)` character. + */ + CloseParen = 41, + /** + * The `*` character. + */ + Asterisk = 42, + /** + * The `+` character. + */ + Plus = 43, + /** + * The `,` character. + */ + Comma = 44, + /** + * The `-` character. + */ + Dash = 45, + /** + * The `.` character. + */ + Period = 46, + /** + * The `/` character. + */ + Slash = 47, + + Digit0 = 48, + Digit1 = 49, + Digit2 = 50, + Digit3 = 51, + Digit4 = 52, + Digit5 = 53, + Digit6 = 54, + Digit7 = 55, + Digit8 = 56, + Digit9 = 57, + + /** + * The `:` character. + */ + Colon = 58, + /** + * The `;` character. + */ + Semicolon = 59, + /** + * The `<` character. + */ + LessThan = 60, + /** + * The `=` character. + */ + Equals = 61, + /** + * The `>` character. + */ + GreaterThan = 62, + /** + * The `?` character. + */ + QuestionMark = 63, + /** + * The `@` character. + */ + AtSign = 64, + + A = 65, + B = 66, + C = 67, + D = 68, + E = 69, + F = 70, + G = 71, + H = 72, + I = 73, + J = 74, + K = 75, + L = 76, + M = 77, + N = 78, + O = 79, + P = 80, + Q = 81, + R = 82, + S = 83, + T = 84, + U = 85, + V = 86, + W = 87, + X = 88, + Y = 89, + Z = 90, + + /** + * The `[` character. + */ + OpenSquareBracket = 91, + /** + * The `\` character. + */ + Backslash = 92, + /** + * The `]` character. + */ + CloseSquareBracket = 93, + /** + * The `^` character. + */ + Caret = 94, + /** + * The `_` character. + */ + Underline = 95, + /** + * The ``(`)`` character. + */ + BackTick = 96, + + a = 97, + b = 98, + c = 99, + d = 100, + e = 101, + f = 102, + g = 103, + h = 104, + i = 105, + j = 106, + k = 107, + l = 108, + m = 109, + n = 110, + o = 111, + p = 112, + q = 113, + r = 114, + s = 115, + t = 116, + u = 117, + v = 118, + w = 119, + x = 120, + y = 121, + z = 122, + + /** + * The `{` character. + */ + OpenCurlyBrace = 123, + /** + * The `|` character. + */ + Pipe = 124, + /** + * The `}` character. + */ + CloseCurlyBrace = 125, + /** + * The `~` character. + */ + Tilde = 126, + + /** + * The   (no-break space) character. + * Unicode Character 'NO-BREAK SPACE' (U+00A0) + */ + NoBreakSpace = 160, + + U_Combining_Grave_Accent = 0x0300, // U+0300 Combining Grave Accent + U_Combining_Acute_Accent = 0x0301, // U+0301 Combining Acute Accent + U_Combining_Circumflex_Accent = 0x0302, // U+0302 Combining Circumflex Accent + U_Combining_Tilde = 0x0303, // U+0303 Combining Tilde + U_Combining_Macron = 0x0304, // U+0304 Combining Macron + U_Combining_Overline = 0x0305, // U+0305 Combining Overline + U_Combining_Breve = 0x0306, // U+0306 Combining Breve + U_Combining_Dot_Above = 0x0307, // U+0307 Combining Dot Above + U_Combining_Diaeresis = 0x0308, // U+0308 Combining Diaeresis + U_Combining_Hook_Above = 0x0309, // U+0309 Combining Hook Above + U_Combining_Ring_Above = 0x030A, // U+030A Combining Ring Above + U_Combining_Double_Acute_Accent = 0x030B, // U+030B Combining Double Acute Accent + U_Combining_Caron = 0x030C, // U+030C Combining Caron + U_Combining_Vertical_Line_Above = 0x030D, // U+030D Combining Vertical Line Above + U_Combining_Double_Vertical_Line_Above = 0x030E, // U+030E Combining Double Vertical Line Above + U_Combining_Double_Grave_Accent = 0x030F, // U+030F Combining Double Grave Accent + U_Combining_Candrabindu = 0x0310, // U+0310 Combining Candrabindu + U_Combining_Inverted_Breve = 0x0311, // U+0311 Combining Inverted Breve + U_Combining_Turned_Comma_Above = 0x0312, // U+0312 Combining Turned Comma Above + U_Combining_Comma_Above = 0x0313, // U+0313 Combining Comma Above + U_Combining_Reversed_Comma_Above = 0x0314, // U+0314 Combining Reversed Comma Above + U_Combining_Comma_Above_Right = 0x0315, // U+0315 Combining Comma Above Right + U_Combining_Grave_Accent_Below = 0x0316, // U+0316 Combining Grave Accent Below + U_Combining_Acute_Accent_Below = 0x0317, // U+0317 Combining Acute Accent Below + U_Combining_Left_Tack_Below = 0x0318, // U+0318 Combining Left Tack Below + U_Combining_Right_Tack_Below = 0x0319, // U+0319 Combining Right Tack Below + U_Combining_Left_Angle_Above = 0x031A, // U+031A Combining Left Angle Above + U_Combining_Horn = 0x031B, // U+031B Combining Horn + U_Combining_Left_Half_Ring_Below = 0x031C, // U+031C Combining Left Half Ring Below + U_Combining_Up_Tack_Below = 0x031D, // U+031D Combining Up Tack Below + U_Combining_Down_Tack_Below = 0x031E, // U+031E Combining Down Tack Below + U_Combining_Plus_Sign_Below = 0x031F, // U+031F Combining Plus Sign Below + U_Combining_Minus_Sign_Below = 0x0320, // U+0320 Combining Minus Sign Below + U_Combining_Palatalized_Hook_Below = 0x0321, // U+0321 Combining Palatalized Hook Below + U_Combining_Retroflex_Hook_Below = 0x0322, // U+0322 Combining Retroflex Hook Below + U_Combining_Dot_Below = 0x0323, // U+0323 Combining Dot Below + U_Combining_Diaeresis_Below = 0x0324, // U+0324 Combining Diaeresis Below + U_Combining_Ring_Below = 0x0325, // U+0325 Combining Ring Below + U_Combining_Comma_Below = 0x0326, // U+0326 Combining Comma Below + U_Combining_Cedilla = 0x0327, // U+0327 Combining Cedilla + U_Combining_Ogonek = 0x0328, // U+0328 Combining Ogonek + U_Combining_Vertical_Line_Below = 0x0329, // U+0329 Combining Vertical Line Below + U_Combining_Bridge_Below = 0x032A, // U+032A Combining Bridge Below + U_Combining_Inverted_Double_Arch_Below = 0x032B, // U+032B Combining Inverted Double Arch Below + U_Combining_Caron_Below = 0x032C, // U+032C Combining Caron Below + U_Combining_Circumflex_Accent_Below = 0x032D, // U+032D Combining Circumflex Accent Below + U_Combining_Breve_Below = 0x032E, // U+032E Combining Breve Below + U_Combining_Inverted_Breve_Below = 0x032F, // U+032F Combining Inverted Breve Below + U_Combining_Tilde_Below = 0x0330, // U+0330 Combining Tilde Below + U_Combining_Macron_Below = 0x0331, // U+0331 Combining Macron Below + U_Combining_Low_Line = 0x0332, // U+0332 Combining Low Line + U_Combining_Double_Low_Line = 0x0333, // U+0333 Combining Double Low Line + U_Combining_Tilde_Overlay = 0x0334, // U+0334 Combining Tilde Overlay + U_Combining_Short_Stroke_Overlay = 0x0335, // U+0335 Combining Short Stroke Overlay + U_Combining_Long_Stroke_Overlay = 0x0336, // U+0336 Combining Long Stroke Overlay + U_Combining_Short_Solidus_Overlay = 0x0337, // U+0337 Combining Short Solidus Overlay + U_Combining_Long_Solidus_Overlay = 0x0338, // U+0338 Combining Long Solidus Overlay + U_Combining_Right_Half_Ring_Below = 0x0339, // U+0339 Combining Right Half Ring Below + U_Combining_Inverted_Bridge_Below = 0x033A, // U+033A Combining Inverted Bridge Below + U_Combining_Square_Below = 0x033B, // U+033B Combining Square Below + U_Combining_Seagull_Below = 0x033C, // U+033C Combining Seagull Below + U_Combining_X_Above = 0x033D, // U+033D Combining X Above + U_Combining_Vertical_Tilde = 0x033E, // U+033E Combining Vertical Tilde + U_Combining_Double_Overline = 0x033F, // U+033F Combining Double Overline + U_Combining_Grave_Tone_Mark = 0x0340, // U+0340 Combining Grave Tone Mark + U_Combining_Acute_Tone_Mark = 0x0341, // U+0341 Combining Acute Tone Mark + U_Combining_Greek_Perispomeni = 0x0342, // U+0342 Combining Greek Perispomeni + U_Combining_Greek_Koronis = 0x0343, // U+0343 Combining Greek Koronis + U_Combining_Greek_Dialytika_Tonos = 0x0344, // U+0344 Combining Greek Dialytika Tonos + U_Combining_Greek_Ypogegrammeni = 0x0345, // U+0345 Combining Greek Ypogegrammeni + U_Combining_Bridge_Above = 0x0346, // U+0346 Combining Bridge Above + U_Combining_Equals_Sign_Below = 0x0347, // U+0347 Combining Equals Sign Below + U_Combining_Double_Vertical_Line_Below = 0x0348, // U+0348 Combining Double Vertical Line Below + U_Combining_Left_Angle_Below = 0x0349, // U+0349 Combining Left Angle Below + U_Combining_Not_Tilde_Above = 0x034A, // U+034A Combining Not Tilde Above + U_Combining_Homothetic_Above = 0x034B, // U+034B Combining Homothetic Above + U_Combining_Almost_Equal_To_Above = 0x034C, // U+034C Combining Almost Equal To Above + U_Combining_Left_Right_Arrow_Below = 0x034D, // U+034D Combining Left Right Arrow Below + U_Combining_Upwards_Arrow_Below = 0x034E, // U+034E Combining Upwards Arrow Below + U_Combining_Grapheme_Joiner = 0x034F, // U+034F Combining Grapheme Joiner + U_Combining_Right_Arrowhead_Above = 0x0350, // U+0350 Combining Right Arrowhead Above + U_Combining_Left_Half_Ring_Above = 0x0351, // U+0351 Combining Left Half Ring Above + U_Combining_Fermata = 0x0352, // U+0352 Combining Fermata + U_Combining_X_Below = 0x0353, // U+0353 Combining X Below + U_Combining_Left_Arrowhead_Below = 0x0354, // U+0354 Combining Left Arrowhead Below + U_Combining_Right_Arrowhead_Below = 0x0355, // U+0355 Combining Right Arrowhead Below + U_Combining_Right_Arrowhead_And_Up_Arrowhead_Below = 0x0356, // U+0356 Combining Right Arrowhead And Up Arrowhead Below + U_Combining_Right_Half_Ring_Above = 0x0357, // U+0357 Combining Right Half Ring Above + U_Combining_Dot_Above_Right = 0x0358, // U+0358 Combining Dot Above Right + U_Combining_Asterisk_Below = 0x0359, // U+0359 Combining Asterisk Below + U_Combining_Double_Ring_Below = 0x035A, // U+035A Combining Double Ring Below + U_Combining_Zigzag_Above = 0x035B, // U+035B Combining Zigzag Above + U_Combining_Double_Breve_Below = 0x035C, // U+035C Combining Double Breve Below + U_Combining_Double_Breve = 0x035D, // U+035D Combining Double Breve + U_Combining_Double_Macron = 0x035E, // U+035E Combining Double Macron + U_Combining_Double_Macron_Below = 0x035F, // U+035F Combining Double Macron Below + U_Combining_Double_Tilde = 0x0360, // U+0360 Combining Double Tilde + U_Combining_Double_Inverted_Breve = 0x0361, // U+0361 Combining Double Inverted Breve + U_Combining_Double_Rightwards_Arrow_Below = 0x0362, // U+0362 Combining Double Rightwards Arrow Below + U_Combining_Latin_Small_Letter_A = 0x0363, // U+0363 Combining Latin Small Letter A + U_Combining_Latin_Small_Letter_E = 0x0364, // U+0364 Combining Latin Small Letter E + U_Combining_Latin_Small_Letter_I = 0x0365, // U+0365 Combining Latin Small Letter I + U_Combining_Latin_Small_Letter_O = 0x0366, // U+0366 Combining Latin Small Letter O + U_Combining_Latin_Small_Letter_U = 0x0367, // U+0367 Combining Latin Small Letter U + U_Combining_Latin_Small_Letter_C = 0x0368, // U+0368 Combining Latin Small Letter C + U_Combining_Latin_Small_Letter_D = 0x0369, // U+0369 Combining Latin Small Letter D + U_Combining_Latin_Small_Letter_H = 0x036A, // U+036A Combining Latin Small Letter H + U_Combining_Latin_Small_Letter_M = 0x036B, // U+036B Combining Latin Small Letter M + U_Combining_Latin_Small_Letter_R = 0x036C, // U+036C Combining Latin Small Letter R + U_Combining_Latin_Small_Letter_T = 0x036D, // U+036D Combining Latin Small Letter T + U_Combining_Latin_Small_Letter_V = 0x036E, // U+036E Combining Latin Small Letter V + U_Combining_Latin_Small_Letter_X = 0x036F, // U+036F Combining Latin Small Letter X + + /** + * Unicode Character 'LINE SEPARATOR' (U+2028) + * http://www.fileformat.info/info/unicode/char/2028/index.htm + */ + LINE_SEPARATOR = 0x2028, + /** + * Unicode Character 'PARAGRAPH SEPARATOR' (U+2029) + * http://www.fileformat.info/info/unicode/char/2029/index.htm + */ + PARAGRAPH_SEPARATOR = 0x2029, + /** + * Unicode Character 'NEXT LINE' (U+0085) + * http://www.fileformat.info/info/unicode/char/0085/index.htm + */ + NEXT_LINE = 0x0085, + + // http://www.fileformat.info/info/unicode/category/Sk/list.htm + U_CIRCUMFLEX = 0x005E, // U+005E CIRCUMFLEX + U_GRAVE_ACCENT = 0x0060, // U+0060 GRAVE ACCENT + U_DIAERESIS = 0x00A8, // U+00A8 DIAERESIS + U_MACRON = 0x00AF, // U+00AF MACRON + U_ACUTE_ACCENT = 0x00B4, // U+00B4 ACUTE ACCENT + U_CEDILLA = 0x00B8, // U+00B8 CEDILLA + U_MODIFIER_LETTER_LEFT_ARROWHEAD = 0x02C2, // U+02C2 MODIFIER LETTER LEFT ARROWHEAD + U_MODIFIER_LETTER_RIGHT_ARROWHEAD = 0x02C3, // U+02C3 MODIFIER LETTER RIGHT ARROWHEAD + U_MODIFIER_LETTER_UP_ARROWHEAD = 0x02C4, // U+02C4 MODIFIER LETTER UP ARROWHEAD + U_MODIFIER_LETTER_DOWN_ARROWHEAD = 0x02C5, // U+02C5 MODIFIER LETTER DOWN ARROWHEAD + U_MODIFIER_LETTER_CENTRED_RIGHT_HALF_RING = 0x02D2, // U+02D2 MODIFIER LETTER CENTRED RIGHT HALF RING + U_MODIFIER_LETTER_CENTRED_LEFT_HALF_RING = 0x02D3, // U+02D3 MODIFIER LETTER CENTRED LEFT HALF RING + U_MODIFIER_LETTER_UP_TACK = 0x02D4, // U+02D4 MODIFIER LETTER UP TACK + U_MODIFIER_LETTER_DOWN_TACK = 0x02D5, // U+02D5 MODIFIER LETTER DOWN TACK + U_MODIFIER_LETTER_PLUS_SIGN = 0x02D6, // U+02D6 MODIFIER LETTER PLUS SIGN + U_MODIFIER_LETTER_MINUS_SIGN = 0x02D7, // U+02D7 MODIFIER LETTER MINUS SIGN + U_BREVE = 0x02D8, // U+02D8 BREVE + U_DOT_ABOVE = 0x02D9, // U+02D9 DOT ABOVE + U_RING_ABOVE = 0x02DA, // U+02DA RING ABOVE + U_OGONEK = 0x02DB, // U+02DB OGONEK + U_SMALL_TILDE = 0x02DC, // U+02DC SMALL TILDE + U_DOUBLE_ACUTE_ACCENT = 0x02DD, // U+02DD DOUBLE ACUTE ACCENT + U_MODIFIER_LETTER_RHOTIC_HOOK = 0x02DE, // U+02DE MODIFIER LETTER RHOTIC HOOK + U_MODIFIER_LETTER_CROSS_ACCENT = 0x02DF, // U+02DF MODIFIER LETTER CROSS ACCENT + U_MODIFIER_LETTER_EXTRA_HIGH_TONE_BAR = 0x02E5, // U+02E5 MODIFIER LETTER EXTRA-HIGH TONE BAR + U_MODIFIER_LETTER_HIGH_TONE_BAR = 0x02E6, // U+02E6 MODIFIER LETTER HIGH TONE BAR + U_MODIFIER_LETTER_MID_TONE_BAR = 0x02E7, // U+02E7 MODIFIER LETTER MID TONE BAR + U_MODIFIER_LETTER_LOW_TONE_BAR = 0x02E8, // U+02E8 MODIFIER LETTER LOW TONE BAR + U_MODIFIER_LETTER_EXTRA_LOW_TONE_BAR = 0x02E9, // U+02E9 MODIFIER LETTER EXTRA-LOW TONE BAR + U_MODIFIER_LETTER_YIN_DEPARTING_TONE_MARK = 0x02EA, // U+02EA MODIFIER LETTER YIN DEPARTING TONE MARK + U_MODIFIER_LETTER_YANG_DEPARTING_TONE_MARK = 0x02EB, // U+02EB MODIFIER LETTER YANG DEPARTING TONE MARK + U_MODIFIER_LETTER_UNASPIRATED = 0x02ED, // U+02ED MODIFIER LETTER UNASPIRATED + U_MODIFIER_LETTER_LOW_DOWN_ARROWHEAD = 0x02EF, // U+02EF MODIFIER LETTER LOW DOWN ARROWHEAD + U_MODIFIER_LETTER_LOW_UP_ARROWHEAD = 0x02F0, // U+02F0 MODIFIER LETTER LOW UP ARROWHEAD + U_MODIFIER_LETTER_LOW_LEFT_ARROWHEAD = 0x02F1, // U+02F1 MODIFIER LETTER LOW LEFT ARROWHEAD + U_MODIFIER_LETTER_LOW_RIGHT_ARROWHEAD = 0x02F2, // U+02F2 MODIFIER LETTER LOW RIGHT ARROWHEAD + U_MODIFIER_LETTER_LOW_RING = 0x02F3, // U+02F3 MODIFIER LETTER LOW RING + U_MODIFIER_LETTER_MIDDLE_GRAVE_ACCENT = 0x02F4, // U+02F4 MODIFIER LETTER MIDDLE GRAVE ACCENT + U_MODIFIER_LETTER_MIDDLE_DOUBLE_GRAVE_ACCENT = 0x02F5, // U+02F5 MODIFIER LETTER MIDDLE DOUBLE GRAVE ACCENT + U_MODIFIER_LETTER_MIDDLE_DOUBLE_ACUTE_ACCENT = 0x02F6, // U+02F6 MODIFIER LETTER MIDDLE DOUBLE ACUTE ACCENT + U_MODIFIER_LETTER_LOW_TILDE = 0x02F7, // U+02F7 MODIFIER LETTER LOW TILDE + U_MODIFIER_LETTER_RAISED_COLON = 0x02F8, // U+02F8 MODIFIER LETTER RAISED COLON + U_MODIFIER_LETTER_BEGIN_HIGH_TONE = 0x02F9, // U+02F9 MODIFIER LETTER BEGIN HIGH TONE + U_MODIFIER_LETTER_END_HIGH_TONE = 0x02FA, // U+02FA MODIFIER LETTER END HIGH TONE + U_MODIFIER_LETTER_BEGIN_LOW_TONE = 0x02FB, // U+02FB MODIFIER LETTER BEGIN LOW TONE + U_MODIFIER_LETTER_END_LOW_TONE = 0x02FC, // U+02FC MODIFIER LETTER END LOW TONE + U_MODIFIER_LETTER_SHELF = 0x02FD, // U+02FD MODIFIER LETTER SHELF + U_MODIFIER_LETTER_OPEN_SHELF = 0x02FE, // U+02FE MODIFIER LETTER OPEN SHELF + U_MODIFIER_LETTER_LOW_LEFT_ARROW = 0x02FF, // U+02FF MODIFIER LETTER LOW LEFT ARROW + U_GREEK_LOWER_NUMERAL_SIGN = 0x0375, // U+0375 GREEK LOWER NUMERAL SIGN + U_GREEK_TONOS = 0x0384, // U+0384 GREEK TONOS + U_GREEK_DIALYTIKA_TONOS = 0x0385, // U+0385 GREEK DIALYTIKA TONOS + U_GREEK_KORONIS = 0x1FBD, // U+1FBD GREEK KORONIS + U_GREEK_PSILI = 0x1FBF, // U+1FBF GREEK PSILI + U_GREEK_PERISPOMENI = 0x1FC0, // U+1FC0 GREEK PERISPOMENI + U_GREEK_DIALYTIKA_AND_PERISPOMENI = 0x1FC1, // U+1FC1 GREEK DIALYTIKA AND PERISPOMENI + U_GREEK_PSILI_AND_VARIA = 0x1FCD, // U+1FCD GREEK PSILI AND VARIA + U_GREEK_PSILI_AND_OXIA = 0x1FCE, // U+1FCE GREEK PSILI AND OXIA + U_GREEK_PSILI_AND_PERISPOMENI = 0x1FCF, // U+1FCF GREEK PSILI AND PERISPOMENI + U_GREEK_DASIA_AND_VARIA = 0x1FDD, // U+1FDD GREEK DASIA AND VARIA + U_GREEK_DASIA_AND_OXIA = 0x1FDE, // U+1FDE GREEK DASIA AND OXIA + U_GREEK_DASIA_AND_PERISPOMENI = 0x1FDF, // U+1FDF GREEK DASIA AND PERISPOMENI + U_GREEK_DIALYTIKA_AND_VARIA = 0x1FED, // U+1FED GREEK DIALYTIKA AND VARIA + U_GREEK_DIALYTIKA_AND_OXIA = 0x1FEE, // U+1FEE GREEK DIALYTIKA AND OXIA + U_GREEK_VARIA = 0x1FEF, // U+1FEF GREEK VARIA + U_GREEK_OXIA = 0x1FFD, // U+1FFD GREEK OXIA + U_GREEK_DASIA = 0x1FFE, // U+1FFE GREEK DASIA + + U_IDEOGRAPHIC_FULL_STOP = 0x3002, // U+3002 IDEOGRAPHIC FULL STOP + U_LEFT_CORNER_BRACKET = 0x300C, // U+300C LEFT CORNER BRACKET + U_RIGHT_CORNER_BRACKET = 0x300D, // U+300D RIGHT CORNER BRACKET + U_LEFT_BLACK_LENTICULAR_BRACKET = 0x3010, // U+3010 LEFT BLACK LENTICULAR BRACKET + U_RIGHT_BLACK_LENTICULAR_BRACKET = 0x3011, // U+3011 RIGHT BLACK LENTICULAR BRACKET + + + U_OVERLINE = 0x203E, // Unicode Character 'OVERLINE' + + /** + * UTF-8 BOM + * Unicode Character 'ZERO WIDTH NO-BREAK SPACE' (U+FEFF) + * http://www.fileformat.info/info/unicode/char/feff/index.htm + */ + UTF8_BOM = 65279, + + U_FULLWIDTH_SEMICOLON = 0xFF1B, // U+FF1B FULLWIDTH SEMICOLON + U_FULLWIDTH_COMMA = 0xFF0C, // U+FF0C FULLWIDTH COMMA +} diff --git a/src/vs/base/common/codiconsUtil.ts b/src/vs/base/common/codiconsUtil.ts new file mode 100644 index 0000000000..ce7f9b2daf --- /dev/null +++ b/src/vs/base/common/codiconsUtil.ts @@ -0,0 +1,28 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import { ThemeIcon } from 'vs/base/common/themables'; +import { isString } from 'vs/base/common/types'; + + +const _codiconFontCharacters: { [id: string]: number } = Object.create(null); + +export function register(id: string, fontCharacter: number | string): ThemeIcon { + if (isString(fontCharacter)) { + const val = _codiconFontCharacters[fontCharacter]; + if (val === undefined) { + throw new Error(`${id} references an unknown codicon: ${fontCharacter}`); + } + fontCharacter = val; + } + _codiconFontCharacters[id] = fontCharacter; + return { id }; +} + +/** + * Only to be used by the iconRegistry. + */ +export function getCodiconFontCharacters(): { [id: string]: number } { + return _codiconFontCharacters; +} diff --git a/src/vs/base/common/collections.ts b/src/vs/base/common/collections.ts new file mode 100644 index 0000000000..d0df190c75 --- /dev/null +++ b/src/vs/base/common/collections.ts @@ -0,0 +1,140 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * An interface for a JavaScript object that + * acts a dictionary. The keys are strings. + */ +export type IStringDictionary = Record; + +/** + * An interface for a JavaScript object that + * acts a dictionary. The keys are numbers. + */ +export type INumberDictionary = Record; + +/** + * Groups the collection into a dictionary based on the provided + * group function. + */ +export function groupBy(data: V[], groupFn: (element: V) => K): Record { + const result: Record = Object.create(null); + for (const element of data) { + const key = groupFn(element); + let target = result[key]; + if (!target) { + target = result[key] = []; + } + target.push(element); + } + return result; +} + +export function diffSets(before: Set, after: Set): { removed: T[]; added: T[] } { + const removed: T[] = []; + const added: T[] = []; + for (const element of before) { + if (!after.has(element)) { + removed.push(element); + } + } + for (const element of after) { + if (!before.has(element)) { + added.push(element); + } + } + return { removed, added }; +} + +export function diffMaps(before: Map, after: Map): { removed: V[]; added: V[] } { + const removed: V[] = []; + const added: V[] = []; + for (const [index, value] of before) { + if (!after.has(index)) { + removed.push(value); + } + } + for (const [index, value] of after) { + if (!before.has(index)) { + added.push(value); + } + } + return { removed, added }; +} + +/** + * Computes the intersection of two sets. + * + * @param setA - The first set. + * @param setB - The second iterable. + * @returns A new set containing the elements that are in both `setA` and `setB`. + */ +export function intersection(setA: Set, setB: Iterable): Set { + const result = new Set(); + for (const elem of setB) { + if (setA.has(elem)) { + result.add(elem); + } + } + return result; +} + +export class SetWithKey implements Set { + private _map = new Map(); + + constructor(values: T[], private toKey: (t: T) => any) { + for (const value of values) { + this.add(value); + } + } + + get size(): number { + return this._map.size; + } + + add(value: T): this { + const key = this.toKey(value); + this._map.set(key, value); + return this; + } + + delete(value: T): boolean { + return this._map.delete(this.toKey(value)); + } + + has(value: T): boolean { + return this._map.has(this.toKey(value)); + } + + *entries(): IterableIterator<[T, T]> { + for (const entry of this._map.values()) { + yield [entry, entry]; + } + } + + keys(): IterableIterator { + return this.values(); + } + + *values(): IterableIterator { + for (const entry of this._map.values()) { + yield entry; + } + } + + clear(): void { + this._map.clear(); + } + + forEach(callbackfn: (value: T, value2: T, set: Set) => void, thisArg?: any): void { + this._map.forEach(entry => callbackfn.call(thisArg, entry, entry, this)); + } + + [Symbol.iterator](): IterableIterator { + return this.values(); + } + + [Symbol.toStringTag]: string = 'SetWithKey'; +} diff --git a/src/vs/base/common/color.ts b/src/vs/base/common/color.ts new file mode 100644 index 0000000000..750cfe7dc7 --- /dev/null +++ b/src/vs/base/common/color.ts @@ -0,0 +1,633 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CharCode } from 'vs/base/common/charCode'; + +function roundFloat(number: number, decimalPoints: number): number { + const decimal = Math.pow(10, decimalPoints); + return Math.round(number * decimal) / decimal; +} + +export class RGBA { + _rgbaBrand: void = undefined; + + /** + * Red: integer in [0-255] + */ + readonly r: number; + + /** + * Green: integer in [0-255] + */ + readonly g: number; + + /** + * Blue: integer in [0-255] + */ + readonly b: number; + + /** + * Alpha: float in [0-1] + */ + readonly a: number; + + constructor(r: number, g: number, b: number, a: number = 1) { + this.r = Math.min(255, Math.max(0, r)) | 0; + this.g = Math.min(255, Math.max(0, g)) | 0; + this.b = Math.min(255, Math.max(0, b)) | 0; + this.a = roundFloat(Math.max(Math.min(1, a), 0), 3); + } + + static equals(a: RGBA, b: RGBA): boolean { + return a.r === b.r && a.g === b.g && a.b === b.b && a.a === b.a; + } +} + +export class HSLA { + + _hslaBrand: void = undefined; + + /** + * Hue: integer in [0, 360] + */ + readonly h: number; + + /** + * Saturation: float in [0, 1] + */ + readonly s: number; + + /** + * Luminosity: float in [0, 1] + */ + readonly l: number; + + /** + * Alpha: float in [0, 1] + */ + readonly a: number; + + constructor(h: number, s: number, l: number, a: number) { + this.h = Math.max(Math.min(360, h), 0) | 0; + this.s = roundFloat(Math.max(Math.min(1, s), 0), 3); + this.l = roundFloat(Math.max(Math.min(1, l), 0), 3); + this.a = roundFloat(Math.max(Math.min(1, a), 0), 3); + } + + static equals(a: HSLA, b: HSLA): boolean { + return a.h === b.h && a.s === b.s && a.l === b.l && a.a === b.a; + } + + /** + * Converts an RGB color value to HSL. Conversion formula + * adapted from http://en.wikipedia.org/wiki/HSL_color_space. + * Assumes r, g, and b are contained in the set [0, 255] and + * returns h in the set [0, 360], s, and l in the set [0, 1]. + */ + static fromRGBA(rgba: RGBA): HSLA { + const r = rgba.r / 255; + const g = rgba.g / 255; + const b = rgba.b / 255; + const a = rgba.a; + + const max = Math.max(r, g, b); + const min = Math.min(r, g, b); + let h = 0; + let s = 0; + const l = (min + max) / 2; + const chroma = max - min; + + if (chroma > 0) { + s = Math.min((l <= 0.5 ? chroma / (2 * l) : chroma / (2 - (2 * l))), 1); + + switch (max) { + case r: h = (g - b) / chroma + (g < b ? 6 : 0); break; + case g: h = (b - r) / chroma + 2; break; + case b: h = (r - g) / chroma + 4; break; + } + + h *= 60; + h = Math.round(h); + } + return new HSLA(h, s, l, a); + } + + private static _hue2rgb(p: number, q: number, t: number): number { + if (t < 0) { + t += 1; + } + if (t > 1) { + t -= 1; + } + if (t < 1 / 6) { + return p + (q - p) * 6 * t; + } + if (t < 1 / 2) { + return q; + } + if (t < 2 / 3) { + return p + (q - p) * (2 / 3 - t) * 6; + } + return p; + } + + /** + * Converts an HSL color value to RGB. Conversion formula + * adapted from http://en.wikipedia.org/wiki/HSL_color_space. + * Assumes h in the set [0, 360] s, and l are contained in the set [0, 1] and + * returns r, g, and b in the set [0, 255]. + */ + static toRGBA(hsla: HSLA): RGBA { + const h = hsla.h / 360; + const { s, l, a } = hsla; + let r: number, g: number, b: number; + + if (s === 0) { + r = g = b = l; // achromatic + } else { + const q = l < 0.5 ? l * (1 + s) : l + s - l * s; + const p = 2 * l - q; + r = HSLA._hue2rgb(p, q, h + 1 / 3); + g = HSLA._hue2rgb(p, q, h); + b = HSLA._hue2rgb(p, q, h - 1 / 3); + } + + return new RGBA(Math.round(r * 255), Math.round(g * 255), Math.round(b * 255), a); + } +} + +export class HSVA { + + _hsvaBrand: void = undefined; + + /** + * Hue: integer in [0, 360] + */ + readonly h: number; + + /** + * Saturation: float in [0, 1] + */ + readonly s: number; + + /** + * Value: float in [0, 1] + */ + readonly v: number; + + /** + * Alpha: float in [0, 1] + */ + readonly a: number; + + constructor(h: number, s: number, v: number, a: number) { + this.h = Math.max(Math.min(360, h), 0) | 0; + this.s = roundFloat(Math.max(Math.min(1, s), 0), 3); + this.v = roundFloat(Math.max(Math.min(1, v), 0), 3); + this.a = roundFloat(Math.max(Math.min(1, a), 0), 3); + } + + static equals(a: HSVA, b: HSVA): boolean { + return a.h === b.h && a.s === b.s && a.v === b.v && a.a === b.a; + } + + // from http://www.rapidtables.com/convert/color/rgb-to-hsv.htm + static fromRGBA(rgba: RGBA): HSVA { + const r = rgba.r / 255; + const g = rgba.g / 255; + const b = rgba.b / 255; + const cmax = Math.max(r, g, b); + const cmin = Math.min(r, g, b); + const delta = cmax - cmin; + const s = cmax === 0 ? 0 : (delta / cmax); + let m: number; + + if (delta === 0) { + m = 0; + } else if (cmax === r) { + m = ((((g - b) / delta) % 6) + 6) % 6; + } else if (cmax === g) { + m = ((b - r) / delta) + 2; + } else { + m = ((r - g) / delta) + 4; + } + + return new HSVA(Math.round(m * 60), s, cmax, rgba.a); + } + + // from http://www.rapidtables.com/convert/color/hsv-to-rgb.htm + static toRGBA(hsva: HSVA): RGBA { + const { h, s, v, a } = hsva; + const c = v * s; + const x = c * (1 - Math.abs((h / 60) % 2 - 1)); + const m = v - c; + let [r, g, b] = [0, 0, 0]; + + if (h < 60) { + r = c; + g = x; + } else if (h < 120) { + r = x; + g = c; + } else if (h < 180) { + g = c; + b = x; + } else if (h < 240) { + g = x; + b = c; + } else if (h < 300) { + r = x; + b = c; + } else if (h <= 360) { + r = c; + b = x; + } + + r = Math.round((r + m) * 255); + g = Math.round((g + m) * 255); + b = Math.round((b + m) * 255); + + return new RGBA(r, g, b, a); + } +} + +export class Color { + + static fromHex(hex: string): Color { + return Color.Format.CSS.parseHex(hex) || Color.red; + } + + static equals(a: Color | null, b: Color | null): boolean { + if (!a && !b) { + return true; + } + if (!a || !b) { + return false; + } + return a.equals(b); + } + + readonly rgba: RGBA; + private _hsla?: HSLA; + get hsla(): HSLA { + if (this._hsla) { + return this._hsla; + } else { + return HSLA.fromRGBA(this.rgba); + } + } + + private _hsva?: HSVA; + get hsva(): HSVA { + if (this._hsva) { + return this._hsva; + } + return HSVA.fromRGBA(this.rgba); + } + + constructor(arg: RGBA | HSLA | HSVA) { + if (!arg) { + throw new Error('Color needs a value'); + } else if (arg instanceof RGBA) { + this.rgba = arg; + } else if (arg instanceof HSLA) { + this._hsla = arg; + this.rgba = HSLA.toRGBA(arg); + } else if (arg instanceof HSVA) { + this._hsva = arg; + this.rgba = HSVA.toRGBA(arg); + } else { + throw new Error('Invalid color ctor argument'); + } + } + + equals(other: Color | null): boolean { + return !!other && RGBA.equals(this.rgba, other.rgba) && HSLA.equals(this.hsla, other.hsla) && HSVA.equals(this.hsva, other.hsva); + } + + /** + * http://www.w3.org/TR/WCAG20/#relativeluminancedef + * Returns the number in the set [0, 1]. O => Darkest Black. 1 => Lightest white. + */ + getRelativeLuminance(): number { + const R = Color._relativeLuminanceForComponent(this.rgba.r); + const G = Color._relativeLuminanceForComponent(this.rgba.g); + const B = Color._relativeLuminanceForComponent(this.rgba.b); + const luminance = 0.2126 * R + 0.7152 * G + 0.0722 * B; + + return roundFloat(luminance, 4); + } + + private static _relativeLuminanceForComponent(color: number): number { + const c = color / 255; + return (c <= 0.03928) ? c / 12.92 : Math.pow(((c + 0.055) / 1.055), 2.4); + } + + /** + * http://www.w3.org/TR/WCAG20/#contrast-ratiodef + * Returns the contrast ration number in the set [1, 21]. + */ + getContrastRatio(another: Color): number { + const lum1 = this.getRelativeLuminance(); + const lum2 = another.getRelativeLuminance(); + return lum1 > lum2 ? (lum1 + 0.05) / (lum2 + 0.05) : (lum2 + 0.05) / (lum1 + 0.05); + } + + /** + * http://24ways.org/2010/calculating-color-contrast + * Return 'true' if darker color otherwise 'false' + */ + isDarker(): boolean { + const yiq = (this.rgba.r * 299 + this.rgba.g * 587 + this.rgba.b * 114) / 1000; + return yiq < 128; + } + + /** + * http://24ways.org/2010/calculating-color-contrast + * Return 'true' if lighter color otherwise 'false' + */ + isLighter(): boolean { + const yiq = (this.rgba.r * 299 + this.rgba.g * 587 + this.rgba.b * 114) / 1000; + return yiq >= 128; + } + + isLighterThan(another: Color): boolean { + const lum1 = this.getRelativeLuminance(); + const lum2 = another.getRelativeLuminance(); + return lum1 > lum2; + } + + isDarkerThan(another: Color): boolean { + const lum1 = this.getRelativeLuminance(); + const lum2 = another.getRelativeLuminance(); + return lum1 < lum2; + } + + lighten(factor: number): Color { + return new Color(new HSLA(this.hsla.h, this.hsla.s, this.hsla.l + this.hsla.l * factor, this.hsla.a)); + } + + darken(factor: number): Color { + return new Color(new HSLA(this.hsla.h, this.hsla.s, this.hsla.l - this.hsla.l * factor, this.hsla.a)); + } + + transparent(factor: number): Color { + const { r, g, b, a } = this.rgba; + return new Color(new RGBA(r, g, b, a * factor)); + } + + isTransparent(): boolean { + return this.rgba.a === 0; + } + + isOpaque(): boolean { + return this.rgba.a === 1; + } + + opposite(): Color { + return new Color(new RGBA(255 - this.rgba.r, 255 - this.rgba.g, 255 - this.rgba.b, this.rgba.a)); + } + + blend(c: Color): Color { + const rgba = c.rgba; + + // Convert to 0..1 opacity + const thisA = this.rgba.a; + const colorA = rgba.a; + + const a = thisA + colorA * (1 - thisA); + if (a < 1e-6) { + return Color.transparent; + } + + const r = this.rgba.r * thisA / a + rgba.r * colorA * (1 - thisA) / a; + const g = this.rgba.g * thisA / a + rgba.g * colorA * (1 - thisA) / a; + const b = this.rgba.b * thisA / a + rgba.b * colorA * (1 - thisA) / a; + + return new Color(new RGBA(r, g, b, a)); + } + + makeOpaque(opaqueBackground: Color): Color { + if (this.isOpaque() || opaqueBackground.rgba.a !== 1) { + // only allow to blend onto a non-opaque color onto a opaque color + return this; + } + + const { r, g, b, a } = this.rgba; + + // https://stackoverflow.com/questions/12228548/finding-equivalent-color-with-opacity + return new Color(new RGBA( + opaqueBackground.rgba.r - a * (opaqueBackground.rgba.r - r), + opaqueBackground.rgba.g - a * (opaqueBackground.rgba.g - g), + opaqueBackground.rgba.b - a * (opaqueBackground.rgba.b - b), + 1 + )); + } + + flatten(...backgrounds: Color[]): Color { + const background = backgrounds.reduceRight((accumulator, color) => { + return Color._flatten(color, accumulator); + }); + return Color._flatten(this, background); + } + + private static _flatten(foreground: Color, background: Color) { + const backgroundAlpha = 1 - foreground.rgba.a; + return new Color(new RGBA( + backgroundAlpha * background.rgba.r + foreground.rgba.a * foreground.rgba.r, + backgroundAlpha * background.rgba.g + foreground.rgba.a * foreground.rgba.g, + backgroundAlpha * background.rgba.b + foreground.rgba.a * foreground.rgba.b + )); + } + + private _toString?: string; + toString(): string { + if (!this._toString) { + this._toString = Color.Format.CSS.format(this); + } + return this._toString; + } + + static getLighterColor(of: Color, relative: Color, factor?: number): Color { + if (of.isLighterThan(relative)) { + return of; + } + factor = factor ? factor : 0.5; + const lum1 = of.getRelativeLuminance(); + const lum2 = relative.getRelativeLuminance(); + factor = factor * (lum2 - lum1) / lum2; + return of.lighten(factor); + } + + static getDarkerColor(of: Color, relative: Color, factor?: number): Color { + if (of.isDarkerThan(relative)) { + return of; + } + factor = factor ? factor : 0.5; + const lum1 = of.getRelativeLuminance(); + const lum2 = relative.getRelativeLuminance(); + factor = factor * (lum1 - lum2) / lum1; + return of.darken(factor); + } + + static readonly white = new Color(new RGBA(255, 255, 255, 1)); + static readonly black = new Color(new RGBA(0, 0, 0, 1)); + static readonly red = new Color(new RGBA(255, 0, 0, 1)); + static readonly blue = new Color(new RGBA(0, 0, 255, 1)); + static readonly green = new Color(new RGBA(0, 255, 0, 1)); + static readonly cyan = new Color(new RGBA(0, 255, 255, 1)); + static readonly lightgrey = new Color(new RGBA(211, 211, 211, 1)); + static readonly transparent = new Color(new RGBA(0, 0, 0, 0)); +} + +export namespace Color { + export namespace Format { + export namespace CSS { + + export function formatRGB(color: Color): string { + if (color.rgba.a === 1) { + return `rgb(${color.rgba.r}, ${color.rgba.g}, ${color.rgba.b})`; + } + + return Color.Format.CSS.formatRGBA(color); + } + + export function formatRGBA(color: Color): string { + return `rgba(${color.rgba.r}, ${color.rgba.g}, ${color.rgba.b}, ${+(color.rgba.a).toFixed(2)})`; + } + + export function formatHSL(color: Color): string { + if (color.hsla.a === 1) { + return `hsl(${color.hsla.h}, ${(color.hsla.s * 100).toFixed(2)}%, ${(color.hsla.l * 100).toFixed(2)}%)`; + } + + return Color.Format.CSS.formatHSLA(color); + } + + export function formatHSLA(color: Color): string { + return `hsla(${color.hsla.h}, ${(color.hsla.s * 100).toFixed(2)}%, ${(color.hsla.l * 100).toFixed(2)}%, ${color.hsla.a.toFixed(2)})`; + } + + function _toTwoDigitHex(n: number): string { + const r = n.toString(16); + return r.length !== 2 ? '0' + r : r; + } + + /** + * Formats the color as #RRGGBB + */ + export function formatHex(color: Color): string { + return `#${_toTwoDigitHex(color.rgba.r)}${_toTwoDigitHex(color.rgba.g)}${_toTwoDigitHex(color.rgba.b)}`; + } + + /** + * Formats the color as #RRGGBBAA + * If 'compact' is set, colors without transparancy will be printed as #RRGGBB + */ + export function formatHexA(color: Color, compact = false): string { + if (compact && color.rgba.a === 1) { + return Color.Format.CSS.formatHex(color); + } + + return `#${_toTwoDigitHex(color.rgba.r)}${_toTwoDigitHex(color.rgba.g)}${_toTwoDigitHex(color.rgba.b)}${_toTwoDigitHex(Math.round(color.rgba.a * 255))}`; + } + + /** + * The default format will use HEX if opaque and RGBA otherwise. + */ + export function format(color: Color): string { + if (color.isOpaque()) { + return Color.Format.CSS.formatHex(color); + } + + return Color.Format.CSS.formatRGBA(color); + } + + /** + * Converts an Hex color value to a Color. + * returns r, g, and b are contained in the set [0, 255] + * @param hex string (#RGB, #RGBA, #RRGGBB or #RRGGBBAA). + */ + export function parseHex(hex: string): Color | null { + const length = hex.length; + + if (length === 0) { + // Invalid color + return null; + } + + if (hex.charCodeAt(0) !== CharCode.Hash) { + // Does not begin with a # + return null; + } + + if (length === 7) { + // #RRGGBB format + const r = 16 * _parseHexDigit(hex.charCodeAt(1)) + _parseHexDigit(hex.charCodeAt(2)); + const g = 16 * _parseHexDigit(hex.charCodeAt(3)) + _parseHexDigit(hex.charCodeAt(4)); + const b = 16 * _parseHexDigit(hex.charCodeAt(5)) + _parseHexDigit(hex.charCodeAt(6)); + return new Color(new RGBA(r, g, b, 1)); + } + + if (length === 9) { + // #RRGGBBAA format + const r = 16 * _parseHexDigit(hex.charCodeAt(1)) + _parseHexDigit(hex.charCodeAt(2)); + const g = 16 * _parseHexDigit(hex.charCodeAt(3)) + _parseHexDigit(hex.charCodeAt(4)); + const b = 16 * _parseHexDigit(hex.charCodeAt(5)) + _parseHexDigit(hex.charCodeAt(6)); + const a = 16 * _parseHexDigit(hex.charCodeAt(7)) + _parseHexDigit(hex.charCodeAt(8)); + return new Color(new RGBA(r, g, b, a / 255)); + } + + if (length === 4) { + // #RGB format + const r = _parseHexDigit(hex.charCodeAt(1)); + const g = _parseHexDigit(hex.charCodeAt(2)); + const b = _parseHexDigit(hex.charCodeAt(3)); + return new Color(new RGBA(16 * r + r, 16 * g + g, 16 * b + b)); + } + + if (length === 5) { + // #RGBA format + const r = _parseHexDigit(hex.charCodeAt(1)); + const g = _parseHexDigit(hex.charCodeAt(2)); + const b = _parseHexDigit(hex.charCodeAt(3)); + const a = _parseHexDigit(hex.charCodeAt(4)); + return new Color(new RGBA(16 * r + r, 16 * g + g, 16 * b + b, (16 * a + a) / 255)); + } + + // Invalid color + return null; + } + + function _parseHexDigit(charCode: CharCode): number { + switch (charCode) { + case CharCode.Digit0: return 0; + case CharCode.Digit1: return 1; + case CharCode.Digit2: return 2; + case CharCode.Digit3: return 3; + case CharCode.Digit4: return 4; + case CharCode.Digit5: return 5; + case CharCode.Digit6: return 6; + case CharCode.Digit7: return 7; + case CharCode.Digit8: return 8; + case CharCode.Digit9: return 9; + case CharCode.a: return 10; + case CharCode.A: return 10; + case CharCode.b: return 11; + case CharCode.B: return 11; + case CharCode.c: return 12; + case CharCode.C: return 12; + case CharCode.d: return 13; + case CharCode.D: return 13; + case CharCode.e: return 14; + case CharCode.E: return 14; + case CharCode.f: return 15; + case CharCode.F: return 15; + } + return 0; + } + } + } +} diff --git a/src/vs/base/common/comparers.ts b/src/vs/base/common/comparers.ts new file mode 100644 index 0000000000..e515abd612 --- /dev/null +++ b/src/vs/base/common/comparers.ts @@ -0,0 +1,355 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Lazy } from 'vs/base/common/lazy'; +import { sep } from 'vs/base/common/path'; + +// When comparing large numbers of strings it's better for performance to create an +// Intl.Collator object and use the function provided by its compare property +// than it is to use String.prototype.localeCompare() + +// A collator with numeric sorting enabled, and no sensitivity to case, accents or diacritics. +const intlFileNameCollatorBaseNumeric: Lazy<{ collator: Intl.Collator; collatorIsNumeric: boolean }> = new Lazy(() => { + const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }); + return { + collator, + collatorIsNumeric: collator.resolvedOptions().numeric + }; +}); + +// A collator with numeric sorting enabled. +const intlFileNameCollatorNumeric: Lazy<{ collator: Intl.Collator }> = new Lazy(() => { + const collator = new Intl.Collator(undefined, { numeric: true }); + return { + collator + }; +}); + +// A collator with numeric sorting enabled, and sensitivity to accents and diacritics but not case. +const intlFileNameCollatorNumericCaseInsensitive: Lazy<{ collator: Intl.Collator }> = new Lazy(() => { + const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'accent' }); + return { + collator + }; +}); + +/** Compares filenames without distinguishing the name from the extension. Disambiguates by unicode comparison. */ +export function compareFileNames(one: string | null, other: string | null, caseSensitive = false): number { + const a = one || ''; + const b = other || ''; + const result = intlFileNameCollatorBaseNumeric.value.collator.compare(a, b); + + // Using the numeric option will make compare(`foo1`, `foo01`) === 0. Disambiguate. + if (intlFileNameCollatorBaseNumeric.value.collatorIsNumeric && result === 0 && a !== b) { + return a < b ? -1 : 1; + } + + return result; +} + +/** Compares full filenames without grouping by case. */ +export function compareFileNamesDefault(one: string | null, other: string | null): number { + const collatorNumeric = intlFileNameCollatorNumeric.value.collator; + one = one || ''; + other = other || ''; + + return compareAndDisambiguateByLength(collatorNumeric, one, other); +} + +/** Compares full filenames grouping uppercase names before lowercase. */ +export function compareFileNamesUpper(one: string | null, other: string | null) { + const collatorNumeric = intlFileNameCollatorNumeric.value.collator; + one = one || ''; + other = other || ''; + + return compareCaseUpperFirst(one, other) || compareAndDisambiguateByLength(collatorNumeric, one, other); +} + +/** Compares full filenames grouping lowercase names before uppercase. */ +export function compareFileNamesLower(one: string | null, other: string | null) { + const collatorNumeric = intlFileNameCollatorNumeric.value.collator; + one = one || ''; + other = other || ''; + + return compareCaseLowerFirst(one, other) || compareAndDisambiguateByLength(collatorNumeric, one, other); +} + +/** Compares full filenames by unicode value. */ +export function compareFileNamesUnicode(one: string | null, other: string | null) { + one = one || ''; + other = other || ''; + + if (one === other) { + return 0; + } + + return one < other ? -1 : 1; +} + +/** Compares filenames by extension, then by name. Disambiguates by unicode comparison. */ +export function compareFileExtensions(one: string | null, other: string | null): number { + const [oneName, oneExtension] = extractNameAndExtension(one); + const [otherName, otherExtension] = extractNameAndExtension(other); + + let result = intlFileNameCollatorBaseNumeric.value.collator.compare(oneExtension, otherExtension); + + if (result === 0) { + // Using the numeric option will make compare(`foo1`, `foo01`) === 0. Disambiguate. + if (intlFileNameCollatorBaseNumeric.value.collatorIsNumeric && oneExtension !== otherExtension) { + return oneExtension < otherExtension ? -1 : 1; + } + + // Extensions are equal, compare filenames + result = intlFileNameCollatorBaseNumeric.value.collator.compare(oneName, otherName); + + if (intlFileNameCollatorBaseNumeric.value.collatorIsNumeric && result === 0 && oneName !== otherName) { + return oneName < otherName ? -1 : 1; + } + } + + return result; +} + +/** Compares filenames by extension, then by full filename. Mixes uppercase and lowercase names together. */ +export function compareFileExtensionsDefault(one: string | null, other: string | null): number { + one = one || ''; + other = other || ''; + const oneExtension = extractExtension(one); + const otherExtension = extractExtension(other); + const collatorNumeric = intlFileNameCollatorNumeric.value.collator; + const collatorNumericCaseInsensitive = intlFileNameCollatorNumericCaseInsensitive.value.collator; + + return compareAndDisambiguateByLength(collatorNumericCaseInsensitive, oneExtension, otherExtension) || + compareAndDisambiguateByLength(collatorNumeric, one, other); +} + +/** Compares filenames by extension, then case, then full filename. Groups uppercase names before lowercase. */ +export function compareFileExtensionsUpper(one: string | null, other: string | null): number { + one = one || ''; + other = other || ''; + const oneExtension = extractExtension(one); + const otherExtension = extractExtension(other); + const collatorNumeric = intlFileNameCollatorNumeric.value.collator; + const collatorNumericCaseInsensitive = intlFileNameCollatorNumericCaseInsensitive.value.collator; + + return compareAndDisambiguateByLength(collatorNumericCaseInsensitive, oneExtension, otherExtension) || + compareCaseUpperFirst(one, other) || + compareAndDisambiguateByLength(collatorNumeric, one, other); +} + +/** Compares filenames by extension, then case, then full filename. Groups lowercase names before uppercase. */ +export function compareFileExtensionsLower(one: string | null, other: string | null): number { + one = one || ''; + other = other || ''; + const oneExtension = extractExtension(one); + const otherExtension = extractExtension(other); + const collatorNumeric = intlFileNameCollatorNumeric.value.collator; + const collatorNumericCaseInsensitive = intlFileNameCollatorNumericCaseInsensitive.value.collator; + + return compareAndDisambiguateByLength(collatorNumericCaseInsensitive, oneExtension, otherExtension) || + compareCaseLowerFirst(one, other) || + compareAndDisambiguateByLength(collatorNumeric, one, other); +} + +/** Compares filenames by case-insensitive extension unicode value, then by full filename unicode value. */ +export function compareFileExtensionsUnicode(one: string | null, other: string | null) { + one = one || ''; + other = other || ''; + const oneExtension = extractExtension(one).toLowerCase(); + const otherExtension = extractExtension(other).toLowerCase(); + + // Check for extension differences + if (oneExtension !== otherExtension) { + return oneExtension < otherExtension ? -1 : 1; + } + + // Check for full filename differences. + if (one !== other) { + return one < other ? -1 : 1; + } + + return 0; +} + +const FileNameMatch = /^(.*?)(\.([^.]*))?$/; + +/** Extracts the name and extension from a full filename, with optional special handling for dotfiles */ +function extractNameAndExtension(str?: string | null, dotfilesAsNames = false): [string, string] { + const match = str ? FileNameMatch.exec(str) as Array : ([] as Array); + + let result: [string, string] = [(match && match[1]) || '', (match && match[3]) || '']; + + // if the dotfilesAsNames option is selected, treat an empty filename with an extension + // or a filename that starts with a dot, as a dotfile name + if (dotfilesAsNames && (!result[0] && result[1] || result[0] && result[0].charAt(0) === '.')) { + result = [result[0] + '.' + result[1], '']; + } + + return result; +} + +/** Extracts the extension from a full filename. Treats dotfiles as names, not extensions. */ +function extractExtension(str?: string | null): string { + const match = str ? FileNameMatch.exec(str) as Array : ([] as Array); + + return (match && match[1] && match[1].charAt(0) !== '.' && match[3]) || ''; +} + +function compareAndDisambiguateByLength(collator: Intl.Collator, one: string, other: string) { + // Check for differences + const result = collator.compare(one, other); + if (result !== 0) { + return result; + } + + // In a numeric comparison, `foo1` and `foo01` will compare as equivalent. + // Disambiguate by sorting the shorter string first. + if (one.length !== other.length) { + return one.length < other.length ? -1 : 1; + } + + return 0; +} + +/** @returns `true` if the string is starts with a lowercase letter. Otherwise, `false`. */ +function startsWithLower(string: string) { + const character = string.charAt(0); + + return (character.toLocaleUpperCase() !== character) ? true : false; +} + +/** @returns `true` if the string starts with an uppercase letter. Otherwise, `false`. */ +function startsWithUpper(string: string) { + const character = string.charAt(0); + + return (character.toLocaleLowerCase() !== character) ? true : false; +} + +/** + * Compares the case of the provided strings - lowercase before uppercase + * + * @returns + * ```text + * -1 if one is lowercase and other is uppercase + * 1 if one is uppercase and other is lowercase + * 0 otherwise + * ``` + */ +function compareCaseLowerFirst(one: string, other: string): number { + if (startsWithLower(one) && startsWithUpper(other)) { + return -1; + } + return (startsWithUpper(one) && startsWithLower(other)) ? 1 : 0; +} + +/** + * Compares the case of the provided strings - uppercase before lowercase + * + * @returns + * ```text + * -1 if one is uppercase and other is lowercase + * 1 if one is lowercase and other is uppercase + * 0 otherwise + * ``` + */ +function compareCaseUpperFirst(one: string, other: string): number { + if (startsWithUpper(one) && startsWithLower(other)) { + return -1; + } + return (startsWithLower(one) && startsWithUpper(other)) ? 1 : 0; +} + +function comparePathComponents(one: string, other: string, caseSensitive = false): number { + if (!caseSensitive) { + one = one && one.toLowerCase(); + other = other && other.toLowerCase(); + } + + if (one === other) { + return 0; + } + + return one < other ? -1 : 1; +} + +export function comparePaths(one: string, other: string, caseSensitive = false): number { + const oneParts = one.split(sep); + const otherParts = other.split(sep); + + const lastOne = oneParts.length - 1; + const lastOther = otherParts.length - 1; + let endOne: boolean, endOther: boolean; + + for (let i = 0; ; i++) { + endOne = lastOne === i; + endOther = lastOther === i; + + if (endOne && endOther) { + return compareFileNames(oneParts[i], otherParts[i], caseSensitive); + } else if (endOne) { + return -1; + } else if (endOther) { + return 1; + } + + const result = comparePathComponents(oneParts[i], otherParts[i], caseSensitive); + + if (result !== 0) { + return result; + } + } +} + +export function compareAnything(one: string, other: string, lookFor: string): number { + const elementAName = one.toLowerCase(); + const elementBName = other.toLowerCase(); + + // Sort prefix matches over non prefix matches + const prefixCompare = compareByPrefix(one, other, lookFor); + if (prefixCompare) { + return prefixCompare; + } + + // Sort suffix matches over non suffix matches + const elementASuffixMatch = elementAName.endsWith(lookFor); + const elementBSuffixMatch = elementBName.endsWith(lookFor); + if (elementASuffixMatch !== elementBSuffixMatch) { + return elementASuffixMatch ? -1 : 1; + } + + // Understand file names + const r = compareFileNames(elementAName, elementBName); + if (r !== 0) { + return r; + } + + // Compare by name + return elementAName.localeCompare(elementBName); +} + +export function compareByPrefix(one: string, other: string, lookFor: string): number { + const elementAName = one.toLowerCase(); + const elementBName = other.toLowerCase(); + + // Sort prefix matches over non prefix matches + const elementAPrefixMatch = elementAName.startsWith(lookFor); + const elementBPrefixMatch = elementBName.startsWith(lookFor); + if (elementAPrefixMatch !== elementBPrefixMatch) { + return elementAPrefixMatch ? -1 : 1; + } + + // Same prefix: Sort shorter matches to the top to have those on top that match more precisely + else if (elementAPrefixMatch && elementBPrefixMatch) { + if (elementAName.length < elementBName.length) { + return -1; + } + + if (elementAName.length > elementBName.length) { + return 1; + } + } + + return 0; +} diff --git a/src/vs/base/common/controlFlow.ts b/src/vs/base/common/controlFlow.ts new file mode 100644 index 0000000000..2c4d020dd9 --- /dev/null +++ b/src/vs/base/common/controlFlow.ts @@ -0,0 +1,69 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import { BugIndicatingError } from 'vs/base/common/errors'; + +/* + * This file contains helper classes to manage control flow. +*/ + +/** + * Prevents code from being re-entrant. +*/ +export class ReentrancyBarrier { + private _isOccupied = false; + + /** + * Calls `runner` if the barrier is not occupied. + * During the call, the barrier becomes occupied. + */ + public runExclusivelyOrSkip(runner: () => void): void { + if (this._isOccupied) { + return; + } + this._isOccupied = true; + try { + runner(); + } finally { + this._isOccupied = false; + } + } + + /** + * Calls `runner`. If the barrier is occupied, throws an error. + * During the call, the barrier becomes active. + */ + public runExclusivelyOrThrow(runner: () => void): void { + if (this._isOccupied) { + throw new BugIndicatingError(`ReentrancyBarrier: reentrant call detected!`); + } + this._isOccupied = true; + try { + runner(); + } finally { + this._isOccupied = false; + } + } + + /** + * Indicates if some runner occupies this barrier. + */ + public get isOccupied() { + return this._isOccupied; + } + + public makeExclusiveOrSkip(fn: TFunction): TFunction { + return ((...args: any[]) => { + if (this._isOccupied) { + return; + } + this._isOccupied = true; + try { + return fn(...args); + } finally { + this._isOccupied = false; + } + }) as any; + } +} diff --git a/src/vs/base/common/date.ts b/src/vs/base/common/date.ts new file mode 100644 index 0000000000..2ae03a711a --- /dev/null +++ b/src/vs/base/common/date.ts @@ -0,0 +1,242 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { localize } from 'vs/nls'; + +const minute = 60; +const hour = minute * 60; +const day = hour * 24; +const week = day * 7; +const month = day * 30; +const year = day * 365; + +/** + * Create a localized difference of the time between now and the specified date. + * @param date The date to generate the difference from. + * @param appendAgoLabel Whether to append the " ago" to the end. + * @param useFullTimeWords Whether to use full words (eg. seconds) instead of + * shortened (eg. secs). + * @param disallowNow Whether to disallow the string "now" when the difference + * is less than 30 seconds. + */ +export function fromNow(date: number | Date, appendAgoLabel?: boolean, useFullTimeWords?: boolean, disallowNow?: boolean): string { + if (typeof date !== 'number') { + date = date.getTime(); + } + + const seconds = Math.round((new Date().getTime() - date) / 1000); + if (seconds < -30) { + return localize('date.fromNow.in', 'in {0}', fromNow(new Date().getTime() + seconds * 1000, false)); + } + + if (!disallowNow && seconds < 30) { + return localize('date.fromNow.now', 'now'); + } + + let value: number; + if (seconds < minute) { + value = seconds; + + if (appendAgoLabel) { + if (value === 1) { + return useFullTimeWords + ? localize('date.fromNow.seconds.singular.ago.fullWord', '{0} second ago', value) + : localize('date.fromNow.seconds.singular.ago', '{0} sec ago', value); + } else { + return useFullTimeWords + ? localize('date.fromNow.seconds.plural.ago.fullWord', '{0} seconds ago', value) + : localize('date.fromNow.seconds.plural.ago', '{0} secs ago', value); + } + } else { + if (value === 1) { + return useFullTimeWords + ? localize('date.fromNow.seconds.singular.fullWord', '{0} second', value) + : localize('date.fromNow.seconds.singular', '{0} sec', value); + } else { + return useFullTimeWords + ? localize('date.fromNow.seconds.plural.fullWord', '{0} seconds', value) + : localize('date.fromNow.seconds.plural', '{0} secs', value); + } + } + } + + if (seconds < hour) { + value = Math.floor(seconds / minute); + if (appendAgoLabel) { + if (value === 1) { + return useFullTimeWords + ? localize('date.fromNow.minutes.singular.ago.fullWord', '{0} minute ago', value) + : localize('date.fromNow.minutes.singular.ago', '{0} min ago', value); + } else { + return useFullTimeWords + ? localize('date.fromNow.minutes.plural.ago.fullWord', '{0} minutes ago', value) + : localize('date.fromNow.minutes.plural.ago', '{0} mins ago', value); + } + } else { + if (value === 1) { + return useFullTimeWords + ? localize('date.fromNow.minutes.singular.fullWord', '{0} minute', value) + : localize('date.fromNow.minutes.singular', '{0} min', value); + } else { + return useFullTimeWords + ? localize('date.fromNow.minutes.plural.fullWord', '{0} minutes', value) + : localize('date.fromNow.minutes.plural', '{0} mins', value); + } + } + } + + if (seconds < day) { + value = Math.floor(seconds / hour); + if (appendAgoLabel) { + if (value === 1) { + return useFullTimeWords + ? localize('date.fromNow.hours.singular.ago.fullWord', '{0} hour ago', value) + : localize('date.fromNow.hours.singular.ago', '{0} hr ago', value); + } else { + return useFullTimeWords + ? localize('date.fromNow.hours.plural.ago.fullWord', '{0} hours ago', value) + : localize('date.fromNow.hours.plural.ago', '{0} hrs ago', value); + } + } else { + if (value === 1) { + return useFullTimeWords + ? localize('date.fromNow.hours.singular.fullWord', '{0} hour', value) + : localize('date.fromNow.hours.singular', '{0} hr', value); + } else { + return useFullTimeWords + ? localize('date.fromNow.hours.plural.fullWord', '{0} hours', value) + : localize('date.fromNow.hours.plural', '{0} hrs', value); + } + } + } + + if (seconds < week) { + value = Math.floor(seconds / day); + if (appendAgoLabel) { + return value === 1 + ? localize('date.fromNow.days.singular.ago', '{0} day ago', value) + : localize('date.fromNow.days.plural.ago', '{0} days ago', value); + } else { + return value === 1 + ? localize('date.fromNow.days.singular', '{0} day', value) + : localize('date.fromNow.days.plural', '{0} days', value); + } + } + + if (seconds < month) { + value = Math.floor(seconds / week); + if (appendAgoLabel) { + if (value === 1) { + return useFullTimeWords + ? localize('date.fromNow.weeks.singular.ago.fullWord', '{0} week ago', value) + : localize('date.fromNow.weeks.singular.ago', '{0} wk ago', value); + } else { + return useFullTimeWords + ? localize('date.fromNow.weeks.plural.ago.fullWord', '{0} weeks ago', value) + : localize('date.fromNow.weeks.plural.ago', '{0} wks ago', value); + } + } else { + if (value === 1) { + return useFullTimeWords + ? localize('date.fromNow.weeks.singular.fullWord', '{0} week', value) + : localize('date.fromNow.weeks.singular', '{0} wk', value); + } else { + return useFullTimeWords + ? localize('date.fromNow.weeks.plural.fullWord', '{0} weeks', value) + : localize('date.fromNow.weeks.plural', '{0} wks', value); + } + } + } + + if (seconds < year) { + value = Math.floor(seconds / month); + if (appendAgoLabel) { + if (value === 1) { + return useFullTimeWords + ? localize('date.fromNow.months.singular.ago.fullWord', '{0} month ago', value) + : localize('date.fromNow.months.singular.ago', '{0} mo ago', value); + } else { + return useFullTimeWords + ? localize('date.fromNow.months.plural.ago.fullWord', '{0} months ago', value) + : localize('date.fromNow.months.plural.ago', '{0} mos ago', value); + } + } else { + if (value === 1) { + return useFullTimeWords + ? localize('date.fromNow.months.singular.fullWord', '{0} month', value) + : localize('date.fromNow.months.singular', '{0} mo', value); + } else { + return useFullTimeWords + ? localize('date.fromNow.months.plural.fullWord', '{0} months', value) + : localize('date.fromNow.months.plural', '{0} mos', value); + } + } + } + + value = Math.floor(seconds / year); + if (appendAgoLabel) { + if (value === 1) { + return useFullTimeWords + ? localize('date.fromNow.years.singular.ago.fullWord', '{0} year ago', value) + : localize('date.fromNow.years.singular.ago', '{0} yr ago', value); + } else { + return useFullTimeWords + ? localize('date.fromNow.years.plural.ago.fullWord', '{0} years ago', value) + : localize('date.fromNow.years.plural.ago', '{0} yrs ago', value); + } + } else { + if (value === 1) { + return useFullTimeWords + ? localize('date.fromNow.years.singular.fullWord', '{0} year', value) + : localize('date.fromNow.years.singular', '{0} yr', value); + } else { + return useFullTimeWords + ? localize('date.fromNow.years.plural.fullWord', '{0} years', value) + : localize('date.fromNow.years.plural', '{0} yrs', value); + } + } +} + +/** + * Gets a readable duration with intelligent/lossy precision. For example "40ms" or "3.040s") + * @param ms The duration to get in milliseconds. + * @param useFullTimeWords Whether to use full words (eg. seconds) instead of + * shortened (eg. secs). + */ +export function getDurationString(ms: number, useFullTimeWords?: boolean) { + const seconds = Math.abs(ms / 1000); + if (seconds < 1) { + return useFullTimeWords + ? localize('duration.ms.full', '{0} milliseconds', ms) + : localize('duration.ms', '{0}ms', ms); + } + if (seconds < minute) { + return useFullTimeWords + ? localize('duration.s.full', '{0} seconds', Math.round(ms) / 1000) + : localize('duration.s', '{0}s', Math.round(ms) / 1000); + } + if (seconds < hour) { + return useFullTimeWords + ? localize('duration.m.full', '{0} minutes', Math.round(ms / (1000 * minute))) + : localize('duration.m', '{0} mins', Math.round(ms / (1000 * minute))); + } + if (seconds < day) { + return useFullTimeWords + ? localize('duration.h.full', '{0} hours', Math.round(ms / (1000 * hour))) + : localize('duration.h', '{0} hrs', Math.round(ms / (1000 * hour))); + } + return localize('duration.d', '{0} days', Math.round(ms / (1000 * day))); +} + +export function toLocalISOString(date: Date): string { + return date.getFullYear() + + '-' + String(date.getMonth() + 1).padStart(2, '0') + + '-' + String(date.getDate()).padStart(2, '0') + + 'T' + String(date.getHours()).padStart(2, '0') + + ':' + String(date.getMinutes()).padStart(2, '0') + + ':' + String(date.getSeconds()).padStart(2, '0') + + '.' + (date.getMilliseconds() / 1000).toFixed(3).slice(2, 5) + + 'Z'; +} diff --git a/src/vs/base/common/decorators.ts b/src/vs/base/common/decorators.ts new file mode 100644 index 0000000000..34592a7b81 --- /dev/null +++ b/src/vs/base/common/decorators.ts @@ -0,0 +1,130 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +function createDecorator(mapFn: (fn: Function, key: string) => Function): Function { + return (target: any, key: string, descriptor: any) => { + let fnKey: string | null = null; + let fn: Function | null = null; + + if (typeof descriptor.value === 'function') { + fnKey = 'value'; + fn = descriptor.value; + } else if (typeof descriptor.get === 'function') { + fnKey = 'get'; + fn = descriptor.get; + } + + if (!fn) { + throw new Error('not supported'); + } + + descriptor[fnKey!] = mapFn(fn, key); + }; +} + +export function memoize(_target: any, key: string, descriptor: any) { + let fnKey: string | null = null; + let fn: Function | null = null; + + if (typeof descriptor.value === 'function') { + fnKey = 'value'; + fn = descriptor.value; + + if (fn!.length !== 0) { + console.warn('Memoize should only be used in functions with zero parameters'); + } + } else if (typeof descriptor.get === 'function') { + fnKey = 'get'; + fn = descriptor.get; + } + + if (!fn) { + throw new Error('not supported'); + } + + const memoizeKey = `$memoize$${key}`; + descriptor[fnKey!] = function (...args: any[]) { + if (!this.hasOwnProperty(memoizeKey)) { + Object.defineProperty(this, memoizeKey, { + configurable: false, + enumerable: false, + writable: false, + value: fn.apply(this, args) + }); + } + + return this[memoizeKey]; + }; +} + +export interface IDebounceReducer { + (previousValue: T, ...args: any[]): T; +} + +export function debounce(delay: number, reducer?: IDebounceReducer, initialValueProvider?: () => T): Function { + return createDecorator((fn, key) => { + const timerKey = `$debounce$${key}`; + const resultKey = `$debounce$result$${key}`; + + return function (this: any, ...args: any[]) { + if (!this[resultKey]) { + this[resultKey] = initialValueProvider ? initialValueProvider() : undefined; + } + + clearTimeout(this[timerKey]); + + if (reducer) { + this[resultKey] = reducer(this[resultKey], ...args); + args = [this[resultKey]]; + } + + this[timerKey] = setTimeout(() => { + fn.apply(this, args); + this[resultKey] = initialValueProvider ? initialValueProvider() : undefined; + }, delay); + }; + }); +} + +export function throttle(delay: number, reducer?: IDebounceReducer, initialValueProvider?: () => T): Function { + return createDecorator((fn, key) => { + const timerKey = `$throttle$timer$${key}`; + const resultKey = `$throttle$result$${key}`; + const lastRunKey = `$throttle$lastRun$${key}`; + const pendingKey = `$throttle$pending$${key}`; + + return function (this: any, ...args: any[]) { + if (!this[resultKey]) { + this[resultKey] = initialValueProvider ? initialValueProvider() : undefined; + } + if (this[lastRunKey] === null || this[lastRunKey] === undefined) { + this[lastRunKey] = -Number.MAX_VALUE; + } + + if (reducer) { + this[resultKey] = reducer(this[resultKey], ...args); + } + + if (this[pendingKey]) { + return; + } + + const nextTime = this[lastRunKey] + delay; + if (nextTime <= Date.now()) { + this[lastRunKey] = Date.now(); + fn.apply(this, [this[resultKey]]); + this[resultKey] = initialValueProvider ? initialValueProvider() : undefined; + } else { + this[pendingKey] = true; + this[timerKey] = setTimeout(() => { + this[pendingKey] = false; + this[lastRunKey] = Date.now(); + fn.apply(this, [this[resultKey]]); + this[resultKey] = initialValueProvider ? initialValueProvider() : undefined; + }, nextTime - Date.now()); + } + }; + }); +} diff --git a/src/vs/base/common/desktopEnvironmentInfo.ts b/src/vs/base/common/desktopEnvironmentInfo.ts new file mode 100644 index 0000000000..b6e4c107db --- /dev/null +++ b/src/vs/base/common/desktopEnvironmentInfo.ts @@ -0,0 +1,101 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { env } from 'vs/base/common/process'; + +// Define the enumeration for Desktop Environments +enum DesktopEnvironment { + UNKNOWN = 'UNKNOWN', + CINNAMON = 'CINNAMON', + DEEPIN = 'DEEPIN', + GNOME = 'GNOME', + KDE3 = 'KDE3', + KDE4 = 'KDE4', + KDE5 = 'KDE5', + KDE6 = 'KDE6', + PANTHEON = 'PANTHEON', + UNITY = 'UNITY', + XFCE = 'XFCE', + UKUI = 'UKUI', + LXQT = 'LXQT', +} + +const kXdgCurrentDesktopEnvVar = 'XDG_CURRENT_DESKTOP'; +const kKDESessionEnvVar = 'KDE_SESSION_VERSION'; + +export function getDesktopEnvironment(): DesktopEnvironment { + const xdgCurrentDesktop = env[kXdgCurrentDesktopEnvVar]; + if (xdgCurrentDesktop) { + const values = xdgCurrentDesktop.split(':').map(value => value.trim()).filter(value => value.length > 0); + for (const value of values) { + switch (value) { + case 'Unity': { + const desktopSessionUnity = env['DESKTOP_SESSION']; + if (desktopSessionUnity && desktopSessionUnity.includes('gnome-fallback')) { + return DesktopEnvironment.GNOME; + } + + return DesktopEnvironment.UNITY; + } + case 'Deepin': + return DesktopEnvironment.DEEPIN; + case 'GNOME': + return DesktopEnvironment.GNOME; + case 'X-Cinnamon': + return DesktopEnvironment.CINNAMON; + case 'KDE': { + const kdeSession = env[kKDESessionEnvVar]; + if (kdeSession === '5') { return DesktopEnvironment.KDE5; } + if (kdeSession === '6') { return DesktopEnvironment.KDE6; } + return DesktopEnvironment.KDE4; + } + case 'Pantheon': + return DesktopEnvironment.PANTHEON; + case 'XFCE': + return DesktopEnvironment.XFCE; + case 'UKUI': + return DesktopEnvironment.UKUI; + case 'LXQt': + return DesktopEnvironment.LXQT; + } + } + } + + const desktopSession = env['DESKTOP_SESSION']; + if (desktopSession) { + switch (desktopSession) { + case 'deepin': + return DesktopEnvironment.DEEPIN; + case 'gnome': + case 'mate': + return DesktopEnvironment.GNOME; + case 'kde4': + case 'kde-plasma': + return DesktopEnvironment.KDE4; + case 'kde': + if (kKDESessionEnvVar in env) { + return DesktopEnvironment.KDE4; + } + return DesktopEnvironment.KDE3; + case 'xfce': + case 'xubuntu': + return DesktopEnvironment.XFCE; + case 'ukui': + return DesktopEnvironment.UKUI; + } + } + + if ('GNOME_DESKTOP_SESSION_ID' in env) { + return DesktopEnvironment.GNOME; + } + if ('KDE_FULL_SESSION' in env) { + if (kKDESessionEnvVar in env) { + return DesktopEnvironment.KDE4; + } + return DesktopEnvironment.KDE3; + } + + return DesktopEnvironment.UNKNOWN; +} diff --git a/src/vs/base/common/equals.ts b/src/vs/base/common/equals.ts new file mode 100644 index 0000000000..6e2ae8503a --- /dev/null +++ b/src/vs/base/common/equals.ts @@ -0,0 +1,146 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as arrays from 'vs/base/common/arrays'; + +export type EqualityComparer = (a: T, b: T) => boolean; + +/** + * Compares two items for equality using strict equality. +*/ +export const strictEquals: EqualityComparer = (a, b) => a === b; + +/** + * Checks if the items of two arrays are equal. + * By default, strict equality is used to compare elements, but a custom equality comparer can be provided. + */ +export function itemsEquals(itemEquals: EqualityComparer = strictEquals): EqualityComparer { + return (a, b) => arrays.equals(a, b, itemEquals); +} + +/** + * Two items are considered equal, if their stringified representations are equal. +*/ +export function jsonStringifyEquals(): EqualityComparer { + return (a, b) => JSON.stringify(a) === JSON.stringify(b); +} + +/** + * Uses `item.equals(other)` to determine equality. + */ +export function itemEquals(): EqualityComparer { + return (a, b) => a.equals(b); +} + +/** + * Checks if two items are both null or undefined, or are equal according to the provided equality comparer. +*/ +export function equalsIfDefined(v1: T | undefined | null, v2: T | undefined | null, equals: EqualityComparer): boolean; +/** + * Returns an equality comparer that checks if two items are both null or undefined, or are equal according to the provided equality comparer. +*/ +export function equalsIfDefined(equals: EqualityComparer): EqualityComparer; +export function equalsIfDefined(equalsOrV1: EqualityComparer | T, v2?: T | undefined | null, equals?: EqualityComparer): EqualityComparer | boolean { + if (equals !== undefined) { + const v1 = equalsOrV1 as T | undefined; + if (v1 === undefined || v1 === null || v2 === undefined || v2 === null) { + return v2 === v1; + } + return equals(v1, v2); + } else { + const equals = equalsOrV1 as EqualityComparer; + return (v1, v2) => { + if (v1 === undefined || v1 === null || v2 === undefined || v2 === null) { + return v2 === v1; + } + return equals(v1, v2); + }; + } +} + +/** + * Drills into arrays (items ordered) and objects (keys unordered) and uses strict equality on everything else. +*/ +export function structuralEquals(a: T, b: T): boolean { + if (a === b) { + return true; + } + + if (Array.isArray(a) && Array.isArray(b)) { + if (a.length !== b.length) { + return false; + } + for (let i = 0; i < a.length; i++) { + if (!structuralEquals(a[i], b[i])) { + return false; + } + } + return true; + } + + if (a && typeof a === 'object' && b && typeof b === 'object') { + if (Object.getPrototypeOf(a) === Object.prototype && Object.getPrototypeOf(b) === Object.prototype) { + const aObj = a as Record; + const bObj = b as Record; + const keysA = Object.keys(aObj); + const keysB = Object.keys(bObj); + const keysBSet = new Set(keysB); + + if (keysA.length !== keysB.length) { + return false; + } + + for (const key of keysA) { + if (!keysBSet.has(key)) { + return false; + } + if (!structuralEquals(aObj[key], bObj[key])) { + return false; + } + } + + return true; + } + } + + return false; +} + +/** + * `getStructuralKey(a) === getStructuralKey(b) <=> structuralEquals(a, b)` + * (assuming that a and b are not cyclic structures and nothing extends globalThis Array). +*/ +export function getStructuralKey(t: unknown): string { + return JSON.stringify(toNormalizedJsonStructure(t)); +} + +let objectId = 0; +const objIds = new WeakMap(); + +function toNormalizedJsonStructure(t: unknown): unknown { + if (Array.isArray(t)) { + return t.map(toNormalizedJsonStructure); + } + + if (t && typeof t === 'object') { + if (Object.getPrototypeOf(t) === Object.prototype) { + const tObj = t as Record; + const res: Record = Object.create(null); + for (const key of Object.keys(tObj).sort()) { + res[key] = toNormalizedJsonStructure(tObj[key]); + } + return res; + } else { + let objId = objIds.get(t); + if (objId === undefined) { + objId = objectId++; + objIds.set(t, objId); + } + // Random string to prevent collisions + return objId + '----2b76a038c20c4bcc'; + } + } + return t; +} diff --git a/src/vs/base/common/errorMessage.ts b/src/vs/base/common/errorMessage.ts new file mode 100644 index 0000000000..f16616da43 --- /dev/null +++ b/src/vs/base/common/errorMessage.ts @@ -0,0 +1,113 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as arrays from 'vs/base/common/arrays'; +import * as types from 'vs/base/common/types'; +import * as nls from 'vs/nls'; +import { IAction } from 'vs/base/common/actions'; + +function exceptionToErrorMessage(exception: any, verbose: boolean): string { + if (verbose && (exception.stack || exception.stacktrace)) { + return nls.localize('stackTrace.format', "{0}: {1}", detectSystemErrorMessage(exception), stackToString(exception.stack) || stackToString(exception.stacktrace)); + } + + return detectSystemErrorMessage(exception); +} + +function stackToString(stack: string[] | string | undefined): string | undefined { + if (Array.isArray(stack)) { + return stack.join('\n'); + } + + return stack; +} + +function detectSystemErrorMessage(exception: any): string { + + // Custom node.js error from us + if (exception.code === 'ERR_UNC_HOST_NOT_ALLOWED') { + return `${exception.message}. Please update the 'security.allowedUNCHosts' setting if you want to allow this host.`; + } + + // See https://nodejs.org/api/errors.html#errors_class_system_error + if (typeof exception.code === 'string' && typeof exception.errno === 'number' && typeof exception.syscall === 'string') { + return nls.localize('nodeExceptionMessage', "A system error occurred ({0})", exception.message); + } + + return exception.message || nls.localize('error.defaultMessage', "An unknown error occurred. Please consult the log for more details."); +} + +/** + * Tries to generate a human readable error message out of the error. If the verbose parameter + * is set to true, the error message will include stacktrace details if provided. + * + * @returns A string containing the error message. + */ +export function toErrorMessage(error: any = null, verbose: boolean = false): string { + if (!error) { + return nls.localize('error.defaultMessage', "An unknown error occurred. Please consult the log for more details."); + } + + if (Array.isArray(error)) { + const errors: any[] = arrays.coalesce(error); + const msg = toErrorMessage(errors[0], verbose); + + if (errors.length > 1) { + return nls.localize('error.moreErrors', "{0} ({1} errors in total)", msg, errors.length); + } + + return msg; + } + + if (types.isString(error)) { + return error; + } + + if (error.detail) { + const detail = error.detail; + + if (detail.error) { + return exceptionToErrorMessage(detail.error, verbose); + } + + if (detail.exception) { + return exceptionToErrorMessage(detail.exception, verbose); + } + } + + if (error.stack) { + return exceptionToErrorMessage(error, verbose); + } + + if (error.message) { + return error.message; + } + + return nls.localize('error.defaultMessage', "An unknown error occurred. Please consult the log for more details."); +} + + +export interface IErrorWithActions extends Error { + actions: IAction[]; +} + +export function isErrorWithActions(obj: unknown): obj is IErrorWithActions { + const candidate = obj as IErrorWithActions | undefined; + + return candidate instanceof Error && Array.isArray(candidate.actions); +} + +export function createErrorWithActions(messageOrError: string | Error, actions: IAction[]): IErrorWithActions { + let error: IErrorWithActions; + if (typeof messageOrError === 'string') { + error = new Error(messageOrError) as IErrorWithActions; + } else { + error = messageOrError as IErrorWithActions; + } + + error.actions = actions; + + return error; +} diff --git a/src/vs/base/common/errors.ts b/src/vs/base/common/errors.ts new file mode 100644 index 0000000000..ce5d8b2985 --- /dev/null +++ b/src/vs/base/common/errors.ts @@ -0,0 +1,303 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export interface ErrorListenerCallback { + (error: any): void; +} + +export interface ErrorListenerUnbind { + (): void; +} + +// Avoid circular dependency on EventEmitter by implementing a subset of the interface. +export class ErrorHandler { + private unexpectedErrorHandler: (e: any) => void; + private listeners: ErrorListenerCallback[]; + + constructor() { + + this.listeners = []; + + this.unexpectedErrorHandler = function (e: any) { + setTimeout(() => { + if (e.stack) { + if (ErrorNoTelemetry.isErrorNoTelemetry(e)) { + throw new ErrorNoTelemetry(e.message + '\n\n' + e.stack); + } + + throw new Error(e.message + '\n\n' + e.stack); + } + + throw e; + }, 0); + }; + } + + addListener(listener: ErrorListenerCallback): ErrorListenerUnbind { + this.listeners.push(listener); + + return () => { + this._removeListener(listener); + }; + } + + private emit(e: any): void { + this.listeners.forEach((listener) => { + listener(e); + }); + } + + private _removeListener(listener: ErrorListenerCallback): void { + this.listeners.splice(this.listeners.indexOf(listener), 1); + } + + setUnexpectedErrorHandler(newUnexpectedErrorHandler: (e: any) => void): void { + this.unexpectedErrorHandler = newUnexpectedErrorHandler; + } + + getUnexpectedErrorHandler(): (e: any) => void { + return this.unexpectedErrorHandler; + } + + onUnexpectedError(e: any): void { + this.unexpectedErrorHandler(e); + this.emit(e); + } + + // For external errors, we don't want the listeners to be called + onUnexpectedExternalError(e: any): void { + this.unexpectedErrorHandler(e); + } +} + +export const errorHandler = new ErrorHandler(); + +/** @skipMangle */ +export function setUnexpectedErrorHandler(newUnexpectedErrorHandler: (e: any) => void): void { + errorHandler.setUnexpectedErrorHandler(newUnexpectedErrorHandler); +} + +/** + * Returns if the error is a SIGPIPE error. SIGPIPE errors should generally be + * logged at most once, to avoid a loop. + * + * @see https://github.com/microsoft/vscode-remote-release/issues/6481 + */ +export function isSigPipeError(e: unknown): e is Error { + if (!e || typeof e !== 'object') { + return false; + } + + const cast = e as Record; + return cast.code === 'EPIPE' && cast.syscall?.toUpperCase() === 'WRITE'; +} + +export function onUnexpectedError(e: any): undefined { + // ignore errors from cancelled promises + if (!isCancellationError(e)) { + errorHandler.onUnexpectedError(e); + } + return undefined; +} + +export function onUnexpectedExternalError(e: any): undefined { + // ignore errors from cancelled promises + if (!isCancellationError(e)) { + errorHandler.onUnexpectedExternalError(e); + } + return undefined; +} + +export interface SerializedError { + readonly $isError: true; + readonly name: string; + readonly message: string; + readonly stack: string; + readonly noTelemetry: boolean; +} + +export function transformErrorForSerialization(error: Error): SerializedError; +export function transformErrorForSerialization(error: any): any; +export function transformErrorForSerialization(error: any): any { + if (error instanceof Error) { + const { name, message } = error; + const stack: string = (error).stacktrace || (error).stack; + return { + $isError: true, + name, + message, + stack, + noTelemetry: ErrorNoTelemetry.isErrorNoTelemetry(error) + }; + } + + // return as is + return error; +} + +export function transformErrorFromSerialization(data: SerializedError): Error { + let error: Error; + if (data.noTelemetry) { + error = new ErrorNoTelemetry(); + } else { + error = new Error(); + error.name = data.name; + } + error.message = data.message; + error.stack = data.stack; + return error; +} + +// see https://github.com/v8/v8/wiki/Stack%20Trace%20API#basic-stack-traces +export interface V8CallSite { + getThis(): unknown; + getTypeName(): string | null; + getFunction(): Function | undefined; + getFunctionName(): string | null; + getMethodName(): string | null; + getFileName(): string | null; + getLineNumber(): number | null; + getColumnNumber(): number | null; + getEvalOrigin(): string | undefined; + isToplevel(): boolean; + isEval(): boolean; + isNative(): boolean; + isConstructor(): boolean; + toString(): string; +} + +const canceledName = 'Canceled'; + +/** + * Checks if the given error is a promise in canceled state + */ +export function isCancellationError(error: any): boolean { + if (error instanceof CancellationError) { + return true; + } + return error instanceof Error && error.name === canceledName && error.message === canceledName; +} + +// !!!IMPORTANT!!! +// Do NOT change this class because it is also used as an API-type. +export class CancellationError extends Error { + constructor() { + super(canceledName); + this.name = this.message; + } +} + +/** + * @deprecated use {@link CancellationError `new CancellationError()`} instead + */ +export function canceled(): Error { + const error = new Error(canceledName); + error.name = error.message; + return error; +} + +export function illegalArgument(name?: string): Error { + if (name) { + return new Error(`Illegal argument: ${name}`); + } else { + return new Error('Illegal argument'); + } +} + +export function illegalState(name?: string): Error { + if (name) { + return new Error(`Illegal state: ${name}`); + } else { + return new Error('Illegal state'); + } +} + +export class ReadonlyError extends TypeError { + constructor(name?: string) { + super(name ? `${name} is read-only and cannot be changed` : 'Cannot change read-only property'); + } +} + +export function getErrorMessage(err: any): string { + if (!err) { + return 'Error'; + } + + if (err.message) { + return err.message; + } + + if (err.stack) { + return err.stack.split('\n')[0]; + } + + return String(err); +} + +export class NotImplementedError extends Error { + constructor(message?: string) { + super('NotImplemented'); + if (message) { + this.message = message; + } + } +} + +export class NotSupportedError extends Error { + constructor(message?: string) { + super('NotSupported'); + if (message) { + this.message = message; + } + } +} + +export class ExpectedError extends Error { + readonly isExpected = true; +} + +/** + * Error that when thrown won't be logged in telemetry as an unhandled error. + */ +export class ErrorNoTelemetry extends Error { + override readonly name: string; + + constructor(msg?: string) { + super(msg); + this.name = 'CodeExpectedError'; + } + + public static fromError(err: Error): ErrorNoTelemetry { + if (err instanceof ErrorNoTelemetry) { + return err; + } + + const result = new ErrorNoTelemetry(); + result.message = err.message; + result.stack = err.stack; + return result; + } + + public static isErrorNoTelemetry(err: Error): err is ErrorNoTelemetry { + return err.name === 'CodeExpectedError'; + } +} + +/** + * This error indicates a bug. + * Do not throw this for invalid user input. + * Only catch this error to recover gracefully from bugs. + */ +export class BugIndicatingError extends Error { + constructor(message?: string) { + super(message || 'An unexpected bug occurred.'); + Object.setPrototypeOf(this, BugIndicatingError.prototype); + + // Because we know for sure only buggy code throws this, + // we definitely want to break here and fix the bug. + // eslint-disable-next-line no-debugger + // debugger; + } +} diff --git a/src/vs/base/common/event.ts b/src/vs/base/common/event.ts new file mode 100644 index 0000000000..d563a2c77d --- /dev/null +++ b/src/vs/base/common/event.ts @@ -0,0 +1,1762 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CancellationToken } from 'vs/base/common/cancellation'; +import { onUnexpectedError } from 'vs/base/common/errors'; +import { createSingleCallFunction } from 'vs/base/common/functional'; +import { combinedDisposable, Disposable, DisposableMap, DisposableStore, IDisposable, toDisposable } from 'vs/base/common/lifecycle'; +import { LinkedList } from 'vs/base/common/linkedList'; +import { IObservable, IObserver } from 'vs/base/common/observable'; +import { StopWatch } from 'vs/base/common/stopwatch'; +import { MicrotaskDelay } from 'vs/base/common/symbols'; + + +// ----------------------------------------------------------------------------------------------------------------------- +// Uncomment the next line to print warnings whenever a listener is GC'ed without having been disposed. This is a LEAK. +// ----------------------------------------------------------------------------------------------------------------------- +const _enableListenerGCedWarning = false + // || Boolean("TRUE") // causes a linter warning so that it cannot be pushed + ; + +// ----------------------------------------------------------------------------------------------------------------------- +// Uncomment the next line to print warnings whenever an emitter with listeners is disposed. That is a sign of code smell. +// ----------------------------------------------------------------------------------------------------------------------- +const _enableDisposeWithListenerWarning = false + // || Boolean("TRUE") // causes a linter warning so that it cannot be pushed + ; + + +// ----------------------------------------------------------------------------------------------------------------------- +// Uncomment the next line to print warnings whenever a snapshotted event is used repeatedly without cleanup. +// See https://github.com/microsoft/vscode/issues/142851 +// ----------------------------------------------------------------------------------------------------------------------- +const _enableSnapshotPotentialLeakWarning = false + // || Boolean("TRUE") // causes a linter warning so that it cannot be pushed + ; + +/** + * An event with zero or one parameters that can be subscribed to. The event is a function itself. + */ +export interface Event { + (listener: (e: T) => any, thisArgs?: any, disposables?: IDisposable[] | DisposableStore): IDisposable; +} + +export namespace Event { + export const None: Event = () => Disposable.None; + + function _addLeakageTraceLogic(options: EmitterOptions) { + if (_enableSnapshotPotentialLeakWarning) { + const { onDidAddListener: origListenerDidAdd } = options; + const stack = Stacktrace.create(); + let count = 0; + options.onDidAddListener = () => { + if (++count === 2) { + console.warn('snapshotted emitter LIKELY used public and SHOULD HAVE BEEN created with DisposableStore. snapshotted here'); + stack.print(); + } + origListenerDidAdd?.(); + }; + } + } + + /** + * Given an event, returns another event which debounces calls and defers the listeners to a later task via a shared + * `setTimeout`. The event is converted into a signal (`Event`) to avoid additional object creation as a + * result of merging events and to try prevent race conditions that could arise when using related deferred and + * non-deferred events. + * + * This is useful for deferring non-critical work (eg. general UI updates) to ensure it does not block critical work + * (eg. latency of keypress to text rendered). + * + * *NOTE* that this function returns an `Event` and it MUST be called with a `DisposableStore` whenever the returned + * event is accessible to "third parties", e.g the event is a public property. Otherwise a leaked listener on the + * returned event causes this utility to leak a listener on the original event. + * + * @param event The event source for the new event. + * @param disposable A disposable store to add the new EventEmitter to. + */ + export function defer(event: Event, disposable?: DisposableStore): Event { + return debounce(event, () => void 0, 0, undefined, true, undefined, disposable); + } + + /** + * Given an event, returns another event which only fires once. + * + * @param event The event source for the new event. + */ + export function once(event: Event): Event { + return (listener, thisArgs = null, disposables?) => { + // we need this, in case the event fires during the listener call + let didFire = false; + let result: IDisposable | undefined = undefined; + result = event(e => { + if (didFire) { + return; + } else if (result) { + result.dispose(); + } else { + didFire = true; + } + + return listener.call(thisArgs, e); + }, null, disposables); + + if (didFire) { + result.dispose(); + } + + return result; + }; + } + + /** + * Maps an event of one type into an event of another type using a mapping function, similar to how + * `Array.prototype.map` works. + * + * *NOTE* that this function returns an `Event` and it MUST be called with a `DisposableStore` whenever the returned + * event is accessible to "third parties", e.g the event is a public property. Otherwise a leaked listener on the + * returned event causes this utility to leak a listener on the original event. + * + * @param event The event source for the new event. + * @param map The mapping function. + * @param disposable A disposable store to add the new EventEmitter to. + */ + export function map(event: Event, map: (i: I) => O, disposable?: DisposableStore): Event { + return snapshot((listener, thisArgs = null, disposables?) => event(i => listener.call(thisArgs, map(i)), null, disposables), disposable); + } + + /** + * Wraps an event in another event that performs some function on the event object before firing. + * + * *NOTE* that this function returns an `Event` and it MUST be called with a `DisposableStore` whenever the returned + * event is accessible to "third parties", e.g the event is a public property. Otherwise a leaked listener on the + * returned event causes this utility to leak a listener on the original event. + * + * @param event The event source for the new event. + * @param each The function to perform on the event object. + * @param disposable A disposable store to add the new EventEmitter to. + */ + export function forEach(event: Event, each: (i: I) => void, disposable?: DisposableStore): Event { + return snapshot((listener, thisArgs = null, disposables?) => event(i => { each(i); listener.call(thisArgs, i); }, null, disposables), disposable); + } + + /** + * Wraps an event in another event that fires only when some condition is met. + * + * *NOTE* that this function returns an `Event` and it MUST be called with a `DisposableStore` whenever the returned + * event is accessible to "third parties", e.g the event is a public property. Otherwise a leaked listener on the + * returned event causes this utility to leak a listener on the original event. + * + * @param event The event source for the new event. + * @param filter The filter function that defines the condition. The event will fire for the object if this function + * returns true. + * @param disposable A disposable store to add the new EventEmitter to. + */ + export function filter(event: Event, filter: (e: T | U) => e is T, disposable?: DisposableStore): Event; + export function filter(event: Event, filter: (e: T) => boolean, disposable?: DisposableStore): Event; + export function filter(event: Event, filter: (e: T | R) => e is R, disposable?: DisposableStore): Event; + export function filter(event: Event, filter: (e: T) => boolean, disposable?: DisposableStore): Event { + return snapshot((listener, thisArgs = null, disposables?) => event(e => filter(e) && listener.call(thisArgs, e), null, disposables), disposable); + } + + /** + * Given an event, returns the same event but typed as `Event`. + */ + export function signal(event: Event): Event { + return event as Event as Event; + } + + /** + * Given a collection of events, returns a single event which emits whenever any of the provided events emit. + */ + export function any(...events: Event[]): Event; + export function any(...events: Event[]): Event; + export function any(...events: Event[]): Event { + return (listener, thisArgs = null, disposables?) => { + const disposable = combinedDisposable(...events.map(event => event(e => listener.call(thisArgs, e)))); + return addAndReturnDisposable(disposable, disposables); + }; + } + + /** + * *NOTE* that this function returns an `Event` and it MUST be called with a `DisposableStore` whenever the returned + * event is accessible to "third parties", e.g the event is a public property. Otherwise a leaked listener on the + * returned event causes this utility to leak a listener on the original event. + */ + export function reduce(event: Event, merge: (last: O | undefined, event: I) => O, initial?: O, disposable?: DisposableStore): Event { + let output: O | undefined = initial; + + return map(event, e => { + output = merge(output, e); + return output; + }, disposable); + } + + function snapshot(event: Event, disposable: DisposableStore | undefined): Event { + let listener: IDisposable | undefined; + + const options: EmitterOptions | undefined = { + onWillAddFirstListener() { + listener = event(emitter.fire, emitter); + }, + onDidRemoveLastListener() { + listener?.dispose(); + } + }; + + if (!disposable) { + _addLeakageTraceLogic(options); + } + + const emitter = new Emitter(options); + + disposable?.add(emitter); + + return emitter.event; + } + + /** + * Adds the IDisposable to the store if it's set, and returns it. Useful to + * Event function implementation. + */ + function addAndReturnDisposable(d: T, store: DisposableStore | IDisposable[] | undefined): T { + if (store instanceof Array) { + store.push(d); + } else if (store) { + store.add(d); + } + return d; + } + + /** + * Given an event, creates a new emitter that event that will debounce events based on {@link delay} and give an + * array event object of all events that fired. + * + * *NOTE* that this function returns an `Event` and it MUST be called with a `DisposableStore` whenever the returned + * event is accessible to "third parties", e.g the event is a public property. Otherwise a leaked listener on the + * returned event causes this utility to leak a listener on the original event. + * + * @param event The original event to debounce. + * @param merge A function that reduces all events into a single event. + * @param delay The number of milliseconds to debounce. + * @param leading Whether to fire a leading event without debouncing. + * @param flushOnListenerRemove Whether to fire all debounced events when a listener is removed. If this is not + * specified, some events could go missing. Use this if it's important that all events are processed, even if the + * listener gets disposed before the debounced event fires. + * @param leakWarningThreshold See {@link EmitterOptions.leakWarningThreshold}. + * @param disposable A disposable store to register the debounce emitter to. + */ + export function debounce(event: Event, merge: (last: T | undefined, event: T) => T, delay?: number | typeof MicrotaskDelay, leading?: boolean, flushOnListenerRemove?: boolean, leakWarningThreshold?: number, disposable?: DisposableStore): Event; + export function debounce(event: Event, merge: (last: O | undefined, event: I) => O, delay?: number | typeof MicrotaskDelay, leading?: boolean, flushOnListenerRemove?: boolean, leakWarningThreshold?: number, disposable?: DisposableStore): Event; + export function debounce(event: Event, merge: (last: O | undefined, event: I) => O, delay: number | typeof MicrotaskDelay = 100, leading = false, flushOnListenerRemove = false, leakWarningThreshold?: number, disposable?: DisposableStore): Event { + let subscription: IDisposable; + let output: O | undefined = undefined; + let handle: any = undefined; + let numDebouncedCalls = 0; + let doFire: (() => void) | undefined; + + const options: EmitterOptions | undefined = { + leakWarningThreshold, + onWillAddFirstListener() { + subscription = event(cur => { + numDebouncedCalls++; + output = merge(output, cur); + + if (leading && !handle) { + emitter.fire(output); + output = undefined; + } + + doFire = () => { + const _output = output; + output = undefined; + handle = undefined; + if (!leading || numDebouncedCalls > 1) { + emitter.fire(_output!); + } + numDebouncedCalls = 0; + }; + + if (typeof delay === 'number') { + clearTimeout(handle); + handle = setTimeout(doFire, delay); + } else { + if (handle === undefined) { + handle = 0; + queueMicrotask(doFire); + } + } + }); + }, + onWillRemoveListener() { + if (flushOnListenerRemove && numDebouncedCalls > 0) { + doFire?.(); + } + }, + onDidRemoveLastListener() { + doFire = undefined; + subscription.dispose(); + } + }; + + if (!disposable) { + _addLeakageTraceLogic(options); + } + + const emitter = new Emitter(options); + + disposable?.add(emitter); + + return emitter.event; + } + + /** + * Debounces an event, firing after some delay (default=0) with an array of all event original objects. + * + * *NOTE* that this function returns an `Event` and it MUST be called with a `DisposableStore` whenever the returned + * event is accessible to "third parties", e.g the event is a public property. Otherwise a leaked listener on the + * returned event causes this utility to leak a listener on the original event. + */ + export function accumulate(event: Event, delay: number = 0, disposable?: DisposableStore): Event { + return Event.debounce(event, (last, e) => { + if (!last) { + return [e]; + } + last.push(e); + return last; + }, delay, undefined, true, undefined, disposable); + } + + /** + * Filters an event such that some condition is _not_ met more than once in a row, effectively ensuring duplicate + * event objects from different sources do not fire the same event object. + * + * *NOTE* that this function returns an `Event` and it MUST be called with a `DisposableStore` whenever the returned + * event is accessible to "third parties", e.g the event is a public property. Otherwise a leaked listener on the + * returned event causes this utility to leak a listener on the original event. + * + * @param event The event source for the new event. + * @param equals The equality condition. + * @param disposable A disposable store to add the new EventEmitter to. + * + * @example + * ``` + * // Fire only one time when a single window is opened or focused + * Event.latch(Event.any(onDidOpenWindow, onDidFocusWindow)) + * ``` + */ + export function latch(event: Event, equals: (a: T, b: T) => boolean = (a, b) => a === b, disposable?: DisposableStore): Event { + let firstCall = true; + let cache: T; + + return filter(event, value => { + const shouldEmit = firstCall || !equals(value, cache); + firstCall = false; + cache = value; + return shouldEmit; + }, disposable); + } + + /** + * Splits an event whose parameter is a union type into 2 separate events for each type in the union. + * + * *NOTE* that this function returns an `Event` and it MUST be called with a `DisposableStore` whenever the returned + * event is accessible to "third parties", e.g the event is a public property. Otherwise a leaked listener on the + * returned event causes this utility to leak a listener on the original event. + * + * @example + * ``` + * const event = new EventEmitter().event; + * const [numberEvent, undefinedEvent] = Event.split(event, isUndefined); + * ``` + * + * @param event The event source for the new event. + * @param isT A function that determines what event is of the first type. + * @param disposable A disposable store to add the new EventEmitter to. + */ + export function split(event: Event, isT: (e: T | U) => e is T, disposable?: DisposableStore): [Event, Event] { + return [ + Event.filter(event, isT, disposable), + Event.filter(event, e => !isT(e), disposable) as Event, + ]; + } + + /** + * Buffers an event until it has a listener attached. + * + * *NOTE* that this function returns an `Event` and it MUST be called with a `DisposableStore` whenever the returned + * event is accessible to "third parties", e.g the event is a public property. Otherwise a leaked listener on the + * returned event causes this utility to leak a listener on the original event. + * + * @param event The event source for the new event. + * @param flushAfterTimeout Determines whether to flush the buffer after a timeout immediately or after a + * `setTimeout` when the first event listener is added. + * @param _buffer Internal: A source event array used for tests. + * + * @example + * ``` + * // Start accumulating events, when the first listener is attached, flush + * // the event after a timeout such that multiple listeners attached before + * // the timeout would receive the event + * this.onInstallExtension = Event.buffer(service.onInstallExtension, true); + * ``` + */ + export function buffer(event: Event, flushAfterTimeout = false, _buffer: T[] = [], disposable?: DisposableStore): Event { + let buffer: T[] | null = _buffer.slice(); + + let listener: IDisposable | null = event(e => { + if (buffer) { + buffer.push(e); + } else { + emitter.fire(e); + } + }); + + if (disposable) { + disposable.add(listener); + } + + const flush = () => { + buffer?.forEach(e => emitter.fire(e)); + buffer = null; + }; + + const emitter = new Emitter({ + onWillAddFirstListener() { + if (!listener) { + listener = event(e => emitter.fire(e)); + if (disposable) { + disposable.add(listener); + } + } + }, + + onDidAddFirstListener() { + if (buffer) { + if (flushAfterTimeout) { + setTimeout(flush); + } else { + flush(); + } + } + }, + + onDidRemoveLastListener() { + if (listener) { + listener.dispose(); + } + listener = null; + } + }); + + if (disposable) { + disposable.add(emitter); + } + + return emitter.event; + } + /** + * Wraps the event in an {@link IChainableEvent}, allowing a more functional programming style. + * + * @example + * ``` + * // Normal + * const onEnterPressNormal = Event.filter( + * Event.map(onKeyPress.event, e => new StandardKeyboardEvent(e)), + * e.keyCode === KeyCode.Enter + * ).event; + * + * // Using chain + * const onEnterPressChain = Event.chain(onKeyPress.event, $ => $ + * .map(e => new StandardKeyboardEvent(e)) + * .filter(e => e.keyCode === KeyCode.Enter) + * ); + * ``` + */ + export function chain(event: Event, sythensize: ($: IChainableSythensis) => IChainableSythensis): Event { + const fn: Event = (listener, thisArgs, disposables) => { + const cs = sythensize(new ChainableSynthesis()) as ChainableSynthesis; + return event(function (value) { + const result = cs.evaluate(value); + if (result !== HaltChainable) { + listener.call(thisArgs, result); + } + }, undefined, disposables); + }; + + return fn; + } + + const HaltChainable = Symbol('HaltChainable'); + + class ChainableSynthesis implements IChainableSythensis { + private readonly steps: ((input: any) => any)[] = []; + + map(fn: (i: any) => O): this { + this.steps.push(fn); + return this; + } + + forEach(fn: (i: any) => void): this { + this.steps.push(v => { + fn(v); + return v; + }); + return this; + } + + filter(fn: (e: any) => boolean): this { + this.steps.push(v => fn(v) ? v : HaltChainable); + return this; + } + + reduce(merge: (last: R | undefined, event: any) => R, initial?: R | undefined): this { + let last = initial; + this.steps.push(v => { + last = merge(last, v); + return last; + }); + return this; + } + + latch(equals: (a: any, b: any) => boolean = (a, b) => a === b): ChainableSynthesis { + let firstCall = true; + let cache: any; + this.steps.push(value => { + const shouldEmit = firstCall || !equals(value, cache); + firstCall = false; + cache = value; + return shouldEmit ? value : HaltChainable; + }); + + return this; + } + + public evaluate(value: any) { + for (const step of this.steps) { + value = step(value); + if (value === HaltChainable) { + break; + } + } + + return value; + } + } + + export interface IChainableSythensis { + map(fn: (i: T) => O): IChainableSythensis; + forEach(fn: (i: T) => void): IChainableSythensis; + filter(fn: (e: T) => e is R): IChainableSythensis; + filter(fn: (e: T) => boolean): IChainableSythensis; + reduce(merge: (last: R, event: T) => R, initial: R): IChainableSythensis; + reduce(merge: (last: R | undefined, event: T) => R): IChainableSythensis; + latch(equals?: (a: T, b: T) => boolean): IChainableSythensis; + } + + export interface NodeEventEmitter { + on(event: string | symbol, listener: Function): unknown; + removeListener(event: string | symbol, listener: Function): unknown; + } + + /** + * Creates an {@link Event} from a node event emitter. + */ + export function fromNodeEventEmitter(emitter: NodeEventEmitter, eventName: string, map: (...args: any[]) => T = id => id): Event { + const fn = (...args: any[]) => result.fire(map(...args)); + const onFirstListenerAdd = () => emitter.on(eventName, fn); + const onLastListenerRemove = () => emitter.removeListener(eventName, fn); + const result = new Emitter({ onWillAddFirstListener: onFirstListenerAdd, onDidRemoveLastListener: onLastListenerRemove }); + + return result.event; + } + + export interface DOMEventEmitter { + addEventListener(event: string | symbol, listener: Function): void; + removeEventListener(event: string | symbol, listener: Function): void; + } + + /** + * Creates an {@link Event} from a DOM event emitter. + */ + export function fromDOMEventEmitter(emitter: DOMEventEmitter, eventName: string, map: (...args: any[]) => T = id => id): Event { + const fn = (...args: any[]) => result.fire(map(...args)); + const onFirstListenerAdd = () => emitter.addEventListener(eventName, fn); + const onLastListenerRemove = () => emitter.removeEventListener(eventName, fn); + const result = new Emitter({ onWillAddFirstListener: onFirstListenerAdd, onDidRemoveLastListener: onLastListenerRemove }); + + return result.event; + } + + /** + * Creates a promise out of an event, using the {@link Event.once} helper. + */ + export function toPromise(event: Event): Promise { + return new Promise(resolve => once(event)(resolve)); + } + + /** + * Creates an event out of a promise that fires once when the promise is + * resolved with the result of the promise or `undefined`. + */ + export function fromPromise(promise: Promise): Event { + const result = new Emitter(); + + promise.then(res => { + result.fire(res); + }, () => { + result.fire(undefined); + }).finally(() => { + result.dispose(); + }); + + return result.event; + } + + /** + * Adds a listener to an event and calls the listener immediately with undefined as the event object. + * + * @example + * ``` + * // Initialize the UI and update it when dataChangeEvent fires + * runAndSubscribe(dataChangeEvent, () => this._updateUI()); + * ``` + */ + export function runAndSubscribe(event: Event, handler: (e: T) => any, initial: T): IDisposable; + export function runAndSubscribe(event: Event, handler: (e: T | undefined) => any): IDisposable; + export function runAndSubscribe(event: Event, handler: (e: T | undefined) => any, initial?: T): IDisposable { + handler(initial); + return event(e => handler(e)); + } + + class EmitterObserver implements IObserver { + + readonly emitter: Emitter; + + private _counter = 0; + private _hasChanged = false; + + constructor(readonly _observable: IObservable, store: DisposableStore | undefined) { + const options: EmitterOptions = { + onWillAddFirstListener: () => { + _observable.addObserver(this); + }, + onDidRemoveLastListener: () => { + _observable.removeObserver(this); + } + }; + if (!store) { + _addLeakageTraceLogic(options); + } + this.emitter = new Emitter(options); + if (store) { + store.add(this.emitter); + } + } + + beginUpdate(_observable: IObservable): void { + // assert(_observable === this.obs); + this._counter++; + } + + handlePossibleChange(_observable: IObservable): void { + // assert(_observable === this.obs); + } + + handleChange(_observable: IObservable, _change: TChange): void { + // assert(_observable === this.obs); + this._hasChanged = true; + } + + endUpdate(_observable: IObservable): void { + // assert(_observable === this.obs); + this._counter--; + if (this._counter === 0) { + this._observable.reportChanges(); + if (this._hasChanged) { + this._hasChanged = false; + this.emitter.fire(this._observable.get()); + } + } + } + } + + /** + * Creates an event emitter that is fired when the observable changes. + * Each listeners subscribes to the emitter. + */ + export function fromObservable(obs: IObservable, store?: DisposableStore): Event { + const observer = new EmitterObserver(obs, store); + return observer.emitter.event; + } + + /** + * Each listener is attached to the observable directly. + */ + export function fromObservableLight(observable: IObservable): Event { + return (listener, thisArgs, disposables) => { + let count = 0; + let didChange = false; + const observer: IObserver = { + beginUpdate() { + count++; + }, + endUpdate() { + count--; + if (count === 0) { + observable.reportChanges(); + if (didChange) { + didChange = false; + listener.call(thisArgs); + } + } + }, + handlePossibleChange() { + // noop + }, + handleChange() { + didChange = true; + } + }; + observable.addObserver(observer); + observable.reportChanges(); + const disposable = { + dispose() { + observable.removeObserver(observer); + } + }; + + if (disposables instanceof DisposableStore) { + disposables.add(disposable); + } else if (Array.isArray(disposables)) { + disposables.push(disposable); + } + + return disposable; + }; + } +} + +export interface EmitterOptions { + /** + * Optional function that's called *before* the very first listener is added + */ + onWillAddFirstListener?: Function; + /** + * Optional function that's called *after* the very first listener is added + */ + onDidAddFirstListener?: Function; + /** + * Optional function that's called after a listener is added + */ + onDidAddListener?: Function; + /** + * Optional function that's called *after* remove the very last listener + */ + onDidRemoveLastListener?: Function; + /** + * Optional function that's called *before* a listener is removed + */ + onWillRemoveListener?: Function; + /** + * Optional function that's called when a listener throws an error. Defaults to + * {@link onUnexpectedError} + */ + onListenerError?: (e: any) => void; + /** + * Number of listeners that are allowed before assuming a leak. Default to + * a globally configured value + * + * @see setGlobalLeakWarningThreshold + */ + leakWarningThreshold?: number; + /** + * Pass in a delivery queue, which is useful for ensuring + * in order event delivery across multiple emitters. + */ + deliveryQueue?: EventDeliveryQueue; + + /** ONLY enable this during development */ + _profName?: string; +} + + +export class EventProfiling { + + static readonly all = new Set(); + + private static _idPool = 0; + + readonly name: string; + public listenerCount: number = 0; + public invocationCount = 0; + public elapsedOverall = 0; + public durations: number[] = []; + + private _stopWatch?: StopWatch; + + constructor(name: string) { + this.name = `${name}_${EventProfiling._idPool++}`; + EventProfiling.all.add(this); + } + + start(listenerCount: number): void { + this._stopWatch = new StopWatch(); + this.listenerCount = listenerCount; + } + + stop(): void { + if (this._stopWatch) { + const elapsed = this._stopWatch.elapsed(); + this.durations.push(elapsed); + this.elapsedOverall += elapsed; + this.invocationCount += 1; + this._stopWatch = undefined; + } + } +} + +let _globalLeakWarningThreshold = -1; +export function setGlobalLeakWarningThreshold(n: number): IDisposable { + const oldValue = _globalLeakWarningThreshold; + _globalLeakWarningThreshold = n; + return { + dispose() { + _globalLeakWarningThreshold = oldValue; + } + }; +} + +class LeakageMonitor { + + private static _idPool = 1; + + private _stacks: Map | undefined; + private _warnCountdown: number = 0; + + constructor( + private readonly _errorHandler: (err: Error) => void, + readonly threshold: number, + readonly name: string = (LeakageMonitor._idPool++).toString(16).padStart(3, '0') + ) { } + + dispose(): void { + this._stacks?.clear(); + } + + check(stack: Stacktrace, listenerCount: number): undefined | (() => void) { + + const threshold = this.threshold; + if (threshold <= 0 || listenerCount < threshold) { + return undefined; + } + + if (!this._stacks) { + this._stacks = new Map(); + } + const count = (this._stacks.get(stack.value) || 0); + this._stacks.set(stack.value, count + 1); + this._warnCountdown -= 1; + + if (this._warnCountdown <= 0) { + // only warn on first exceed and then every time the limit + // is exceeded by 50% again + this._warnCountdown = threshold * 0.5; + + const [topStack, topCount] = this.getMostFrequentStack()!; + const message = `[${this.name}] potential listener LEAK detected, having ${listenerCount} listeners already. MOST frequent listener (${topCount}):`; + console.warn(message); + console.warn(topStack!); + + const error = new ListenerLeakError(message, topStack); + this._errorHandler(error); + } + + return () => { + const count = (this._stacks!.get(stack.value) || 0); + this._stacks!.set(stack.value, count - 1); + }; + } + + getMostFrequentStack(): [string, number] | undefined { + if (!this._stacks) { + return undefined; + } + let topStack: [string, number] | undefined; + let topCount: number = 0; + for (const [stack, count] of this._stacks) { + if (!topStack || topCount < count) { + topStack = [stack, count]; + topCount = count; + } + } + return topStack; + } +} + +class Stacktrace { + + static create() { + const err = new Error(); + return new Stacktrace(err.stack ?? ''); + } + + private constructor(readonly value: string) { } + + print() { + console.warn(this.value.split('\n').slice(2).join('\n')); + } +} + +// error that is logged when going over the configured listener threshold +export class ListenerLeakError extends Error { + constructor(message: string, stack: string) { + super(message); + this.name = 'ListenerLeakError'; + this.stack = stack; + } +} + +// SEVERE error that is logged when having gone way over the configured listener +// threshold so that the emitter refuses to accept more listeners +export class ListenerRefusalError extends Error { + constructor(message: string, stack: string) { + super(message); + this.name = 'ListenerRefusalError'; + this.stack = stack; + } +} + +let id = 0; +class UniqueContainer { + stack?: Stacktrace; + public id = id++; + constructor(public readonly value: T) { } +} +const compactionThreshold = 2; + +type ListenerContainer = UniqueContainer<(data: T) => void>; +type ListenerOrListeners = (ListenerContainer | undefined)[] | ListenerContainer; + +const forEachListener = (listeners: ListenerOrListeners, fn: (c: ListenerContainer) => void) => { + if (listeners instanceof UniqueContainer) { + fn(listeners); + } else { + for (let i = 0; i < listeners.length; i++) { + const l = listeners[i]; + if (l) { + fn(l); + } + } + } +}; + + +let _listenerFinalizers: FinalizationRegistry | undefined; + +if (_enableListenerGCedWarning) { + const leaks: string[] = []; + + setInterval(() => { + if (leaks.length === 0) { + return; + } + console.warn('[LEAKING LISTENERS] GC\'ed these listeners that were NOT yet disposed:'); + console.warn(leaks.join('\n')); + leaks.length = 0; + }, 3000); + + _listenerFinalizers = new FinalizationRegistry(heldValue => { + if (typeof heldValue === 'string') { + leaks.push(heldValue); + } + }); +} + +/** + * The Emitter can be used to expose an Event to the public + * to fire it from the insides. + * Sample: + class Document { + + private readonly _onDidChange = new Emitter<(value:string)=>any>(); + + public onDidChange = this._onDidChange.event; + + // getter-style + // get onDidChange(): Event<(value:string)=>any> { + // return this._onDidChange.event; + // } + + private _doIt() { + //... + this._onDidChange.fire(value); + } + } + */ +export class Emitter { + + private readonly _options?: EmitterOptions; + private readonly _leakageMon?: LeakageMonitor; + private readonly _perfMon?: EventProfiling; + private _disposed?: true; + private _event?: Event; + + /** + * A listener, or list of listeners. A single listener is the most common + * for event emitters (#185789), so we optimize that special case to avoid + * wrapping it in an array (just like Node.js itself.) + * + * A list of listeners never 'downgrades' back to a plain function if + * listeners are removed, for two reasons: + * + * 1. That's complicated (especially with the deliveryQueue) + * 2. A listener with >1 listener is likely to have >1 listener again at + * some point, and swapping between arrays and functions may[citation needed] + * introduce unnecessary work and garbage. + * + * The array listeners can be 'sparse', to avoid reallocating the array + * whenever any listener is added or removed. If more than `1 / compactionThreshold` + * of the array is empty, only then is it resized. + */ + protected _listeners?: ListenerOrListeners; + + /** + * Always to be defined if _listeners is an array. It's no longer a true + * queue, but holds the dispatching 'state'. If `fire()` is called on an + * emitter, any work left in the _deliveryQueue is finished first. + */ + private _deliveryQueue?: EventDeliveryQueuePrivate; + protected _size = 0; + + constructor(options?: EmitterOptions) { + this._options = options; + this._leakageMon = (_globalLeakWarningThreshold > 0 || this._options?.leakWarningThreshold) + ? new LeakageMonitor(options?.onListenerError ?? onUnexpectedError, this._options?.leakWarningThreshold ?? _globalLeakWarningThreshold) : + undefined; + this._perfMon = this._options?._profName ? new EventProfiling(this._options._profName) : undefined; + this._deliveryQueue = this._options?.deliveryQueue as EventDeliveryQueuePrivate | undefined; + } + + dispose() { + if (!this._disposed) { + this._disposed = true; + + // It is bad to have listeners at the time of disposing an emitter, it is worst to have listeners keep the emitter + // alive via the reference that's embedded in their disposables. Therefore we loop over all remaining listeners and + // unset their subscriptions/disposables. Looping and blaming remaining listeners is done on next tick because the + // the following programming pattern is very popular: + // + // const someModel = this._disposables.add(new ModelObject()); // (1) create and register model + // this._disposables.add(someModel.onDidChange(() => { ... }); // (2) subscribe and register model-event listener + // ...later... + // this._disposables.dispose(); disposes (1) then (2): don't warn after (1) but after the "overall dispose" is done + + if (this._deliveryQueue?.current === this) { + this._deliveryQueue.reset(); + } + if (this._listeners) { + if (_enableDisposeWithListenerWarning) { + const listeners = this._listeners; + queueMicrotask(() => { + forEachListener(listeners, l => l.stack?.print()); + }); + } + + this._listeners = undefined; + this._size = 0; + } + this._options?.onDidRemoveLastListener?.(); + this._leakageMon?.dispose(); + } + } + + /** + * For the public to allow to subscribe + * to events from this Emitter + */ + get event(): Event { + this._event ??= (callback: (e: T) => any, thisArgs?: any, disposables?: IDisposable[] | DisposableStore) => { + if (this._leakageMon && this._size > this._leakageMon.threshold ** 2) { + const message = `[${this._leakageMon.name}] REFUSES to accept new listeners because it exceeded its threshold by far (${this._size} vs ${this._leakageMon.threshold})`; + console.warn(message); + + const tuple = this._leakageMon.getMostFrequentStack() ?? ['UNKNOWN stack', -1]; + const error = new ListenerRefusalError(`${message}. HINT: Stack shows most frequent listener (${tuple[1]}-times)`, tuple[0]); + const errorHandler = this._options?.onListenerError || onUnexpectedError; + errorHandler(error); + + return Disposable.None; + } + + if (this._disposed) { + // todo: should we warn if a listener is added to a disposed emitter? This happens often + return Disposable.None; + } + + if (thisArgs) { + callback = callback.bind(thisArgs); + } + + const contained = new UniqueContainer(callback); + + let removeMonitor: Function | undefined; + let stack: Stacktrace | undefined; + if (this._leakageMon && this._size >= Math.ceil(this._leakageMon.threshold * 0.2)) { + // check and record this emitter for potential leakage + contained.stack = Stacktrace.create(); + removeMonitor = this._leakageMon.check(contained.stack, this._size + 1); + } + + if (_enableDisposeWithListenerWarning) { + contained.stack = stack ?? Stacktrace.create(); + } + + if (!this._listeners) { + this._options?.onWillAddFirstListener?.(this); + this._listeners = contained; + this._options?.onDidAddFirstListener?.(this); + } else if (this._listeners instanceof UniqueContainer) { + this._deliveryQueue ??= new EventDeliveryQueuePrivate(); + this._listeners = [this._listeners, contained]; + } else { + this._listeners.push(contained); + } + + this._size++; + + + const result = toDisposable(() => { + _listenerFinalizers?.unregister(result); + removeMonitor?.(); + this._removeListener(contained); + }); + if (disposables instanceof DisposableStore) { + disposables.add(result); + } else if (Array.isArray(disposables)) { + disposables.push(result); + } + + if (_listenerFinalizers) { + const stack = new Error().stack!.split('\n').slice(2, 3).join('\n').trim(); + const match = /(file:|vscode-file:\/\/vscode-app)?(\/[^:]*:\d+:\d+)/.exec(stack); + _listenerFinalizers.register(result, match?.[2] ?? stack, result); + } + + return result; + }; + + return this._event; + } + + private _removeListener(listener: ListenerContainer) { + this._options?.onWillRemoveListener?.(this); + + if (!this._listeners) { + return; // expected if a listener gets disposed + } + + if (this._size === 1) { + this._listeners = undefined; + this._options?.onDidRemoveLastListener?.(this); + this._size = 0; + return; + } + + // size > 1 which requires that listeners be a list: + const listeners = this._listeners as (ListenerContainer | undefined)[]; + + const index = listeners.indexOf(listener); + if (index === -1) { + console.log('disposed?', this._disposed); + console.log('size?', this._size); + console.log('arr?', JSON.stringify(this._listeners)); + throw new Error('Attempted to dispose unknown listener'); + } + + this._size--; + listeners[index] = undefined; + + const adjustDeliveryQueue = this._deliveryQueue!.current === this; + if (this._size * compactionThreshold <= listeners.length) { + let n = 0; + for (let i = 0; i < listeners.length; i++) { + if (listeners[i]) { + listeners[n++] = listeners[i]; + } else if (adjustDeliveryQueue) { + this._deliveryQueue!.end--; + if (n < this._deliveryQueue!.i) { + this._deliveryQueue!.i--; + } + } + } + listeners.length = n; + } + } + + private _deliver(listener: undefined | UniqueContainer<(value: T) => void>, value: T) { + if (!listener) { + return; + } + + const errorHandler = this._options?.onListenerError || onUnexpectedError; + if (!errorHandler) { + listener.value(value); + return; + } + + try { + listener.value(value); + } catch (e) { + errorHandler(e); + } + } + + /** Delivers items in the queue. Assumes the queue is ready to go. */ + private _deliverQueue(dq: EventDeliveryQueuePrivate) { + const listeners = dq.current!._listeners! as (ListenerContainer | undefined)[]; + while (dq.i < dq.end) { + // important: dq.i is incremented before calling deliver() because it might reenter deliverQueue() + this._deliver(listeners[dq.i++], dq.value as T); + } + dq.reset(); + } + + /** + * To be kept private to fire an event to + * subscribers + */ + fire(event: T): void { + if (this._deliveryQueue?.current) { + this._deliverQueue(this._deliveryQueue); + this._perfMon?.stop(); // last fire() will have starting perfmon, stop it before starting the next dispatch + } + + this._perfMon?.start(this._size); + + if (!this._listeners) { + // no-op + } else if (this._listeners instanceof UniqueContainer) { + this._deliver(this._listeners, event); + } else { + const dq = this._deliveryQueue!; + dq.enqueue(this, event, this._listeners.length); + this._deliverQueue(dq); + } + + this._perfMon?.stop(); + } + + hasListeners(): boolean { + return this._size > 0; + } +} + +export interface EventDeliveryQueue { + _isEventDeliveryQueue: true; +} + +export const createEventDeliveryQueue = (): EventDeliveryQueue => new EventDeliveryQueuePrivate(); + +class EventDeliveryQueuePrivate implements EventDeliveryQueue { + declare _isEventDeliveryQueue: true; + + /** + * Index in current's listener list. + */ + public i = -1; + + /** + * The last index in the listener's list to deliver. + */ + public end = 0; + + /** + * Emitter currently being dispatched on. Emitter._listeners is always an array. + */ + public current?: Emitter; + /** + * Currently emitting value. Defined whenever `current` is. + */ + public value?: unknown; + + public enqueue(emitter: Emitter, value: T, end: number) { + this.i = 0; + this.end = end; + this.current = emitter; + this.value = value; + } + + public reset() { + this.i = this.end; // force any current emission loop to stop, mainly for during dispose + this.current = undefined; + this.value = undefined; + } +} + +export interface IWaitUntil { + token: CancellationToken; + waitUntil(thenable: Promise): void; +} + +export type IWaitUntilData = Omit, 'token'>; + +export class AsyncEmitter extends Emitter { + + private _asyncDeliveryQueue?: LinkedList<[(ev: T) => void, IWaitUntilData]>; + + async fireAsync(data: IWaitUntilData, token: CancellationToken, promiseJoin?: (p: Promise, listener: Function) => Promise): Promise { + if (!this._listeners) { + return; + } + + if (!this._asyncDeliveryQueue) { + this._asyncDeliveryQueue = new LinkedList(); + } + + forEachListener(this._listeners, listener => this._asyncDeliveryQueue!.push([listener.value, data])); + + while (this._asyncDeliveryQueue.size > 0 && !token.isCancellationRequested) { + + const [listener, data] = this._asyncDeliveryQueue.shift()!; + const thenables: Promise[] = []; + + const event = { + ...data, + token, + waitUntil: (p: Promise): void => { + if (Object.isFrozen(thenables)) { + throw new Error('waitUntil can NOT be called asynchronous'); + } + if (promiseJoin) { + p = promiseJoin(p, listener); + } + thenables.push(p); + } + }; + + try { + listener(event); + } catch (e) { + onUnexpectedError(e); + continue; + } + + // freeze thenables-collection to enforce sync-calls to + // wait until and then wait for all thenables to resolve + Object.freeze(thenables); + + await Promise.allSettled(thenables).then(values => { + for (const value of values) { + if (value.status === 'rejected') { + onUnexpectedError(value.reason); + } + } + }); + } + } +} + + +export class PauseableEmitter extends Emitter { + + private _isPaused = 0; + protected _eventQueue = new LinkedList(); + private _mergeFn?: (input: T[]) => T; + + public get isPaused(): boolean { + return this._isPaused !== 0; + } + + constructor(options?: EmitterOptions & { merge?: (input: T[]) => T }) { + super(options); + this._mergeFn = options?.merge; + } + + pause(): void { + this._isPaused++; + } + + resume(): void { + if (this._isPaused !== 0 && --this._isPaused === 0) { + if (this._mergeFn) { + // use the merge function to create a single composite + // event. make a copy in case firing pauses this emitter + if (this._eventQueue.size > 0) { + const events = Array.from(this._eventQueue); + this._eventQueue.clear(); + super.fire(this._mergeFn(events)); + } + + } else { + // no merging, fire each event individually and test + // that this emitter isn't paused halfway through + while (!this._isPaused && this._eventQueue.size !== 0) { + super.fire(this._eventQueue.shift()!); + } + } + } + } + + override fire(event: T): void { + if (this._size) { + if (this._isPaused !== 0) { + this._eventQueue.push(event); + } else { + super.fire(event); + } + } + } +} + +export class DebounceEmitter extends PauseableEmitter { + + private readonly _delay: number; + private _handle: any | undefined; + + constructor(options: EmitterOptions & { merge: (input: T[]) => T; delay?: number }) { + super(options); + this._delay = options.delay ?? 100; + } + + override fire(event: T): void { + if (!this._handle) { + this.pause(); + this._handle = setTimeout(() => { + this._handle = undefined; + this.resume(); + }, this._delay); + } + super.fire(event); + } +} + +/** + * An emitter which queue all events and then process them at the + * end of the event loop. + */ +export class MicrotaskEmitter extends Emitter { + private _queuedEvents: T[] = []; + private _mergeFn?: (input: T[]) => T; + + constructor(options?: EmitterOptions & { merge?: (input: T[]) => T }) { + super(options); + this._mergeFn = options?.merge; + } + override fire(event: T): void { + + if (!this.hasListeners()) { + return; + } + + this._queuedEvents.push(event); + if (this._queuedEvents.length === 1) { + queueMicrotask(() => { + if (this._mergeFn) { + super.fire(this._mergeFn(this._queuedEvents)); + } else { + this._queuedEvents.forEach(e => super.fire(e)); + } + this._queuedEvents = []; + }); + } + } +} + +/** + * An event emitter that multiplexes many events into a single event. + * + * @example Listen to the `onData` event of all `Thing`s, dynamically adding and removing `Thing`s + * to the multiplexer as needed. + * + * ```typescript + * const anythingDataMultiplexer = new EventMultiplexer<{ data: string }>(); + * + * const thingListeners = DisposableMap(); + * + * thingService.onDidAddThing(thing => { + * thingListeners.set(thing, anythingDataMultiplexer.add(thing.onData); + * }); + * thingService.onDidRemoveThing(thing => { + * thingListeners.deleteAndDispose(thing); + * }); + * + * anythingDataMultiplexer.event(e => { + * console.log('Something fired data ' + e.data) + * }); + * ``` + */ +export class EventMultiplexer implements IDisposable { + + private readonly emitter: Emitter; + private hasListeners = false; + private events: { event: Event; listener: IDisposable | null }[] = []; + + constructor() { + this.emitter = new Emitter({ + onWillAddFirstListener: () => this.onFirstListenerAdd(), + onDidRemoveLastListener: () => this.onLastListenerRemove() + }); + } + + get event(): Event { + return this.emitter.event; + } + + add(event: Event): IDisposable { + const e = { event: event, listener: null }; + this.events.push(e); + + if (this.hasListeners) { + this.hook(e); + } + + const dispose = () => { + if (this.hasListeners) { + this.unhook(e); + } + + const idx = this.events.indexOf(e); + this.events.splice(idx, 1); + }; + + return toDisposable(createSingleCallFunction(dispose)); + } + + private onFirstListenerAdd(): void { + this.hasListeners = true; + this.events.forEach(e => this.hook(e)); + } + + private onLastListenerRemove(): void { + this.hasListeners = false; + this.events.forEach(e => this.unhook(e)); + } + + private hook(e: { event: Event; listener: IDisposable | null }): void { + e.listener = e.event(r => this.emitter.fire(r)); + } + + private unhook(e: { event: Event; listener: IDisposable | null }): void { + e.listener?.dispose(); + e.listener = null; + } + + dispose(): void { + this.emitter.dispose(); + + for (const e of this.events) { + e.listener?.dispose(); + } + this.events = []; + } +} + +export interface IDynamicListEventMultiplexer extends IDisposable { + readonly event: Event; +} +export class DynamicListEventMultiplexer implements IDynamicListEventMultiplexer { + private readonly _store = new DisposableStore(); + + readonly event: Event; + + constructor( + items: TItem[], + onAddItem: Event, + onRemoveItem: Event, + getEvent: (item: TItem) => Event + ) { + const multiplexer = this._store.add(new EventMultiplexer()); + const itemListeners = this._store.add(new DisposableMap()); + + function addItem(instance: TItem) { + itemListeners.set(instance, multiplexer.add(getEvent(instance))); + } + + // Existing items + for (const instance of items) { + addItem(instance); + } + + // Added items + this._store.add(onAddItem(instance => { + addItem(instance); + })); + + // Removed items + this._store.add(onRemoveItem(instance => { + itemListeners.deleteAndDispose(instance); + })); + + this.event = multiplexer.event; + } + + dispose() { + this._store.dispose(); + } +} + +/** + * The EventBufferer is useful in situations in which you want + * to delay firing your events during some code. + * You can wrap that code and be sure that the event will not + * be fired during that wrap. + * + * ``` + * const emitter: Emitter; + * const delayer = new EventDelayer(); + * const delayedEvent = delayer.wrapEvent(emitter.event); + * + * delayedEvent(console.log); + * + * delayer.bufferEvents(() => { + * emitter.fire(); // event will not be fired yet + * }); + * + * // event will only be fired at this point + * ``` + */ +export class EventBufferer { + + private data: { buffers: Function[] }[] = []; + + wrapEvent(event: Event): Event; + wrapEvent(event: Event, reduce: (last: T | undefined, event: T) => T): Event; + wrapEvent(event: Event, reduce: (last: O | undefined, event: T) => O, initial: O): Event; + wrapEvent(event: Event, reduce?: (last: T | O | undefined, event: T) => T | O, initial?: O): Event { + return (listener, thisArgs?, disposables?) => { + return event(i => { + const data = this.data[this.data.length - 1]; + + // Non-reduce scenario + if (!reduce) { + // Buffering case + if (data) { + data.buffers.push(() => listener.call(thisArgs, i)); + } else { + // Not buffering case + listener.call(thisArgs, i); + } + return; + } + + // Reduce scenario + const reduceData = data as typeof data & { + /** + * The accumulated items that will be reduced. + */ + items?: T[]; + /** + * The reduced result cached to be shared with other listeners. + */ + reducedResult?: T | O; + }; + + // Not buffering case + if (!reduceData) { + // TODO: Is there a way to cache this reduce call for all listeners? + listener.call(thisArgs, reduce(initial, i)); + return; + } + + // Buffering case + reduceData.items ??= []; + reduceData.items.push(i); + if (reduceData.buffers.length === 0) { + // Include a single buffered function that will reduce all events when we're done buffering events + data.buffers.push(() => { + // cache the reduced result so that the value can be shared across all listeners + reduceData.reducedResult ??= initial + ? reduceData.items!.reduce(reduce as (last: O | undefined, event: T) => O, initial) + : reduceData.items!.reduce(reduce as (last: T | undefined, event: T) => T); + listener.call(thisArgs, reduceData.reducedResult); + }); + } + }, undefined, disposables); + }; + } + + bufferEvents(fn: () => R): R { + const data = { buffers: new Array() }; + this.data.push(data); + const r = fn(); + this.data.pop(); + data.buffers.forEach(flush => flush()); + return r; + } +} + +/** + * A Relay is an event forwarder which functions as a replugabble event pipe. + * Once created, you can connect an input event to it and it will simply forward + * events from that input event through its own `event` property. The `input` + * can be changed at any point in time. + */ +export class Relay implements IDisposable { + + private listening = false; + private inputEvent: Event = Event.None; + private inputEventListener: IDisposable = Disposable.None; + + private readonly emitter = new Emitter({ + onDidAddFirstListener: () => { + this.listening = true; + this.inputEventListener = this.inputEvent(this.emitter.fire, this.emitter); + }, + onDidRemoveLastListener: () => { + this.listening = false; + this.inputEventListener.dispose(); + } + }); + + readonly event: Event = this.emitter.event; + + set input(event: Event) { + this.inputEvent = event; + + if (this.listening) { + this.inputEventListener.dispose(); + this.inputEventListener = event(this.emitter.fire, this.emitter); + } + } + + dispose() { + this.inputEventListener.dispose(); + this.emitter.dispose(); + } +} + +export interface IValueWithChangeEvent { + readonly onDidChange: Event; + get value(): T; +} + +export class ValueWithChangeEvent implements IValueWithChangeEvent { + public static const(value: T): IValueWithChangeEvent { + return new ConstValueWithChangeEvent(value); + } + + private readonly _onDidChange = new Emitter(); + readonly onDidChange: Event = this._onDidChange.event; + + constructor(private _value: T) { } + + get value(): T { + return this._value; + } + + set value(value: T) { + if (value !== this._value) { + this._value = value; + this._onDidChange.fire(undefined); + } + } +} + +class ConstValueWithChangeEvent implements IValueWithChangeEvent { + public readonly onDidChange: Event = Event.None; + + constructor(readonly value: T) { } +} diff --git a/src/vs/base/common/extpath.ts b/src/vs/base/common/extpath.ts new file mode 100644 index 0000000000..e0ee6968dc --- /dev/null +++ b/src/vs/base/common/extpath.ts @@ -0,0 +1,423 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CharCode } from 'vs/base/common/charCode'; +import { isAbsolute, join, normalize, posix, sep } from 'vs/base/common/path'; +import { isWindows } from 'vs/base/common/platform'; +import { equalsIgnoreCase, rtrim, startsWithIgnoreCase } from 'vs/base/common/strings'; +import { isNumber } from 'vs/base/common/types'; + +export function isPathSeparator(code: number) { + return code === CharCode.Slash || code === CharCode.Backslash; +} + +/** + * Takes a Windows OS path and changes backward slashes to forward slashes. + * This should only be done for OS paths from Windows (or user provided paths potentially from Windows). + * Using it on a Linux or MaxOS path might change it. + */ +export function toSlashes(osPath: string) { + return osPath.replace(/[\\/]/g, posix.sep); +} + +/** + * Takes a Windows OS path (using backward or forward slashes) and turns it into a posix path: + * - turns backward slashes into forward slashes + * - makes it absolute if it starts with a drive letter + * This should only be done for OS paths from Windows (or user provided paths potentially from Windows). + * Using it on a Linux or MaxOS path might change it. + */ +export function toPosixPath(osPath: string) { + if (osPath.indexOf('/') === -1) { + osPath = toSlashes(osPath); + } + if (/^[a-zA-Z]:(\/|$)/.test(osPath)) { // starts with a drive letter + osPath = '/' + osPath; + } + return osPath; +} + +/** + * Computes the _root_ this path, like `getRoot('c:\files') === c:\`, + * `getRoot('files:///files/path') === files:///`, + * or `getRoot('\\server\shares\path') === \\server\shares\` + */ +export function getRoot(path: string, sep: string = posix.sep): string { + if (!path) { + return ''; + } + + const len = path.length; + const firstLetter = path.charCodeAt(0); + if (isPathSeparator(firstLetter)) { + if (isPathSeparator(path.charCodeAt(1))) { + // UNC candidate \\localhost\shares\ddd + // ^^^^^^^^^^^^^^^^^^^ + if (!isPathSeparator(path.charCodeAt(2))) { + let pos = 3; + const start = pos; + for (; pos < len; pos++) { + if (isPathSeparator(path.charCodeAt(pos))) { + break; + } + } + if (start !== pos && !isPathSeparator(path.charCodeAt(pos + 1))) { + pos += 1; + for (; pos < len; pos++) { + if (isPathSeparator(path.charCodeAt(pos))) { + return path.slice(0, pos + 1) // consume this separator + .replace(/[\\/]/g, sep); + } + } + } + } + } + + // /user/far + // ^ + return sep; + + } else if (isWindowsDriveLetter(firstLetter)) { + // check for windows drive letter c:\ or c: + + if (path.charCodeAt(1) === CharCode.Colon) { + if (isPathSeparator(path.charCodeAt(2))) { + // C:\fff + // ^^^ + return path.slice(0, 2) + sep; + } else { + // C: + // ^^ + return path.slice(0, 2); + } + } + } + + // check for URI + // scheme://authority/path + // ^^^^^^^^^^^^^^^^^^^ + let pos = path.indexOf('://'); + if (pos !== -1) { + pos += 3; // 3 -> "://".length + for (; pos < len; pos++) { + if (isPathSeparator(path.charCodeAt(pos))) { + return path.slice(0, pos + 1); // consume this separator + } + } + } + + return ''; +} + +/** + * Check if the path follows this pattern: `\\hostname\sharename`. + * + * @see https://msdn.microsoft.com/en-us/library/gg465305.aspx + * @return A boolean indication if the path is a UNC path, on none-windows + * always false. + */ +export function isUNC(path: string): boolean { + if (!isWindows) { + // UNC is a windows concept + return false; + } + + if (!path || path.length < 5) { + // at least \\a\b + return false; + } + + let code = path.charCodeAt(0); + if (code !== CharCode.Backslash) { + return false; + } + + code = path.charCodeAt(1); + + if (code !== CharCode.Backslash) { + return false; + } + + let pos = 2; + const start = pos; + for (; pos < path.length; pos++) { + code = path.charCodeAt(pos); + if (code === CharCode.Backslash) { + break; + } + } + + if (start === pos) { + return false; + } + + code = path.charCodeAt(pos + 1); + + if (isNaN(code) || code === CharCode.Backslash) { + return false; + } + + return true; +} + +// Reference: https://en.wikipedia.org/wiki/Filename +const WINDOWS_INVALID_FILE_CHARS = /[\\/:\*\?"<>\|]/g; +const UNIX_INVALID_FILE_CHARS = /[/]/g; +const WINDOWS_FORBIDDEN_NAMES = /^(con|prn|aux|clock\$|nul|lpt[0-9]|com[0-9])(\.(.*?))?$/i; +export function isValidBasename(name: string | null | undefined, isWindowsOS: boolean = isWindows): boolean { + const invalidFileChars = isWindowsOS ? WINDOWS_INVALID_FILE_CHARS : UNIX_INVALID_FILE_CHARS; + + if (!name || name.length === 0 || /^\s+$/.test(name)) { + return false; // require a name that is not just whitespace + } + + invalidFileChars.lastIndex = 0; // the holy grail of software development + if (invalidFileChars.test(name)) { + return false; // check for certain invalid file characters + } + + if (isWindowsOS && WINDOWS_FORBIDDEN_NAMES.test(name)) { + return false; // check for certain invalid file names + } + + if (name === '.' || name === '..') { + return false; // check for reserved values + } + + if (isWindowsOS && name[name.length - 1] === '.') { + return false; // Windows: file cannot end with a "." + } + + if (isWindowsOS && name.length !== name.trim().length) { + return false; // Windows: file cannot end with a whitespace + } + + if (name.length > 255) { + return false; // most file systems do not allow files > 255 length + } + + return true; +} + +/** + * @deprecated please use `IUriIdentityService.extUri.isEqual` instead. If you are + * in a context without services, consider to pass down the `extUri` from the outside + * or use `extUriBiasedIgnorePathCase` if you know what you are doing. + */ +export function isEqual(pathA: string, pathB: string, ignoreCase?: boolean): boolean { + const identityEquals = (pathA === pathB); + if (!ignoreCase || identityEquals) { + return identityEquals; + } + + if (!pathA || !pathB) { + return false; + } + + return equalsIgnoreCase(pathA, pathB); +} + +/** + * @deprecated please use `IUriIdentityService.extUri.isEqualOrParent` instead. If + * you are in a context without services, consider to pass down the `extUri` from the + * outside, or use `extUriBiasedIgnorePathCase` if you know what you are doing. + */ +export function isEqualOrParent(base: string, parentCandidate: string, ignoreCase?: boolean, separator = sep): boolean { + if (base === parentCandidate) { + return true; + } + + if (!base || !parentCandidate) { + return false; + } + + if (parentCandidate.length > base.length) { + return false; + } + + if (ignoreCase) { + const beginsWith = startsWithIgnoreCase(base, parentCandidate); + if (!beginsWith) { + return false; + } + + if (parentCandidate.length === base.length) { + return true; // same path, different casing + } + + let sepOffset = parentCandidate.length; + if (parentCandidate.charAt(parentCandidate.length - 1) === separator) { + sepOffset--; // adjust the expected sep offset in case our candidate already ends in separator character + } + + return base.charAt(sepOffset) === separator; + } + + if (parentCandidate.charAt(parentCandidate.length - 1) !== separator) { + parentCandidate += separator; + } + + return base.indexOf(parentCandidate) === 0; +} + +export function isWindowsDriveLetter(char0: number): boolean { + return char0 >= CharCode.A && char0 <= CharCode.Z || char0 >= CharCode.a && char0 <= CharCode.z; +} + +export function sanitizeFilePath(candidate: string, cwd: string): string { + + // Special case: allow to open a drive letter without trailing backslash + if (isWindows && candidate.endsWith(':')) { + candidate += sep; + } + + // Ensure absolute + if (!isAbsolute(candidate)) { + candidate = join(cwd, candidate); + } + + // Ensure normalized + candidate = normalize(candidate); + + // Ensure no trailing slash/backslash + return removeTrailingPathSeparator(candidate); +} + +export function removeTrailingPathSeparator(candidate: string): string { + if (isWindows) { + candidate = rtrim(candidate, sep); + + // Special case: allow to open drive root ('C:\') + if (candidate.endsWith(':')) { + candidate += sep; + } + + } else { + candidate = rtrim(candidate, sep); + + // Special case: allow to open root ('/') + if (!candidate) { + candidate = sep; + } + } + + return candidate; +} + +export function isRootOrDriveLetter(path: string): boolean { + const pathNormalized = normalize(path); + + if (isWindows) { + if (path.length > 3) { + return false; + } + + return hasDriveLetter(pathNormalized) && + (path.length === 2 || pathNormalized.charCodeAt(2) === CharCode.Backslash); + } + + return pathNormalized === posix.sep; +} + +export function hasDriveLetter(path: string, isWindowsOS: boolean = isWindows): boolean { + if (isWindowsOS) { + return isWindowsDriveLetter(path.charCodeAt(0)) && path.charCodeAt(1) === CharCode.Colon; + } + + return false; +} + +export function getDriveLetter(path: string, isWindowsOS: boolean = isWindows): string | undefined { + return hasDriveLetter(path, isWindowsOS) ? path[0] : undefined; +} + +export function indexOfPath(path: string, candidate: string, ignoreCase?: boolean): number { + if (candidate.length > path.length) { + return -1; + } + + if (path === candidate) { + return 0; + } + + if (ignoreCase) { + path = path.toLowerCase(); + candidate = candidate.toLowerCase(); + } + + return path.indexOf(candidate); +} + +export interface IPathWithLineAndColumn { + path: string; + line?: number; + column?: number; +} + +export function parseLineAndColumnAware(rawPath: string): IPathWithLineAndColumn { + const segments = rawPath.split(':'); // C:\file.txt:: + + let path: string | undefined = undefined; + let line: number | undefined = undefined; + let column: number | undefined = undefined; + + for (const segment of segments) { + const segmentAsNumber = Number(segment); + if (!isNumber(segmentAsNumber)) { + path = !!path ? [path, segment].join(':') : segment; // a colon can well be part of a path (e.g. C:\...) + } else if (line === undefined) { + line = segmentAsNumber; + } else if (column === undefined) { + column = segmentAsNumber; + } + } + + if (!path) { + throw new Error('Format for `--goto` should be: `FILE:LINE(:COLUMN)`'); + } + + return { + path, + line: line !== undefined ? line : undefined, + column: column !== undefined ? column : line !== undefined ? 1 : undefined // if we have a line, make sure column is also set + }; +} + +const pathChars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; +const windowsSafePathFirstChars = 'BDEFGHIJKMOQRSTUVWXYZbdefghijkmoqrstuvwxyz0123456789'; + +export function randomPath(parent?: string, prefix?: string, randomLength = 8): string { + let suffix = ''; + for (let i = 0; i < randomLength; i++) { + let pathCharsTouse: string; + if (i === 0 && isWindows && !prefix && (randomLength === 3 || randomLength === 4)) { + + // Windows has certain reserved file names that cannot be used, such + // as AUX, CON, PRN, etc. We want to avoid generating a random name + // that matches that pattern, so we use a different set of characters + // for the first character of the name that does not include any of + // the reserved names first characters. + + pathCharsTouse = windowsSafePathFirstChars; + } else { + pathCharsTouse = pathChars; + } + + suffix += pathCharsTouse.charAt(Math.floor(Math.random() * pathCharsTouse.length)); + } + + let randomFileName: string; + if (prefix) { + randomFileName = `${prefix}-${suffix}`; + } else { + randomFileName = suffix; + } + + if (parent) { + return join(parent, randomFileName); + } + + return randomFileName; +} diff --git a/src/vs/base/common/functional.ts b/src/vs/base/common/functional.ts new file mode 100644 index 0000000000..d580cf37f1 --- /dev/null +++ b/src/vs/base/common/functional.ts @@ -0,0 +1,32 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Given a function, returns a function that is only calling that function once. + */ +export function createSingleCallFunction(this: unknown, fn: T, fnDidRunCallback?: () => void): T { + const _this = this; + let didCall = false; + let result: unknown; + + return function () { + if (didCall) { + return result; + } + + didCall = true; + if (fnDidRunCallback) { + try { + result = fn.apply(_this, arguments); + } finally { + fnDidRunCallback(); + } + } else { + result = fn.apply(_this, arguments); + } + + return result; + } as unknown as T; +} diff --git a/src/vs/base/common/glob.ts b/src/vs/base/common/glob.ts new file mode 100644 index 0000000000..2f24135eb9 --- /dev/null +++ b/src/vs/base/common/glob.ts @@ -0,0 +1,737 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { equals } from 'vs/base/common/arrays'; +import { isThenable } from 'vs/base/common/async'; +import { CharCode } from 'vs/base/common/charCode'; +import { isEqualOrParent } from 'vs/base/common/extpath'; +import { basename, extname, posix, sep } from 'vs/base/common/path'; +import { isLinux } from 'vs/base/common/platform'; +import { escapeRegExpCharacters, ltrim } from 'vs/base/common/strings'; + +export interface IRelativePattern { + + /** + * A base file path to which this pattern will be matched against relatively. + */ + readonly base: string; + + /** + * A file glob pattern like `*.{ts,js}` that will be matched on file paths + * relative to the base path. + * + * Example: Given a base of `/home/work/folder` and a file path of `/home/work/folder/index.js`, + * the file glob pattern will match on `index.js`. + */ + readonly pattern: string; +} + +export interface IExpression { + [pattern: string]: boolean | SiblingClause; +} + +export function getEmptyExpression(): IExpression { + return Object.create(null); +} + +interface SiblingClause { + when: string; +} + +export const GLOBSTAR = '**'; +export const GLOB_SPLIT = '/'; + +const PATH_REGEX = '[/\\\\]'; // any slash or backslash +const NO_PATH_REGEX = '[^/\\\\]'; // any non-slash and non-backslash +const ALL_FORWARD_SLASHES = /\//g; + +function starsToRegExp(starCount: number, isLastPattern?: boolean): string { + switch (starCount) { + case 0: + return ''; + case 1: + return `${NO_PATH_REGEX}*?`; // 1 star matches any number of characters except path separator (/ and \) - non greedy (?) + default: + // Matches: (Path Sep OR Path Val followed by Path Sep) 0-many times except when it's the last pattern + // in which case also matches (Path Sep followed by Path Val) + // Group is non capturing because we don't need to capture at all (?:...) + // Overall we use non-greedy matching because it could be that we match too much + return `(?:${PATH_REGEX}|${NO_PATH_REGEX}+${PATH_REGEX}${isLastPattern ? `|${PATH_REGEX}${NO_PATH_REGEX}+` : ''})*?`; + } +} + +export function splitGlobAware(pattern: string, splitChar: string): string[] { + if (!pattern) { + return []; + } + + const segments: string[] = []; + + let inBraces = false; + let inBrackets = false; + + let curVal = ''; + for (const char of pattern) { + switch (char) { + case splitChar: + if (!inBraces && !inBrackets) { + segments.push(curVal); + curVal = ''; + + continue; + } + break; + case '{': + inBraces = true; + break; + case '}': + inBraces = false; + break; + case '[': + inBrackets = true; + break; + case ']': + inBrackets = false; + break; + } + + curVal += char; + } + + // Tail + if (curVal) { + segments.push(curVal); + } + + return segments; +} + +function parseRegExp(pattern: string): string { + if (!pattern) { + return ''; + } + + let regEx = ''; + + // Split up into segments for each slash found + const segments = splitGlobAware(pattern, GLOB_SPLIT); + + // Special case where we only have globstars + if (segments.every(segment => segment === GLOBSTAR)) { + regEx = '.*'; + } + + // Build regex over segments + else { + let previousSegmentWasGlobStar = false; + segments.forEach((segment, index) => { + + // Treat globstar specially + if (segment === GLOBSTAR) { + + // if we have more than one globstar after another, just ignore it + if (previousSegmentWasGlobStar) { + return; + } + + regEx += starsToRegExp(2, index === segments.length - 1); + } + + // Anything else, not globstar + else { + + // States + let inBraces = false; + let braceVal = ''; + + let inBrackets = false; + let bracketVal = ''; + + for (const char of segment) { + + // Support brace expansion + if (char !== '}' && inBraces) { + braceVal += char; + continue; + } + + // Support brackets + if (inBrackets && (char !== ']' || !bracketVal) /* ] is literally only allowed as first character in brackets to match it */) { + let res: string; + + // range operator + if (char === '-') { + res = char; + } + + // negation operator (only valid on first index in bracket) + else if ((char === '^' || char === '!') && !bracketVal) { + res = '^'; + } + + // glob split matching is not allowed within character ranges + // see http://man7.org/linux/man-pages/man7/glob.7.html + else if (char === GLOB_SPLIT) { + res = ''; + } + + // anything else gets escaped + else { + res = escapeRegExpCharacters(char); + } + + bracketVal += res; + continue; + } + + switch (char) { + case '{': + inBraces = true; + continue; + + case '[': + inBrackets = true; + continue; + + case '}': { + const choices = splitGlobAware(braceVal, ','); + + // Converts {foo,bar} => [foo|bar] + const braceRegExp = `(?:${choices.map(choice => parseRegExp(choice)).join('|')})`; + + regEx += braceRegExp; + + inBraces = false; + braceVal = ''; + + break; + } + + case ']': { + regEx += ('[' + bracketVal + ']'); + + inBrackets = false; + bracketVal = ''; + + break; + } + + case '?': + regEx += NO_PATH_REGEX; // 1 ? matches any single character except path separator (/ and \) + continue; + + case '*': + regEx += starsToRegExp(1); + continue; + + default: + regEx += escapeRegExpCharacters(char); + } + } + + // Tail: Add the slash we had split on if there is more to + // come and the remaining pattern is not a globstar + // For example if pattern: some/**/*.js we want the "/" after + // some to be included in the RegEx to prevent a folder called + // "something" to match as well. + if ( + index < segments.length - 1 && // more segments to come after this + ( + segments[index + 1] !== GLOBSTAR || // next segment is not **, or... + index + 2 < segments.length // ...next segment is ** but there is more segments after that + ) + ) { + regEx += PATH_REGEX; + } + } + + // update globstar state + previousSegmentWasGlobStar = (segment === GLOBSTAR); + }); + } + + return regEx; +} + +// regexes to check for trivial glob patterns that just check for String#endsWith +const T1 = /^\*\*\/\*\.[\w\.-]+$/; // **/*.something +const T2 = /^\*\*\/([\w\.-]+)\/?$/; // **/something +const T3 = /^{\*\*\/\*?[\w\.-]+\/?(,\*\*\/\*?[\w\.-]+\/?)*}$/; // {**/*.something,**/*.else} or {**/package.json,**/project.json} +const T3_2 = /^{\*\*\/\*?[\w\.-]+(\/(\*\*)?)?(,\*\*\/\*?[\w\.-]+(\/(\*\*)?)?)*}$/; // Like T3, with optional trailing /** +const T4 = /^\*\*((\/[\w\.-]+)+)\/?$/; // **/something/else +const T5 = /^([\w\.-]+(\/[\w\.-]+)*)\/?$/; // something/else + +export type ParsedPattern = (path: string, basename?: string) => boolean; + +// The `ParsedExpression` returns a `Promise` +// iff `hasSibling` returns a `Promise`. +export type ParsedExpression = (path: string, basename?: string, hasSibling?: (name: string) => boolean | Promise) => string | null | Promise /* the matching pattern */; + +interface IGlobOptions { + + /** + * Simplify patterns for use as exclusion filters during + * tree traversal to skip entire subtrees. Cannot be used + * outside of a tree traversal. + */ + trimForExclusions?: boolean; +} + +interface ParsedStringPattern { + (path: string, basename?: string): string | null | Promise /* the matching pattern */; + basenames?: string[]; + patterns?: string[]; + allBasenames?: string[]; + allPaths?: string[]; +} + +interface ParsedExpressionPattern { + (path: string, basename?: string, name?: string, hasSibling?: (name: string) => boolean | Promise): string | null | Promise /* the matching pattern */; + requiresSiblings?: boolean; + allBasenames?: string[]; + allPaths?: string[]; +} + +const FALSE = function () { + return false; +}; + +const NULL = function (): string | null { + return null; +}; + +function trimForExclusions(pattern: string, options: IGlobOptions): string { + return options.trimForExclusions && pattern.endsWith('/**') ? pattern.substr(0, pattern.length - 2) : pattern; // dropping **, tailing / is dropped later +} + +// common pattern: **/*.txt just need endsWith check +function trivia1(base: string, pattern: string): ParsedStringPattern { + return function (path: string, basename?: string) { + return typeof path === 'string' && path.endsWith(base) ? pattern : null; + }; +} + +// common pattern: **/some.txt just need basename check +function trivia2(base: string, pattern: string): ParsedStringPattern { + const slashBase = `/${base}`; + const backslashBase = `\\${base}`; + + const parsedPattern: ParsedStringPattern = function (path: string, basename?: string) { + if (typeof path !== 'string') { + return null; + } + + if (basename) { + return basename === base ? pattern : null; + } + + return path === base || path.endsWith(slashBase) || path.endsWith(backslashBase) ? pattern : null; + }; + + const basenames = [base]; + parsedPattern.basenames = basenames; + parsedPattern.patterns = [pattern]; + parsedPattern.allBasenames = basenames; + + return parsedPattern; +} + +// repetition of common patterns (see above) {**/*.txt,**/*.png} +function trivia3(pattern: string, options: IGlobOptions): ParsedStringPattern { + const parsedPatterns = aggregateBasenameMatches(pattern.slice(1, -1) + .split(',') + .map(pattern => parsePattern(pattern, options)) + .filter(pattern => pattern !== NULL), pattern); + + const patternsLength = parsedPatterns.length; + if (!patternsLength) { + return NULL; + } + + if (patternsLength === 1) { + return parsedPatterns[0]; + } + + const parsedPattern: ParsedStringPattern = function (path: string, basename?: string) { + for (let i = 0, n = parsedPatterns.length; i < n; i++) { + if (parsedPatterns[i](path, basename)) { + return pattern; + } + } + + return null; + }; + + const withBasenames = parsedPatterns.find(pattern => !!pattern.allBasenames); + if (withBasenames) { + parsedPattern.allBasenames = withBasenames.allBasenames; + } + + const allPaths = parsedPatterns.reduce((all, current) => current.allPaths ? all.concat(current.allPaths) : all, [] as string[]); + if (allPaths.length) { + parsedPattern.allPaths = allPaths; + } + + return parsedPattern; +} + +// common patterns: **/something/else just need endsWith check, something/else just needs and equals check +function trivia4and5(targetPath: string, pattern: string, matchPathEnds: boolean): ParsedStringPattern { + const usingPosixSep = sep === posix.sep; + const nativePath = usingPosixSep ? targetPath : targetPath.replace(ALL_FORWARD_SLASHES, sep); + const nativePathEnd = sep + nativePath; + const targetPathEnd = posix.sep + targetPath; + + let parsedPattern: ParsedStringPattern; + if (matchPathEnds) { + parsedPattern = function (path: string, basename?: string) { + return typeof path === 'string' && ((path === nativePath || path.endsWith(nativePathEnd)) || !usingPosixSep && (path === targetPath || path.endsWith(targetPathEnd))) ? pattern : null; + }; + } else { + parsedPattern = function (path: string, basename?: string) { + return typeof path === 'string' && (path === nativePath || (!usingPosixSep && path === targetPath)) ? pattern : null; + }; + } + + parsedPattern.allPaths = [(matchPathEnds ? '*/' : './') + targetPath]; + + return parsedPattern; +} + +function toRegExp(pattern: string): ParsedStringPattern { + try { + const regExp = new RegExp(`^${parseRegExp(pattern)}$`); + return function (path: string) { + regExp.lastIndex = 0; // reset RegExp to its initial state to reuse it! + + return typeof path === 'string' && regExp.test(path) ? pattern : null; + }; + } catch (error) { + return NULL; + } +} + +/** + * Simplified glob matching. Supports a subset of glob patterns: + * * `*` to match zero or more characters in a path segment + * * `?` to match on one character in a path segment + * * `**` to match any number of path segments, including none + * * `{}` to group conditions (e.g. *.{ts,js} matches all TypeScript and JavaScript files) + * * `[]` to declare a range of characters to match in a path segment (e.g., `example.[0-9]` to match on `example.0`, `example.1`, …) + * * `[!...]` to negate a range of characters to match in a path segment (e.g., `example.[!0-9]` to match on `example.a`, `example.b`, but not `example.0`) + */ +export function match(pattern: string | IRelativePattern, path: string): boolean; +export function match(expression: IExpression, path: string, hasSibling?: (name: string) => boolean): string /* the matching pattern */; +export function match(arg1: string | IExpression | IRelativePattern, path: string, hasSibling?: (name: string) => boolean): boolean | string | null | Promise { + if (!arg1 || typeof path !== 'string') { + return false; + } + + return parse(arg1)(path, undefined, hasSibling); +} + +/** + * Simplified glob matching. Supports a subset of glob patterns: + * * `*` to match zero or more characters in a path segment + * * `?` to match on one character in a path segment + * * `**` to match any number of path segments, including none + * * `{}` to group conditions (e.g. *.{ts,js} matches all TypeScript and JavaScript files) + * * `[]` to declare a range of characters to match in a path segment (e.g., `example.[0-9]` to match on `example.0`, `example.1`, …) + * * `[!...]` to negate a range of characters to match in a path segment (e.g., `example.[!0-9]` to match on `example.a`, `example.b`, but not `example.0`) + */ +export function parse(pattern: string | IRelativePattern, options?: IGlobOptions): ParsedPattern; +export function parse(expression: IExpression, options?: IGlobOptions): ParsedExpression; +export function parse(arg1: string | IExpression | IRelativePattern, options?: IGlobOptions): ParsedPattern | ParsedExpression; +export function parse(arg1: string | IExpression | IRelativePattern, options: IGlobOptions = {}): ParsedPattern | ParsedExpression { + if (!arg1) { + return FALSE; + } + + // Glob with String + if (typeof arg1 === 'string' || isRelativePattern(arg1)) { + const parsedPattern = parsePattern(arg1, options); + if (parsedPattern === NULL) { + return FALSE; + } + + const resultPattern: ParsedPattern & { allBasenames?: string[]; allPaths?: string[] } = function (path: string, basename?: string) { + return !!parsedPattern(path, basename); + }; + + if (parsedPattern.allBasenames) { + resultPattern.allBasenames = parsedPattern.allBasenames; + } + + if (parsedPattern.allPaths) { + resultPattern.allPaths = parsedPattern.allPaths; + } + + return resultPattern; + } + + // Glob with Expression + return parsedExpression(arg1, options); +} + +export function isRelativePattern(obj: unknown): obj is IRelativePattern { + const rp = obj as IRelativePattern | undefined | null; + if (!rp) { + return false; + } + + return typeof rp.base === 'string' && typeof rp.pattern === 'string'; +} + +export function getBasenameTerms(patternOrExpression: ParsedPattern | ParsedExpression): string[] { + return (patternOrExpression).allBasenames || []; +} + +export function getPathTerms(patternOrExpression: ParsedPattern | ParsedExpression): string[] { + return (patternOrExpression).allPaths || []; +} + +function parsedExpression(expression: IExpression, options: IGlobOptions): ParsedExpression { + const parsedPatterns = aggregateBasenameMatches(Object.getOwnPropertyNames(expression) + .map(pattern => parseExpressionPattern(pattern, expression[pattern], options)) + .filter(pattern => pattern !== NULL)); + + const patternsLength = parsedPatterns.length; + if (!patternsLength) { + return NULL; + } + + if (!parsedPatterns.some(parsedPattern => !!(parsedPattern).requiresSiblings)) { + if (patternsLength === 1) { + return parsedPatterns[0] as ParsedStringPattern; + } + + const resultExpression: ParsedStringPattern = function (path: string, basename?: string) { + let resultPromises: Promise[] | undefined = undefined; + + for (let i = 0, n = parsedPatterns.length; i < n; i++) { + const result = parsedPatterns[i](path, basename); + if (typeof result === 'string') { + return result; // immediately return as soon as the first expression matches + } + + // If the result is a promise, we have to keep it for + // later processing and await the result properly. + if (isThenable(result)) { + if (!resultPromises) { + resultPromises = []; + } + + resultPromises.push(result); + } + } + + // With result promises, we have to loop over each and + // await the result before we can return any result. + if (resultPromises) { + return (async () => { + for (const resultPromise of resultPromises) { + const result = await resultPromise; + if (typeof result === 'string') { + return result; + } + } + + return null; + })(); + } + + return null; + }; + + const withBasenames = parsedPatterns.find(pattern => !!pattern.allBasenames); + if (withBasenames) { + resultExpression.allBasenames = withBasenames.allBasenames; + } + + const allPaths = parsedPatterns.reduce((all, current) => current.allPaths ? all.concat(current.allPaths) : all, [] as string[]); + if (allPaths.length) { + resultExpression.allPaths = allPaths; + } + + return resultExpression; + } + + const resultExpression: ParsedStringPattern = function (path: string, base?: string, hasSibling?: (name: string) => boolean | Promise) { + let name: string | undefined = undefined; + let resultPromises: Promise[] | undefined = undefined; + + for (let i = 0, n = parsedPatterns.length; i < n; i++) { + + // Pattern matches path + const parsedPattern = (parsedPatterns[i]); + if (parsedPattern.requiresSiblings && hasSibling) { + if (!base) { + base = basename(path); + } + + if (!name) { + name = base.substr(0, base.length - extname(path).length); + } + } + + const result = parsedPattern(path, base, name, hasSibling); + if (typeof result === 'string') { + return result; // immediately return as soon as the first expression matches + } + + // If the result is a promise, we have to keep it for + // later processing and await the result properly. + if (isThenable(result)) { + if (!resultPromises) { + resultPromises = []; + } + + resultPromises.push(result); + } + } + + // With result promises, we have to loop over each and + // await the result before we can return any result. + if (resultPromises) { + return (async () => { + for (const resultPromise of resultPromises) { + const result = await resultPromise; + if (typeof result === 'string') { + return result; + } + } + + return null; + })(); + } + + return null; + }; + + const withBasenames = parsedPatterns.find(pattern => !!pattern.allBasenames); + if (withBasenames) { + resultExpression.allBasenames = withBasenames.allBasenames; + } + + const allPaths = parsedPatterns.reduce((all, current) => current.allPaths ? all.concat(current.allPaths) : all, [] as string[]); + if (allPaths.length) { + resultExpression.allPaths = allPaths; + } + + return resultExpression; +} + +function parseExpressionPattern(pattern: string, value: boolean | SiblingClause, options: IGlobOptions): (ParsedStringPattern | ParsedExpressionPattern) { + if (value === false) { + return NULL; // pattern is disabled + } + + const parsedPattern = parsePattern(pattern, options); + if (parsedPattern === NULL) { + return NULL; + } + + // Expression Pattern is + if (typeof value === 'boolean') { + return parsedPattern; + } + + // Expression Pattern is + if (value) { + const when = value.when; + if (typeof when === 'string') { + const result: ParsedExpressionPattern = (path: string, basename?: string, name?: string, hasSibling?: (name: string) => boolean | Promise) => { + if (!hasSibling || !parsedPattern(path, basename)) { + return null; + } + + const clausePattern = when.replace('$(basename)', () => name!); + const matched = hasSibling(clausePattern); + return isThenable(matched) ? + matched.then(match => match ? pattern : null) : + matched ? pattern : null; + }; + + result.requiresSiblings = true; + + return result; + } + } + + // Expression is anything + return parsedPattern; +} + +function aggregateBasenameMatches(parsedPatterns: Array, result?: string): Array { + const basenamePatterns = parsedPatterns.filter(parsedPattern => !!(parsedPattern).basenames); + if (basenamePatterns.length < 2) { + return parsedPatterns; + } + + const basenames = basenamePatterns.reduce((all, current) => { + const basenames = (current).basenames; + + return basenames ? all.concat(basenames) : all; + }, [] as string[]); + + let patterns: string[]; + if (result) { + patterns = []; + + for (let i = 0, n = basenames.length; i < n; i++) { + patterns.push(result); + } + } else { + patterns = basenamePatterns.reduce((all, current) => { + const patterns = (current).patterns; + + return patterns ? all.concat(patterns) : all; + }, [] as string[]); + } + + const aggregate: ParsedStringPattern = function (path: string, basename?: string) { + if (typeof path !== 'string') { + return null; + } + + if (!basename) { + let i: number; + for (i = path.length; i > 0; i--) { + const ch = path.charCodeAt(i - 1); + if (ch === CharCode.Slash || ch === CharCode.Backslash) { + break; + } + } + + basename = path.substr(i); + } + + const index = basenames.indexOf(basename); + return index !== -1 ? patterns[index] : null; + }; + + aggregate.basenames = basenames; + aggregate.patterns = patterns; + aggregate.allBasenames = basenames; + + const aggregatedPatterns = parsedPatterns.filter(parsedPattern => !(parsedPattern).basenames); + aggregatedPatterns.push(aggregate); + + return aggregatedPatterns; +} + +export function patternsEquals(patternsA: Array | undefined, patternsB: Array | undefined): boolean { + return equals(patternsA, patternsB, (a, b) => { + if (typeof a === 'string' && typeof b === 'string') { + return a === b; + } + + if (typeof a !== 'string' && typeof b !== 'string') { + return a.base === b.base && a.pattern === b.pattern; + } + + return false; + }); +} diff --git a/src/vs/base/common/hash.ts b/src/vs/base/common/hash.ts new file mode 100644 index 0000000000..76217c4730 --- /dev/null +++ b/src/vs/base/common/hash.ts @@ -0,0 +1,316 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as strings from 'vs/base/common/strings'; + +/** + * Return a hash value for an object. + */ +export function hash(obj: any): number { + return doHash(obj, 0); +} + +export function doHash(obj: any, hashVal: number): number { + switch (typeof obj) { + case 'object': + if (obj === null) { + return numberHash(349, hashVal); + } else if (Array.isArray(obj)) { + return arrayHash(obj, hashVal); + } + return objectHash(obj, hashVal); + case 'string': + return stringHash(obj, hashVal); + case 'boolean': + return booleanHash(obj, hashVal); + case 'number': + return numberHash(obj, hashVal); + case 'undefined': + return numberHash(937, hashVal); + default: + return numberHash(617, hashVal); + } +} + +export function numberHash(val: number, initialHashVal: number): number { + return (((initialHashVal << 5) - initialHashVal) + val) | 0; // hashVal * 31 + ch, keep as int32 +} + +function booleanHash(b: boolean, initialHashVal: number): number { + return numberHash(b ? 433 : 863, initialHashVal); +} + +export function stringHash(s: string, hashVal: number) { + hashVal = numberHash(149417, hashVal); + for (let i = 0, length = s.length; i < length; i++) { + hashVal = numberHash(s.charCodeAt(i), hashVal); + } + return hashVal; +} + +function arrayHash(arr: any[], initialHashVal: number): number { + initialHashVal = numberHash(104579, initialHashVal); + return arr.reduce((hashVal, item) => doHash(item, hashVal), initialHashVal); +} + +function objectHash(obj: any, initialHashVal: number): number { + initialHashVal = numberHash(181387, initialHashVal); + return Object.keys(obj).sort().reduce((hashVal, key) => { + hashVal = stringHash(key, hashVal); + return doHash(obj[key], hashVal); + }, initialHashVal); +} + +export class Hasher { + + private _value = 0; + + get value(): number { + return this._value; + } + + hash(obj: any): number { + this._value = doHash(obj, this._value); + return this._value; + } +} + +const enum SHA1Constant { + BLOCK_SIZE = 64, // 512 / 8 + UNICODE_REPLACEMENT = 0xFFFD, +} + +function leftRotate(value: number, bits: number, totalBits: number = 32): number { + // delta + bits = totalBits + const delta = totalBits - bits; + + // All ones, expect `delta` zeros aligned to the right + const mask = ~((1 << delta) - 1); + + // Join (value left-shifted `bits` bits) with (masked value right-shifted `delta` bits) + return ((value << bits) | ((mask & value) >>> delta)) >>> 0; +} + +function fill(dest: Uint8Array, index: number = 0, count: number = dest.byteLength, value: number = 0): void { + for (let i = 0; i < count; i++) { + dest[index + i] = value; + } +} + +function leftPad(value: string, length: number, char: string = '0'): string { + while (value.length < length) { + value = char + value; + } + return value; +} + +export function toHexString(buffer: ArrayBuffer): string; +export function toHexString(value: number, bitsize?: number): string; +export function toHexString(bufferOrValue: ArrayBuffer | number, bitsize: number = 32): string { + if (bufferOrValue instanceof ArrayBuffer) { + return Array.from(new Uint8Array(bufferOrValue)).map(b => b.toString(16).padStart(2, '0')).join(''); + } + + return leftPad((bufferOrValue >>> 0).toString(16), bitsize / 4); +} + +/** + * A SHA1 implementation that works with strings and does not allocate. + */ +export class StringSHA1 { + private static _bigBlock32 = new DataView(new ArrayBuffer(320)); // 80 * 4 = 320 + + private _h0 = 0x67452301; + private _h1 = 0xEFCDAB89; + private _h2 = 0x98BADCFE; + private _h3 = 0x10325476; + private _h4 = 0xC3D2E1F0; + + private readonly _buff: Uint8Array; + private readonly _buffDV: DataView; + private _buffLen: number; + private _totalLen: number; + private _leftoverHighSurrogate: number; + private _finished: boolean; + + constructor() { + this._buff = new Uint8Array(SHA1Constant.BLOCK_SIZE + 3 /* to fit any utf-8 */); + this._buffDV = new DataView(this._buff.buffer); + this._buffLen = 0; + this._totalLen = 0; + this._leftoverHighSurrogate = 0; + this._finished = false; + } + + public update(str: string): void { + const strLen = str.length; + if (strLen === 0) { + return; + } + + const buff = this._buff; + let buffLen = this._buffLen; + let leftoverHighSurrogate = this._leftoverHighSurrogate; + let charCode: number; + let offset: number; + + if (leftoverHighSurrogate !== 0) { + charCode = leftoverHighSurrogate; + offset = -1; + leftoverHighSurrogate = 0; + } else { + charCode = str.charCodeAt(0); + offset = 0; + } + + while (true) { + let codePoint = charCode; + if (strings.isHighSurrogate(charCode)) { + if (offset + 1 < strLen) { + const nextCharCode = str.charCodeAt(offset + 1); + if (strings.isLowSurrogate(nextCharCode)) { + offset++; + codePoint = strings.computeCodePoint(charCode, nextCharCode); + } else { + // illegal => unicode replacement character + codePoint = SHA1Constant.UNICODE_REPLACEMENT; + } + } else { + // last character is a surrogate pair + leftoverHighSurrogate = charCode; + break; + } + } else if (strings.isLowSurrogate(charCode)) { + // illegal => unicode replacement character + codePoint = SHA1Constant.UNICODE_REPLACEMENT; + } + + buffLen = this._push(buff, buffLen, codePoint); + offset++; + if (offset < strLen) { + charCode = str.charCodeAt(offset); + } else { + break; + } + } + + this._buffLen = buffLen; + this._leftoverHighSurrogate = leftoverHighSurrogate; + } + + private _push(buff: Uint8Array, buffLen: number, codePoint: number): number { + if (codePoint < 0x0080) { + buff[buffLen++] = codePoint; + } else if (codePoint < 0x0800) { + buff[buffLen++] = 0b11000000 | ((codePoint & 0b00000000000000000000011111000000) >>> 6); + buff[buffLen++] = 0b10000000 | ((codePoint & 0b00000000000000000000000000111111) >>> 0); + } else if (codePoint < 0x10000) { + buff[buffLen++] = 0b11100000 | ((codePoint & 0b00000000000000001111000000000000) >>> 12); + buff[buffLen++] = 0b10000000 | ((codePoint & 0b00000000000000000000111111000000) >>> 6); + buff[buffLen++] = 0b10000000 | ((codePoint & 0b00000000000000000000000000111111) >>> 0); + } else { + buff[buffLen++] = 0b11110000 | ((codePoint & 0b00000000000111000000000000000000) >>> 18); + buff[buffLen++] = 0b10000000 | ((codePoint & 0b00000000000000111111000000000000) >>> 12); + buff[buffLen++] = 0b10000000 | ((codePoint & 0b00000000000000000000111111000000) >>> 6); + buff[buffLen++] = 0b10000000 | ((codePoint & 0b00000000000000000000000000111111) >>> 0); + } + + if (buffLen >= SHA1Constant.BLOCK_SIZE) { + this._step(); + buffLen -= SHA1Constant.BLOCK_SIZE; + this._totalLen += SHA1Constant.BLOCK_SIZE; + // take last 3 in case of UTF8 overflow + buff[0] = buff[SHA1Constant.BLOCK_SIZE + 0]; + buff[1] = buff[SHA1Constant.BLOCK_SIZE + 1]; + buff[2] = buff[SHA1Constant.BLOCK_SIZE + 2]; + } + + return buffLen; + } + + public digest(): string { + if (!this._finished) { + this._finished = true; + if (this._leftoverHighSurrogate) { + // illegal => unicode replacement character + this._leftoverHighSurrogate = 0; + this._buffLen = this._push(this._buff, this._buffLen, SHA1Constant.UNICODE_REPLACEMENT); + } + this._totalLen += this._buffLen; + this._wrapUp(); + } + + return toHexString(this._h0) + toHexString(this._h1) + toHexString(this._h2) + toHexString(this._h3) + toHexString(this._h4); + } + + private _wrapUp(): void { + this._buff[this._buffLen++] = 0x80; + fill(this._buff, this._buffLen); + + if (this._buffLen > 56) { + this._step(); + fill(this._buff); + } + + // this will fit because the mantissa can cover up to 52 bits + const ml = 8 * this._totalLen; + + this._buffDV.setUint32(56, Math.floor(ml / 4294967296), false); + this._buffDV.setUint32(60, ml % 4294967296, false); + + this._step(); + } + + private _step(): void { + const bigBlock32 = StringSHA1._bigBlock32; + const data = this._buffDV; + + for (let j = 0; j < 64 /* 16*4 */; j += 4) { + bigBlock32.setUint32(j, data.getUint32(j, false), false); + } + + for (let j = 64; j < 320 /* 80*4 */; j += 4) { + bigBlock32.setUint32(j, leftRotate((bigBlock32.getUint32(j - 12, false) ^ bigBlock32.getUint32(j - 32, false) ^ bigBlock32.getUint32(j - 56, false) ^ bigBlock32.getUint32(j - 64, false)), 1), false); + } + + let a = this._h0; + let b = this._h1; + let c = this._h2; + let d = this._h3; + let e = this._h4; + + let f: number, k: number; + let temp: number; + + for (let j = 0; j < 80; j++) { + if (j < 20) { + f = (b & c) | ((~b) & d); + k = 0x5A827999; + } else if (j < 40) { + f = b ^ c ^ d; + k = 0x6ED9EBA1; + } else if (j < 60) { + f = (b & c) | (b & d) | (c & d); + k = 0x8F1BBCDC; + } else { + f = b ^ c ^ d; + k = 0xCA62C1D6; + } + + temp = (leftRotate(a, 5) + f + e + k + bigBlock32.getUint32(j * 4, false)) & 0xffffffff; + e = d; + d = c; + c = leftRotate(b, 30); + b = a; + a = temp; + } + + this._h0 = (this._h0 + a) & 0xffffffff; + this._h1 = (this._h1 + b) & 0xffffffff; + this._h2 = (this._h2 + c) & 0xffffffff; + this._h3 = (this._h3 + d) & 0xffffffff; + this._h4 = (this._h4 + e) & 0xffffffff; + } +} diff --git a/src/vs/base/common/hierarchicalKind.ts b/src/vs/base/common/hierarchicalKind.ts new file mode 100644 index 0000000000..a2edd61437 --- /dev/null +++ b/src/vs/base/common/hierarchicalKind.ts @@ -0,0 +1,31 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export class HierarchicalKind { + public static readonly sep = '.'; + + public static readonly None = new HierarchicalKind('@@none@@'); // Special kind that matches nothing + public static readonly Empty = new HierarchicalKind(''); + + constructor( + public readonly value: string + ) { } + + public equals(other: HierarchicalKind): boolean { + return this.value === other.value; + } + + public contains(other: HierarchicalKind): boolean { + return this.equals(other) || this.value === '' || other.value.startsWith(this.value + HierarchicalKind.sep); + } + + public intersects(other: HierarchicalKind): boolean { + return this.contains(other) || other.contains(this); + } + + public append(...parts: string[]): HierarchicalKind { + return new HierarchicalKind((this.value ? [this.value, ...parts] : parts).join(HierarchicalKind.sep)); + } +} diff --git a/src/vs/base/common/history.ts b/src/vs/base/common/history.ts new file mode 100644 index 0000000000..9d644a851c --- /dev/null +++ b/src/vs/base/common/history.ts @@ -0,0 +1,277 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { SetWithKey } from 'vs/base/common/collections'; +import { ArrayNavigator, INavigator } from 'vs/base/common/navigator'; + +export class HistoryNavigator implements INavigator { + + private _history!: Set; + private _limit: number; + private _navigator!: ArrayNavigator; + + constructor(history: readonly T[] = [], limit: number = 10) { + this._initialize(history); + this._limit = limit; + this._onChange(); + } + + public getHistory(): T[] { + return this._elements; + } + + public add(t: T) { + this._history.delete(t); + this._history.add(t); + this._onChange(); + } + + public next(): T | null { + // This will navigate past the end of the last element, and in that case the input should be cleared + return this._navigator.next(); + } + + public previous(): T | null { + if (this._currentPosition() !== 0) { + return this._navigator.previous(); + } + return null; + } + + public current(): T | null { + return this._navigator.current(); + } + + public first(): T | null { + return this._navigator.first(); + } + + public last(): T | null { + return this._navigator.last(); + } + + public isFirst(): boolean { + return this._currentPosition() === 0; + } + + public isLast(): boolean { + return this._currentPosition() >= this._elements.length - 1; + } + + public isNowhere(): boolean { + return this._navigator.current() === null; + } + + public has(t: T): boolean { + return this._history.has(t); + } + + public clear(): void { + this._initialize([]); + this._onChange(); + } + + private _onChange() { + this._reduceToLimit(); + const elements = this._elements; + this._navigator = new ArrayNavigator(elements, 0, elements.length, elements.length); + } + + private _reduceToLimit() { + const data = this._elements; + if (data.length > this._limit) { + this._initialize(data.slice(data.length - this._limit)); + } + } + + private _currentPosition(): number { + const currentElement = this._navigator.current(); + if (!currentElement) { + return -1; + } + + return this._elements.indexOf(currentElement); + } + + private _initialize(history: readonly T[]): void { + this._history = new Set(); + for (const entry of history) { + this._history.add(entry); + } + } + + private get _elements(): T[] { + const elements: T[] = []; + this._history.forEach(e => elements.push(e)); + return elements; + } +} + +interface HistoryNode { + value: T; + previous: HistoryNode | undefined; + next: HistoryNode | undefined; +} + +/** + * The right way to use HistoryNavigator2 is for the last item in the list to be the user's uncommitted current text. eg empty string, or whatever has been typed. Then + * the user can navigate away from the last item through the list, and back to it. When updating the last item, call replaceLast. + */ +export class HistoryNavigator2 { + + private valueSet: Set; + private head: HistoryNode; + private tail: HistoryNode; + private cursor: HistoryNode; + private _size: number; + get size(): number { return this._size; } + + constructor(history: readonly T[], private capacity: number = 10, private identityFn: (t: T) => any = t => t) { + if (history.length < 1) { + throw new Error('not supported'); + } + + this._size = 1; + this.head = this.tail = this.cursor = { + value: history[0], + previous: undefined, + next: undefined + }; + + this.valueSet = new SetWithKey([history[0]], identityFn); + for (let i = 1; i < history.length; i++) { + this.add(history[i]); + } + } + + add(value: T): void { + const node: HistoryNode = { + value, + previous: this.tail, + next: undefined + }; + + this.tail.next = node; + this.tail = node; + this.cursor = this.tail; + this._size++; + + if (this.valueSet.has(value)) { + this._deleteFromList(value); + } else { + this.valueSet.add(value); + } + + while (this._size > this.capacity) { + this.valueSet.delete(this.head.value); + + this.head = this.head.next!; + this.head.previous = undefined; + this._size--; + } + } + + /** + * @returns old last value + */ + replaceLast(value: T): T { + if (this.identityFn(this.tail.value) === this.identityFn(value)) { + return value; + } + + const oldValue = this.tail.value; + this.valueSet.delete(oldValue); + this.tail.value = value; + + if (this.valueSet.has(value)) { + this._deleteFromList(value); + } else { + this.valueSet.add(value); + } + + return oldValue; + } + + prepend(value: T): void { + if (this._size === this.capacity || this.valueSet.has(value)) { + return; + } + + const node: HistoryNode = { + value, + previous: undefined, + next: this.head + }; + + this.head.previous = node; + this.head = node; + this._size++; + + this.valueSet.add(value); + } + + isAtEnd(): boolean { + return this.cursor === this.tail; + } + + current(): T { + return this.cursor.value; + } + + previous(): T { + if (this.cursor.previous) { + this.cursor = this.cursor.previous; + } + + return this.cursor.value; + } + + next(): T { + if (this.cursor.next) { + this.cursor = this.cursor.next; + } + + return this.cursor.value; + } + + has(t: T): boolean { + return this.valueSet.has(t); + } + + resetCursor(): T { + this.cursor = this.tail; + return this.cursor.value; + } + + *[Symbol.iterator](): Iterator { + let node: HistoryNode | undefined = this.head; + + while (node) { + yield node.value; + node = node.next; + } + } + + private _deleteFromList(value: T): void { + let temp = this.head; + + const valueKey = this.identityFn(value); + while (temp !== this.tail) { + if (this.identityFn(temp.value) === valueKey) { + if (temp === this.head) { + this.head = this.head.next!; + this.head.previous = undefined; + } else { + temp.previous!.next = temp.next; + temp.next!.previous = temp.previous; + } + + this._size--; + } + + temp = temp.next!; + } + } +} diff --git a/src/vs/base/common/hotReload.ts b/src/vs/base/common/hotReload.ts new file mode 100644 index 0000000000..609fd9d8ef --- /dev/null +++ b/src/vs/base/common/hotReload.ts @@ -0,0 +1,112 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IDisposable } from 'vs/base/common/lifecycle'; +import { env } from 'vs/base/common/process'; + +export function isHotReloadEnabled(): boolean { + return env && !!env['VSCODE_DEV']; +} +export function registerHotReloadHandler(handler: HotReloadHandler): IDisposable { + if (!isHotReloadEnabled()) { + return { dispose() { } }; + } else { + const handlers = registerGlobalHotReloadHandler(); + handlers.add(handler); + return { + dispose() { handlers.delete(handler); } + }; + } +} + +/** + * Takes the old exports of the module to reload and returns a function to apply the new exports. + * If `undefined` is returned, this handler is not able to handle the module. + * + * If no handler can apply the new exports, the module will not be reloaded. + */ +export type HotReloadHandler = (args: { oldExports: Record; newSrc: string; config: IHotReloadConfig }) => AcceptNewExportsHandler | undefined; +export type AcceptNewExportsHandler = (newExports: Record) => boolean; +export type IHotReloadConfig = HotReloadConfig; + +function registerGlobalHotReloadHandler() { + if (!hotReloadHandlers) { + hotReloadHandlers = new Set(); + } + + const g = globalThis as unknown as GlobalThisAddition; + if (!g.$hotReload_applyNewExports) { + g.$hotReload_applyNewExports = args => { + const args2 = { config: { mode: undefined }, ...args }; + + const results: AcceptNewExportsHandler[] = []; + for (const h of hotReloadHandlers!) { + const result = h(args2); + if (result) { + results.push(result); + } + } + if (results.length > 0) { + return newExports => { + let result = false; + for (const r of results) { + if (r(newExports)) { + result = true; + } + } + return result; + }; + } + return undefined; + }; + } + + return hotReloadHandlers; +} + +let hotReloadHandlers: Set<(args: { oldExports: Record; newSrc: string; config: HotReloadConfig }) => AcceptNewExportsFn | undefined> | undefined = undefined; + +interface HotReloadConfig { + mode?: 'patch-prototype' | undefined; +} + +interface GlobalThisAddition { + $hotReload_applyNewExports?(args: { oldExports: Record; newSrc: string; config?: HotReloadConfig }): AcceptNewExportsFn | undefined; +} + +type AcceptNewExportsFn = (newExports: Record) => boolean; + +if (isHotReloadEnabled()) { + // This code does not run in production. + registerHotReloadHandler(({ oldExports, newSrc, config }) => { + if (config.mode !== 'patch-prototype') { + return undefined; + } + + return newExports => { + for (const key in newExports) { + const exportedItem = newExports[key]; + console.log(`[hot-reload] Patching prototype methods of '${key}'`, { exportedItem }); + if (typeof exportedItem === 'function' && exportedItem.prototype) { + const oldExportedItem = oldExports[key]; + if (oldExportedItem) { + for (const prop of Object.getOwnPropertyNames(exportedItem.prototype)) { + const descriptor = Object.getOwnPropertyDescriptor(exportedItem.prototype, prop)!; + const oldDescriptor = Object.getOwnPropertyDescriptor((oldExportedItem as any).prototype, prop); + + if (descriptor?.value?.toString() !== oldDescriptor?.value?.toString()) { + console.log(`[hot-reload] Patching prototype method '${key}.${prop}'`); + } + + Object.defineProperty((oldExportedItem as any).prototype, prop, descriptor); + } + newExports[key] = oldExportedItem; + } + } + } + return true; + }; + }); +} diff --git a/src/vs/base/common/hotReloadHelpers.ts b/src/vs/base/common/hotReloadHelpers.ts new file mode 100644 index 0000000000..174b1adcbc --- /dev/null +++ b/src/vs/base/common/hotReloadHelpers.ts @@ -0,0 +1,30 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { isHotReloadEnabled, registerHotReloadHandler } from 'vs/base/common/hotReload'; +import { IReader, observableSignalFromEvent } from 'vs/base/common/observable'; + +export function readHotReloadableExport(value: T, reader: IReader | undefined): T { + observeHotReloadableExports([value], reader); + return value; +} + +export function observeHotReloadableExports(values: any[], reader: IReader | undefined): void { + if (isHotReloadEnabled()) { + const o = observableSignalFromEvent( + 'reload', + event => registerHotReloadHandler(({ oldExports }) => { + if (![...Object.values(oldExports)].some(v => values.includes(v))) { + return undefined; + } + return (_newExports) => { + event(undefined); + return true; + }; + }) + ); + o.read(reader); + } +} diff --git a/src/vs/base/common/idGenerator.ts b/src/vs/base/common/idGenerator.ts new file mode 100644 index 0000000000..0a66cfec7b --- /dev/null +++ b/src/vs/base/common/idGenerator.ts @@ -0,0 +1,21 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export class IdGenerator { + + private _prefix: string; + private _lastId: number; + + constructor(prefix: string) { + this._prefix = prefix; + this._lastId = 0; + } + + public nextId(): string { + return this._prefix + (++this._lastId); + } +} + +export const defaultGenerator = new IdGenerator('id#'); diff --git a/src/vs/base/common/ime.ts b/src/vs/base/common/ime.ts new file mode 100644 index 0000000000..ce80099b28 --- /dev/null +++ b/src/vs/base/common/ime.ts @@ -0,0 +1,36 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Emitter } from 'vs/base/common/event'; + +export class IMEImpl { + + private readonly _onDidChange = new Emitter(); + public readonly onDidChange = this._onDidChange.event; + + private _enabled = true; + + public get enabled() { + return this._enabled; + } + + /** + * Enable IME + */ + public enable(): void { + this._enabled = true; + this._onDidChange.fire(); + } + + /** + * Disable IME + */ + public disable(): void { + this._enabled = false; + this._onDidChange.fire(); + } +} + +export const IME = new IMEImpl(); diff --git a/src/vs/base/common/iterator.ts b/src/vs/base/common/iterator.ts new file mode 100644 index 0000000000..c329ed6dc7 --- /dev/null +++ b/src/vs/base/common/iterator.ts @@ -0,0 +1,159 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export namespace Iterable { + + export function is(thing: any): thing is Iterable { + return thing && typeof thing === 'object' && typeof thing[Symbol.iterator] === 'function'; + } + + const _empty: Iterable = Object.freeze([]); + export function empty(): Iterable { + return _empty; + } + + export function* single(element: T): Iterable { + yield element; + } + + export function wrap(iterableOrElement: Iterable | T): Iterable { + if (is(iterableOrElement)) { + return iterableOrElement; + } else { + return single(iterableOrElement); + } + } + + export function from(iterable: Iterable | undefined | null): Iterable { + return iterable || _empty; + } + + export function* reverse(array: Array): Iterable { + for (let i = array.length - 1; i >= 0; i--) { + yield array[i]; + } + } + + export function isEmpty(iterable: Iterable | undefined | null): boolean { + return !iterable || iterable[Symbol.iterator]().next().done === true; + } + + export function first(iterable: Iterable): T | undefined { + return iterable[Symbol.iterator]().next().value; + } + + export function some(iterable: Iterable, predicate: (t: T, i: number) => unknown): boolean { + let i = 0; + for (const element of iterable) { + if (predicate(element, i++)) { + return true; + } + } + return false; + } + + export function find(iterable: Iterable, predicate: (t: T) => t is R): R | undefined; + export function find(iterable: Iterable, predicate: (t: T) => boolean): T | undefined; + export function find(iterable: Iterable, predicate: (t: T) => boolean): T | undefined { + for (const element of iterable) { + if (predicate(element)) { + return element; + } + } + + return undefined; + } + + export function filter(iterable: Iterable, predicate: (t: T) => t is R): Iterable; + export function filter(iterable: Iterable, predicate: (t: T) => boolean): Iterable; + export function* filter(iterable: Iterable, predicate: (t: T) => boolean): Iterable { + for (const element of iterable) { + if (predicate(element)) { + yield element; + } + } + } + + export function* map(iterable: Iterable, fn: (t: T, index: number) => R): Iterable { + let index = 0; + for (const element of iterable) { + yield fn(element, index++); + } + } + + export function* flatMap(iterable: Iterable, fn: (t: T, index: number) => Iterable): Iterable { + let index = 0; + for (const element of iterable) { + yield* fn(element, index++); + } + } + + export function* concat(...iterables: Iterable[]): Iterable { + for (const iterable of iterables) { + yield* iterable; + } + } + + export function reduce(iterable: Iterable, reducer: (previousValue: R, currentValue: T) => R, initialValue: R): R { + let value = initialValue; + for (const element of iterable) { + value = reducer(value, element); + } + return value; + } + + /** + * Returns an iterable slice of the array, with the same semantics as `array.slice()`. + */ + export function* slice(arr: ReadonlyArray, from: number, to = arr.length): Iterable { + if (from < 0) { + from += arr.length; + } + + if (to < 0) { + to += arr.length; + } else if (to > arr.length) { + to = arr.length; + } + + for (; from < to; from++) { + yield arr[from]; + } + } + + /** + * Consumes `atMost` elements from iterable and returns the consumed elements, + * and an iterable for the rest of the elements. + */ + export function consume(iterable: Iterable, atMost: number = Number.POSITIVE_INFINITY): [T[], Iterable] { + const consumed: T[] = []; + + if (atMost === 0) { + return [consumed, iterable]; + } + + const iterator = iterable[Symbol.iterator](); + + for (let i = 0; i < atMost; i++) { + const next = iterator.next(); + + if (next.done) { + return [consumed, Iterable.empty()]; + } + + consumed.push(next.value); + } + + return [consumed, { [Symbol.iterator]() { return iterator; } }]; + } + + export async function asyncToArray(iterable: AsyncIterable): Promise { + const result: T[] = []; + for await (const item of iterable) { + result.push(item); + } + return Promise.resolve(result); + } +} diff --git a/src/vs/base/common/json.ts b/src/vs/base/common/json.ts new file mode 100644 index 0000000000..e4adc59003 --- /dev/null +++ b/src/vs/base/common/json.ts @@ -0,0 +1,1326 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export const enum ScanError { + None = 0, + UnexpectedEndOfComment = 1, + UnexpectedEndOfString = 2, + UnexpectedEndOfNumber = 3, + InvalidUnicode = 4, + InvalidEscapeCharacter = 5, + InvalidCharacter = 6 +} + +export const enum SyntaxKind { + OpenBraceToken = 1, + CloseBraceToken = 2, + OpenBracketToken = 3, + CloseBracketToken = 4, + CommaToken = 5, + ColonToken = 6, + NullKeyword = 7, + TrueKeyword = 8, + FalseKeyword = 9, + StringLiteral = 10, + NumericLiteral = 11, + LineCommentTrivia = 12, + BlockCommentTrivia = 13, + LineBreakTrivia = 14, + Trivia = 15, + Unknown = 16, + EOF = 17 +} + +/** + * The scanner object, representing a JSON scanner at a position in the input string. + */ +export interface JSONScanner { + /** + * Sets the scan position to a new offset. A call to 'scan' is needed to get the first token. + */ + setPosition(pos: number): void; + /** + * Read the next token. Returns the token code. + */ + scan(): SyntaxKind; + /** + * Returns the current scan position, which is after the last read token. + */ + getPosition(): number; + /** + * Returns the last read token. + */ + getToken(): SyntaxKind; + /** + * Returns the last read token value. The value for strings is the decoded string content. For numbers its of type number, for boolean it's true or false. + */ + getTokenValue(): string; + /** + * The start offset of the last read token. + */ + getTokenOffset(): number; + /** + * The length of the last read token. + */ + getTokenLength(): number; + /** + * An error code of the last scan. + */ + getTokenError(): ScanError; +} + + + +export interface ParseError { + error: ParseErrorCode; + offset: number; + length: number; +} + +export const enum ParseErrorCode { + InvalidSymbol = 1, + InvalidNumberFormat = 2, + PropertyNameExpected = 3, + ValueExpected = 4, + ColonExpected = 5, + CommaExpected = 6, + CloseBraceExpected = 7, + CloseBracketExpected = 8, + EndOfFileExpected = 9, + InvalidCommentToken = 10, + UnexpectedEndOfComment = 11, + UnexpectedEndOfString = 12, + UnexpectedEndOfNumber = 13, + InvalidUnicode = 14, + InvalidEscapeCharacter = 15, + InvalidCharacter = 16 +} + +export type NodeType = 'object' | 'array' | 'property' | 'string' | 'number' | 'boolean' | 'null'; + +export interface Node { + readonly type: NodeType; + readonly value?: any; + readonly offset: number; + readonly length: number; + readonly colonOffset?: number; + readonly parent?: Node; + readonly children?: Node[]; +} + +export type Segment = string | number; +export type JSONPath = Segment[]; + +export interface Location { + /** + * The previous property key or literal value (string, number, boolean or null) or undefined. + */ + previousNode?: Node; + /** + * The path describing the location in the JSON document. The path consists of a sequence strings + * representing an object property or numbers for array indices. + */ + path: JSONPath; + /** + * Matches the locations path against a pattern consisting of strings (for properties) and numbers (for array indices). + * '*' will match a single segment, of any property name or index. + * '**' will match a sequence of segments or no segment, of any property name or index. + */ + matches: (patterns: JSONPath) => boolean; + /** + * If set, the location's offset is at a property key. + */ + isAtPropertyKey: boolean; +} + +export interface ParseOptions { + disallowComments?: boolean; + allowTrailingComma?: boolean; + allowEmptyContent?: boolean; +} + +export namespace ParseOptions { + export const DEFAULT = { + allowTrailingComma: true + }; +} + +export interface JSONVisitor { + /** + * Invoked when an open brace is encountered and an object is started. The offset and length represent the location of the open brace. + */ + onObjectBegin?: (offset: number, length: number) => void; + + /** + * Invoked when a property is encountered. The offset and length represent the location of the property name. + */ + onObjectProperty?: (property: string, offset: number, length: number) => void; + + /** + * Invoked when a closing brace is encountered and an object is completed. The offset and length represent the location of the closing brace. + */ + onObjectEnd?: (offset: number, length: number) => void; + + /** + * Invoked when an open bracket is encountered. The offset and length represent the location of the open bracket. + */ + onArrayBegin?: (offset: number, length: number) => void; + + /** + * Invoked when a closing bracket is encountered. The offset and length represent the location of the closing bracket. + */ + onArrayEnd?: (offset: number, length: number) => void; + + /** + * Invoked when a literal value is encountered. The offset and length represent the location of the literal value. + */ + onLiteralValue?: (value: any, offset: number, length: number) => void; + + /** + * Invoked when a comma or colon separator is encountered. The offset and length represent the location of the separator. + */ + onSeparator?: (character: string, offset: number, length: number) => void; + + /** + * When comments are allowed, invoked when a line or block comment is encountered. The offset and length represent the location of the comment. + */ + onComment?: (offset: number, length: number) => void; + + /** + * Invoked on an error. + */ + onError?: (error: ParseErrorCode, offset: number, length: number) => void; +} + +/** + * Creates a JSON scanner on the given text. + * If ignoreTrivia is set, whitespaces or comments are ignored. + */ +export function createScanner(text: string, ignoreTrivia: boolean = false): JSONScanner { + + let pos = 0; + const len = text.length; + let value: string = ''; + let tokenOffset = 0; + let token: SyntaxKind = SyntaxKind.Unknown; + let scanError: ScanError = ScanError.None; + + function scanHexDigits(count: number): number { + let digits = 0; + let hexValue = 0; + while (digits < count) { + const ch = text.charCodeAt(pos); + if (ch >= CharacterCodes._0 && ch <= CharacterCodes._9) { + hexValue = hexValue * 16 + ch - CharacterCodes._0; + } + else if (ch >= CharacterCodes.A && ch <= CharacterCodes.F) { + hexValue = hexValue * 16 + ch - CharacterCodes.A + 10; + } + else if (ch >= CharacterCodes.a && ch <= CharacterCodes.f) { + hexValue = hexValue * 16 + ch - CharacterCodes.a + 10; + } + else { + break; + } + pos++; + digits++; + } + if (digits < count) { + hexValue = -1; + } + return hexValue; + } + + function setPosition(newPosition: number) { + pos = newPosition; + value = ''; + tokenOffset = 0; + token = SyntaxKind.Unknown; + scanError = ScanError.None; + } + + function scanNumber(): string { + const start = pos; + if (text.charCodeAt(pos) === CharacterCodes._0) { + pos++; + } else { + pos++; + while (pos < text.length && isDigit(text.charCodeAt(pos))) { + pos++; + } + } + if (pos < text.length && text.charCodeAt(pos) === CharacterCodes.dot) { + pos++; + if (pos < text.length && isDigit(text.charCodeAt(pos))) { + pos++; + while (pos < text.length && isDigit(text.charCodeAt(pos))) { + pos++; + } + } else { + scanError = ScanError.UnexpectedEndOfNumber; + return text.substring(start, pos); + } + } + let end = pos; + if (pos < text.length && (text.charCodeAt(pos) === CharacterCodes.E || text.charCodeAt(pos) === CharacterCodes.e)) { + pos++; + if (pos < text.length && text.charCodeAt(pos) === CharacterCodes.plus || text.charCodeAt(pos) === CharacterCodes.minus) { + pos++; + } + if (pos < text.length && isDigit(text.charCodeAt(pos))) { + pos++; + while (pos < text.length && isDigit(text.charCodeAt(pos))) { + pos++; + } + end = pos; + } else { + scanError = ScanError.UnexpectedEndOfNumber; + } + } + return text.substring(start, end); + } + + function scanString(): string { + + let result = '', + start = pos; + + while (true) { + if (pos >= len) { + result += text.substring(start, pos); + scanError = ScanError.UnexpectedEndOfString; + break; + } + const ch = text.charCodeAt(pos); + if (ch === CharacterCodes.doubleQuote) { + result += text.substring(start, pos); + pos++; + break; + } + if (ch === CharacterCodes.backslash) { + result += text.substring(start, pos); + pos++; + if (pos >= len) { + scanError = ScanError.UnexpectedEndOfString; + break; + } + const ch2 = text.charCodeAt(pos++); + switch (ch2) { + case CharacterCodes.doubleQuote: + result += '\"'; + break; + case CharacterCodes.backslash: + result += '\\'; + break; + case CharacterCodes.slash: + result += '/'; + break; + case CharacterCodes.b: + result += '\b'; + break; + case CharacterCodes.f: + result += '\f'; + break; + case CharacterCodes.n: + result += '\n'; + break; + case CharacterCodes.r: + result += '\r'; + break; + case CharacterCodes.t: + result += '\t'; + break; + case CharacterCodes.u: { + const ch3 = scanHexDigits(4); + if (ch3 >= 0) { + result += String.fromCharCode(ch3); + } else { + scanError = ScanError.InvalidUnicode; + } + break; + } + default: + scanError = ScanError.InvalidEscapeCharacter; + } + start = pos; + continue; + } + if (ch >= 0 && ch <= 0x1F) { + if (isLineBreak(ch)) { + result += text.substring(start, pos); + scanError = ScanError.UnexpectedEndOfString; + break; + } else { + scanError = ScanError.InvalidCharacter; + // mark as error but continue with string + } + } + pos++; + } + return result; + } + + function scanNext(): SyntaxKind { + + value = ''; + scanError = ScanError.None; + + tokenOffset = pos; + + if (pos >= len) { + // at the end + tokenOffset = len; + return token = SyntaxKind.EOF; + } + + let code = text.charCodeAt(pos); + // trivia: whitespace + if (isWhitespace(code)) { + do { + pos++; + value += String.fromCharCode(code); + code = text.charCodeAt(pos); + } while (isWhitespace(code)); + + return token = SyntaxKind.Trivia; + } + + // trivia: newlines + if (isLineBreak(code)) { + pos++; + value += String.fromCharCode(code); + if (code === CharacterCodes.carriageReturn && text.charCodeAt(pos) === CharacterCodes.lineFeed) { + pos++; + value += '\n'; + } + return token = SyntaxKind.LineBreakTrivia; + } + + switch (code) { + // tokens: []{}:, + case CharacterCodes.openBrace: + pos++; + return token = SyntaxKind.OpenBraceToken; + case CharacterCodes.closeBrace: + pos++; + return token = SyntaxKind.CloseBraceToken; + case CharacterCodes.openBracket: + pos++; + return token = SyntaxKind.OpenBracketToken; + case CharacterCodes.closeBracket: + pos++; + return token = SyntaxKind.CloseBracketToken; + case CharacterCodes.colon: + pos++; + return token = SyntaxKind.ColonToken; + case CharacterCodes.comma: + pos++; + return token = SyntaxKind.CommaToken; + + // strings + case CharacterCodes.doubleQuote: + pos++; + value = scanString(); + return token = SyntaxKind.StringLiteral; + + // comments + case CharacterCodes.slash: { + const start = pos - 1; + // Single-line comment + if (text.charCodeAt(pos + 1) === CharacterCodes.slash) { + pos += 2; + + while (pos < len) { + if (isLineBreak(text.charCodeAt(pos))) { + break; + } + pos++; + + } + value = text.substring(start, pos); + return token = SyntaxKind.LineCommentTrivia; + } + + // Multi-line comment + if (text.charCodeAt(pos + 1) === CharacterCodes.asterisk) { + pos += 2; + + const safeLength = len - 1; // For lookahead. + let commentClosed = false; + while (pos < safeLength) { + const ch = text.charCodeAt(pos); + + if (ch === CharacterCodes.asterisk && text.charCodeAt(pos + 1) === CharacterCodes.slash) { + pos += 2; + commentClosed = true; + break; + } + pos++; + } + + if (!commentClosed) { + pos++; + scanError = ScanError.UnexpectedEndOfComment; + } + + value = text.substring(start, pos); + return token = SyntaxKind.BlockCommentTrivia; + } + // just a single slash + value += String.fromCharCode(code); + pos++; + return token = SyntaxKind.Unknown; + } + // numbers + case CharacterCodes.minus: + value += String.fromCharCode(code); + pos++; + if (pos === len || !isDigit(text.charCodeAt(pos))) { + return token = SyntaxKind.Unknown; + } + // found a minus, followed by a number so + // we fall through to proceed with scanning + // numbers + case CharacterCodes._0: + case CharacterCodes._1: + case CharacterCodes._2: + case CharacterCodes._3: + case CharacterCodes._4: + case CharacterCodes._5: + case CharacterCodes._6: + case CharacterCodes._7: + case CharacterCodes._8: + case CharacterCodes._9: + value += scanNumber(); + return token = SyntaxKind.NumericLiteral; + // literals and unknown symbols + default: + // is a literal? Read the full word. + while (pos < len && isUnknownContentCharacter(code)) { + pos++; + code = text.charCodeAt(pos); + } + if (tokenOffset !== pos) { + value = text.substring(tokenOffset, pos); + // keywords: true, false, null + switch (value) { + case 'true': return token = SyntaxKind.TrueKeyword; + case 'false': return token = SyntaxKind.FalseKeyword; + case 'null': return token = SyntaxKind.NullKeyword; + } + return token = SyntaxKind.Unknown; + } + // some + value += String.fromCharCode(code); + pos++; + return token = SyntaxKind.Unknown; + } + } + + function isUnknownContentCharacter(code: CharacterCodes) { + if (isWhitespace(code) || isLineBreak(code)) { + return false; + } + switch (code) { + case CharacterCodes.closeBrace: + case CharacterCodes.closeBracket: + case CharacterCodes.openBrace: + case CharacterCodes.openBracket: + case CharacterCodes.doubleQuote: + case CharacterCodes.colon: + case CharacterCodes.comma: + case CharacterCodes.slash: + return false; + } + return true; + } + + + function scanNextNonTrivia(): SyntaxKind { + let result: SyntaxKind; + do { + result = scanNext(); + } while (result >= SyntaxKind.LineCommentTrivia && result <= SyntaxKind.Trivia); + return result; + } + + return { + setPosition: setPosition, + getPosition: () => pos, + scan: ignoreTrivia ? scanNextNonTrivia : scanNext, + getToken: () => token, + getTokenValue: () => value, + getTokenOffset: () => tokenOffset, + getTokenLength: () => pos - tokenOffset, + getTokenError: () => scanError + }; +} + +function isWhitespace(ch: number): boolean { + return ch === CharacterCodes.space || ch === CharacterCodes.tab || ch === CharacterCodes.verticalTab || ch === CharacterCodes.formFeed || + ch === CharacterCodes.nonBreakingSpace || ch === CharacterCodes.ogham || ch >= CharacterCodes.enQuad && ch <= CharacterCodes.zeroWidthSpace || + ch === CharacterCodes.narrowNoBreakSpace || ch === CharacterCodes.mathematicalSpace || ch === CharacterCodes.ideographicSpace || ch === CharacterCodes.byteOrderMark; +} + +function isLineBreak(ch: number): boolean { + return ch === CharacterCodes.lineFeed || ch === CharacterCodes.carriageReturn || ch === CharacterCodes.lineSeparator || ch === CharacterCodes.paragraphSeparator; +} + +function isDigit(ch: number): boolean { + return ch >= CharacterCodes._0 && ch <= CharacterCodes._9; +} + +const enum CharacterCodes { + nullCharacter = 0, + maxAsciiCharacter = 0x7F, + + lineFeed = 0x0A, // \n + carriageReturn = 0x0D, // \r + lineSeparator = 0x2028, + paragraphSeparator = 0x2029, + + // REVIEW: do we need to support this? The scanner doesn't, but our IText does. This seems + // like an odd disparity? (Or maybe it's completely fine for them to be different). + nextLine = 0x0085, + + // Unicode 3.0 space characters + space = 0x0020, // " " + nonBreakingSpace = 0x00A0, // + enQuad = 0x2000, + emQuad = 0x2001, + enSpace = 0x2002, + emSpace = 0x2003, + threePerEmSpace = 0x2004, + fourPerEmSpace = 0x2005, + sixPerEmSpace = 0x2006, + figureSpace = 0x2007, + punctuationSpace = 0x2008, + thinSpace = 0x2009, + hairSpace = 0x200A, + zeroWidthSpace = 0x200B, + narrowNoBreakSpace = 0x202F, + ideographicSpace = 0x3000, + mathematicalSpace = 0x205F, + ogham = 0x1680, + + _ = 0x5F, + $ = 0x24, + + _0 = 0x30, + _1 = 0x31, + _2 = 0x32, + _3 = 0x33, + _4 = 0x34, + _5 = 0x35, + _6 = 0x36, + _7 = 0x37, + _8 = 0x38, + _9 = 0x39, + + a = 0x61, + b = 0x62, + c = 0x63, + d = 0x64, + e = 0x65, + f = 0x66, + g = 0x67, + h = 0x68, + i = 0x69, + j = 0x6A, + k = 0x6B, + l = 0x6C, + m = 0x6D, + n = 0x6E, + o = 0x6F, + p = 0x70, + q = 0x71, + r = 0x72, + s = 0x73, + t = 0x74, + u = 0x75, + v = 0x76, + w = 0x77, + x = 0x78, + y = 0x79, + z = 0x7A, + + A = 0x41, + B = 0x42, + C = 0x43, + D = 0x44, + E = 0x45, + F = 0x46, + G = 0x47, + H = 0x48, + I = 0x49, + J = 0x4A, + K = 0x4B, + L = 0x4C, + M = 0x4D, + N = 0x4E, + O = 0x4F, + P = 0x50, + Q = 0x51, + R = 0x52, + S = 0x53, + T = 0x54, + U = 0x55, + V = 0x56, + W = 0x57, + X = 0x58, + Y = 0x59, + Z = 0x5A, + + ampersand = 0x26, // & + asterisk = 0x2A, // * + at = 0x40, // @ + backslash = 0x5C, // \ + bar = 0x7C, // | + caret = 0x5E, // ^ + closeBrace = 0x7D, // } + closeBracket = 0x5D, // ] + closeParen = 0x29, // ) + colon = 0x3A, // : + comma = 0x2C, // , + dot = 0x2E, // . + doubleQuote = 0x22, // " + equals = 0x3D, // = + exclamation = 0x21, // ! + greaterThan = 0x3E, // > + lessThan = 0x3C, // < + minus = 0x2D, // - + openBrace = 0x7B, // { + openBracket = 0x5B, // [ + openParen = 0x28, // ( + percent = 0x25, // % + plus = 0x2B, // + + question = 0x3F, // ? + semicolon = 0x3B, // ; + singleQuote = 0x27, // ' + slash = 0x2F, // / + tilde = 0x7E, // ~ + + backspace = 0x08, // \b + formFeed = 0x0C, // \f + byteOrderMark = 0xFEFF, + tab = 0x09, // \t + verticalTab = 0x0B, // \v +} + +interface NodeImpl extends Node { + type: NodeType; + value?: any; + offset: number; + length: number; + colonOffset?: number; + parent?: NodeImpl; + children?: NodeImpl[]; +} + +/** + * For a given offset, evaluate the location in the JSON document. Each segment in the location path is either a property name or an array index. + */ +export function getLocation(text: string, position: number): Location { + const segments: Segment[] = []; // strings or numbers + const earlyReturnException = new Object(); + let previousNode: NodeImpl | undefined = undefined; + const previousNodeInst: NodeImpl = { + value: {}, + offset: 0, + length: 0, + type: 'object', + parent: undefined + }; + let isAtPropertyKey = false; + function setPreviousNode(value: string, offset: number, length: number, type: NodeType) { + previousNodeInst.value = value; + previousNodeInst.offset = offset; + previousNodeInst.length = length; + previousNodeInst.type = type; + previousNodeInst.colonOffset = undefined; + previousNode = previousNodeInst; + } + try { + + visit(text, { + onObjectBegin: (offset: number, length: number) => { + if (position <= offset) { + throw earlyReturnException; + } + previousNode = undefined; + isAtPropertyKey = position > offset; + segments.push(''); // push a placeholder (will be replaced) + }, + onObjectProperty: (name: string, offset: number, length: number) => { + if (position < offset) { + throw earlyReturnException; + } + setPreviousNode(name, offset, length, 'property'); + segments[segments.length - 1] = name; + if (position <= offset + length) { + throw earlyReturnException; + } + }, + onObjectEnd: (offset: number, length: number) => { + if (position <= offset) { + throw earlyReturnException; + } + previousNode = undefined; + segments.pop(); + }, + onArrayBegin: (offset: number, length: number) => { + if (position <= offset) { + throw earlyReturnException; + } + previousNode = undefined; + segments.push(0); + }, + onArrayEnd: (offset: number, length: number) => { + if (position <= offset) { + throw earlyReturnException; + } + previousNode = undefined; + segments.pop(); + }, + onLiteralValue: (value: any, offset: number, length: number) => { + if (position < offset) { + throw earlyReturnException; + } + setPreviousNode(value, offset, length, getNodeType(value)); + + if (position <= offset + length) { + throw earlyReturnException; + } + }, + onSeparator: (sep: string, offset: number, length: number) => { + if (position <= offset) { + throw earlyReturnException; + } + if (sep === ':' && previousNode && previousNode.type === 'property') { + previousNode.colonOffset = offset; + isAtPropertyKey = false; + previousNode = undefined; + } else if (sep === ',') { + const last = segments[segments.length - 1]; + if (typeof last === 'number') { + segments[segments.length - 1] = last + 1; + } else { + isAtPropertyKey = true; + segments[segments.length - 1] = ''; + } + previousNode = undefined; + } + } + }); + } catch (e) { + if (e !== earlyReturnException) { + throw e; + } + } + + return { + path: segments, + previousNode, + isAtPropertyKey, + matches: (pattern: Segment[]) => { + let k = 0; + for (let i = 0; k < pattern.length && i < segments.length; i++) { + if (pattern[k] === segments[i] || pattern[k] === '*') { + k++; + } else if (pattern[k] !== '**') { + return false; + } + } + return k === pattern.length; + } + }; +} + + +/** + * Parses the given text and returns the object the JSON content represents. On invalid input, the parser tries to be as fault tolerant as possible, but still return a result. + * Therefore always check the errors list to find out if the input was valid. + */ +export function parse(text: string, errors: ParseError[] = [], options: ParseOptions = ParseOptions.DEFAULT): any { + let currentProperty: string | null = null; + let currentParent: any = []; + const previousParents: any[] = []; + + function onValue(value: any) { + if (Array.isArray(currentParent)) { + (currentParent).push(value); + } else if (currentProperty !== null) { + currentParent[currentProperty] = value; + } + } + + const visitor: JSONVisitor = { + onObjectBegin: () => { + const object = {}; + onValue(object); + previousParents.push(currentParent); + currentParent = object; + currentProperty = null; + }, + onObjectProperty: (name: string) => { + currentProperty = name; + }, + onObjectEnd: () => { + currentParent = previousParents.pop(); + }, + onArrayBegin: () => { + const array: any[] = []; + onValue(array); + previousParents.push(currentParent); + currentParent = array; + currentProperty = null; + }, + onArrayEnd: () => { + currentParent = previousParents.pop(); + }, + onLiteralValue: onValue, + onError: (error: ParseErrorCode, offset: number, length: number) => { + errors.push({ error, offset, length }); + } + }; + visit(text, visitor, options); + return currentParent[0]; +} + + +/** + * Parses the given text and returns a tree representation the JSON content. On invalid input, the parser tries to be as fault tolerant as possible, but still return a result. + */ +export function parseTree(text: string, errors: ParseError[] = [], options: ParseOptions = ParseOptions.DEFAULT): Node { + let currentParent: NodeImpl = { type: 'array', offset: -1, length: -1, children: [], parent: undefined }; // artificial root + + function ensurePropertyComplete(endOffset: number) { + if (currentParent.type === 'property') { + currentParent.length = endOffset - currentParent.offset; + currentParent = currentParent.parent!; + } + } + + function onValue(valueNode: Node): Node { + currentParent.children!.push(valueNode); + return valueNode; + } + + const visitor: JSONVisitor = { + onObjectBegin: (offset: number) => { + currentParent = onValue({ type: 'object', offset, length: -1, parent: currentParent, children: [] }); + }, + onObjectProperty: (name: string, offset: number, length: number) => { + currentParent = onValue({ type: 'property', offset, length: -1, parent: currentParent, children: [] }); + currentParent.children!.push({ type: 'string', value: name, offset, length, parent: currentParent }); + }, + onObjectEnd: (offset: number, length: number) => { + currentParent.length = offset + length - currentParent.offset; + currentParent = currentParent.parent!; + ensurePropertyComplete(offset + length); + }, + onArrayBegin: (offset: number, length: number) => { + currentParent = onValue({ type: 'array', offset, length: -1, parent: currentParent, children: [] }); + }, + onArrayEnd: (offset: number, length: number) => { + currentParent.length = offset + length - currentParent.offset; + currentParent = currentParent.parent!; + ensurePropertyComplete(offset + length); + }, + onLiteralValue: (value: any, offset: number, length: number) => { + onValue({ type: getNodeType(value), offset, length, parent: currentParent, value }); + ensurePropertyComplete(offset + length); + }, + onSeparator: (sep: string, offset: number, length: number) => { + if (currentParent.type === 'property') { + if (sep === ':') { + currentParent.colonOffset = offset; + } else if (sep === ',') { + ensurePropertyComplete(offset); + } + } + }, + onError: (error: ParseErrorCode, offset: number, length: number) => { + errors.push({ error, offset, length }); + } + }; + visit(text, visitor, options); + + const result = currentParent.children![0]; + if (result) { + delete result.parent; + } + return result; +} + +/** + * Finds the node at the given path in a JSON DOM. + */ +export function findNodeAtLocation(root: Node, path: JSONPath): Node | undefined { + if (!root) { + return undefined; + } + let node = root; + for (const segment of path) { + if (typeof segment === 'string') { + if (node.type !== 'object' || !Array.isArray(node.children)) { + return undefined; + } + let found = false; + for (const propertyNode of node.children) { + if (Array.isArray(propertyNode.children) && propertyNode.children[0].value === segment) { + node = propertyNode.children[1]; + found = true; + break; + } + } + if (!found) { + return undefined; + } + } else { + const index = segment; + if (node.type !== 'array' || index < 0 || !Array.isArray(node.children) || index >= node.children.length) { + return undefined; + } + node = node.children[index]; + } + } + return node; +} + +/** + * Gets the JSON path of the given JSON DOM node + */ +export function getNodePath(node: Node): JSONPath { + if (!node.parent || !node.parent.children) { + return []; + } + const path = getNodePath(node.parent); + if (node.parent.type === 'property') { + const key = node.parent.children[0].value; + path.push(key); + } else if (node.parent.type === 'array') { + const index = node.parent.children.indexOf(node); + if (index !== -1) { + path.push(index); + } + } + return path; +} + +/** + * Evaluates the JavaScript object of the given JSON DOM node + */ +export function getNodeValue(node: Node): any { + switch (node.type) { + case 'array': + return node.children!.map(getNodeValue); + case 'object': { + const obj = Object.create(null); + for (const prop of node.children!) { + const valueNode = prop.children![1]; + if (valueNode) { + obj[prop.children![0].value] = getNodeValue(valueNode); + } + } + return obj; + } + case 'null': + case 'string': + case 'number': + case 'boolean': + return node.value; + default: + return undefined; + } + +} + +export function contains(node: Node, offset: number, includeRightBound = false): boolean { + return (offset >= node.offset && offset < (node.offset + node.length)) || includeRightBound && (offset === (node.offset + node.length)); +} + +/** + * Finds the most inner node at the given offset. If includeRightBound is set, also finds nodes that end at the given offset. + */ +export function findNodeAtOffset(node: Node, offset: number, includeRightBound = false): Node | undefined { + if (contains(node, offset, includeRightBound)) { + const children = node.children; + if (Array.isArray(children)) { + for (let i = 0; i < children.length && children[i].offset <= offset; i++) { + const item = findNodeAtOffset(children[i], offset, includeRightBound); + if (item) { + return item; + } + } + + } + return node; + } + return undefined; +} + + +/** + * Parses the given text and invokes the visitor functions for each object, array and literal reached. + */ +export function visit(text: string, visitor: JSONVisitor, options: ParseOptions = ParseOptions.DEFAULT): any { + + const _scanner = createScanner(text, false); + + function toNoArgVisit(visitFunction?: (offset: number, length: number) => void): () => void { + return visitFunction ? () => visitFunction(_scanner.getTokenOffset(), _scanner.getTokenLength()) : () => true; + } + function toOneArgVisit(visitFunction?: (arg: T, offset: number, length: number) => void): (arg: T) => void { + return visitFunction ? (arg: T) => visitFunction(arg, _scanner.getTokenOffset(), _scanner.getTokenLength()) : () => true; + } + + const onObjectBegin = toNoArgVisit(visitor.onObjectBegin), + onObjectProperty = toOneArgVisit(visitor.onObjectProperty), + onObjectEnd = toNoArgVisit(visitor.onObjectEnd), + onArrayBegin = toNoArgVisit(visitor.onArrayBegin), + onArrayEnd = toNoArgVisit(visitor.onArrayEnd), + onLiteralValue = toOneArgVisit(visitor.onLiteralValue), + onSeparator = toOneArgVisit(visitor.onSeparator), + onComment = toNoArgVisit(visitor.onComment), + onError = toOneArgVisit(visitor.onError); + + const disallowComments = options && options.disallowComments; + const allowTrailingComma = options && options.allowTrailingComma; + function scanNext(): SyntaxKind { + while (true) { + const token = _scanner.scan(); + switch (_scanner.getTokenError()) { + case ScanError.InvalidUnicode: + handleError(ParseErrorCode.InvalidUnicode); + break; + case ScanError.InvalidEscapeCharacter: + handleError(ParseErrorCode.InvalidEscapeCharacter); + break; + case ScanError.UnexpectedEndOfNumber: + handleError(ParseErrorCode.UnexpectedEndOfNumber); + break; + case ScanError.UnexpectedEndOfComment: + if (!disallowComments) { + handleError(ParseErrorCode.UnexpectedEndOfComment); + } + break; + case ScanError.UnexpectedEndOfString: + handleError(ParseErrorCode.UnexpectedEndOfString); + break; + case ScanError.InvalidCharacter: + handleError(ParseErrorCode.InvalidCharacter); + break; + } + switch (token) { + case SyntaxKind.LineCommentTrivia: + case SyntaxKind.BlockCommentTrivia: + if (disallowComments) { + handleError(ParseErrorCode.InvalidCommentToken); + } else { + onComment(); + } + break; + case SyntaxKind.Unknown: + handleError(ParseErrorCode.InvalidSymbol); + break; + case SyntaxKind.Trivia: + case SyntaxKind.LineBreakTrivia: + break; + default: + return token; + } + } + } + + function handleError(error: ParseErrorCode, skipUntilAfter: SyntaxKind[] = [], skipUntil: SyntaxKind[] = []): void { + onError(error); + if (skipUntilAfter.length + skipUntil.length > 0) { + let token = _scanner.getToken(); + while (token !== SyntaxKind.EOF) { + if (skipUntilAfter.indexOf(token) !== -1) { + scanNext(); + break; + } else if (skipUntil.indexOf(token) !== -1) { + break; + } + token = scanNext(); + } + } + } + + function parseString(isValue: boolean): boolean { + const value = _scanner.getTokenValue(); + if (isValue) { + onLiteralValue(value); + } else { + onObjectProperty(value); + } + scanNext(); + return true; + } + + function parseLiteral(): boolean { + switch (_scanner.getToken()) { + case SyntaxKind.NumericLiteral: { + let value = 0; + try { + value = JSON.parse(_scanner.getTokenValue()); + if (typeof value !== 'number') { + handleError(ParseErrorCode.InvalidNumberFormat); + value = 0; + } + } catch (e) { + handleError(ParseErrorCode.InvalidNumberFormat); + } + onLiteralValue(value); + break; + } + case SyntaxKind.NullKeyword: + onLiteralValue(null); + break; + case SyntaxKind.TrueKeyword: + onLiteralValue(true); + break; + case SyntaxKind.FalseKeyword: + onLiteralValue(false); + break; + default: + return false; + } + scanNext(); + return true; + } + + function parseProperty(): boolean { + if (_scanner.getToken() !== SyntaxKind.StringLiteral) { + handleError(ParseErrorCode.PropertyNameExpected, [], [SyntaxKind.CloseBraceToken, SyntaxKind.CommaToken]); + return false; + } + parseString(false); + if (_scanner.getToken() === SyntaxKind.ColonToken) { + onSeparator(':'); + scanNext(); // consume colon + + if (!parseValue()) { + handleError(ParseErrorCode.ValueExpected, [], [SyntaxKind.CloseBraceToken, SyntaxKind.CommaToken]); + } + } else { + handleError(ParseErrorCode.ColonExpected, [], [SyntaxKind.CloseBraceToken, SyntaxKind.CommaToken]); + } + return true; + } + + function parseObject(): boolean { + onObjectBegin(); + scanNext(); // consume open brace + + let needsComma = false; + while (_scanner.getToken() !== SyntaxKind.CloseBraceToken && _scanner.getToken() !== SyntaxKind.EOF) { + if (_scanner.getToken() === SyntaxKind.CommaToken) { + if (!needsComma) { + handleError(ParseErrorCode.ValueExpected, [], []); + } + onSeparator(','); + scanNext(); // consume comma + if (_scanner.getToken() === SyntaxKind.CloseBraceToken && allowTrailingComma) { + break; + } + } else if (needsComma) { + handleError(ParseErrorCode.CommaExpected, [], []); + } + if (!parseProperty()) { + handleError(ParseErrorCode.ValueExpected, [], [SyntaxKind.CloseBraceToken, SyntaxKind.CommaToken]); + } + needsComma = true; + } + onObjectEnd(); + if (_scanner.getToken() !== SyntaxKind.CloseBraceToken) { + handleError(ParseErrorCode.CloseBraceExpected, [SyntaxKind.CloseBraceToken], []); + } else { + scanNext(); // consume close brace + } + return true; + } + + function parseArray(): boolean { + onArrayBegin(); + scanNext(); // consume open bracket + + let needsComma = false; + while (_scanner.getToken() !== SyntaxKind.CloseBracketToken && _scanner.getToken() !== SyntaxKind.EOF) { + if (_scanner.getToken() === SyntaxKind.CommaToken) { + if (!needsComma) { + handleError(ParseErrorCode.ValueExpected, [], []); + } + onSeparator(','); + scanNext(); // consume comma + if (_scanner.getToken() === SyntaxKind.CloseBracketToken && allowTrailingComma) { + break; + } + } else if (needsComma) { + handleError(ParseErrorCode.CommaExpected, [], []); + } + if (!parseValue()) { + handleError(ParseErrorCode.ValueExpected, [], [SyntaxKind.CloseBracketToken, SyntaxKind.CommaToken]); + } + needsComma = true; + } + onArrayEnd(); + if (_scanner.getToken() !== SyntaxKind.CloseBracketToken) { + handleError(ParseErrorCode.CloseBracketExpected, [SyntaxKind.CloseBracketToken], []); + } else { + scanNext(); // consume close bracket + } + return true; + } + + function parseValue(): boolean { + switch (_scanner.getToken()) { + case SyntaxKind.OpenBracketToken: + return parseArray(); + case SyntaxKind.OpenBraceToken: + return parseObject(); + case SyntaxKind.StringLiteral: + return parseString(true); + default: + return parseLiteral(); + } + } + + scanNext(); + if (_scanner.getToken() === SyntaxKind.EOF) { + if (options.allowEmptyContent) { + return true; + } + handleError(ParseErrorCode.ValueExpected, [], []); + return false; + } + if (!parseValue()) { + handleError(ParseErrorCode.ValueExpected, [], []); + return false; + } + if (_scanner.getToken() !== SyntaxKind.EOF) { + handleError(ParseErrorCode.EndOfFileExpected, [], []); + } + return true; +} + +export function getNodeType(value: any): NodeType { + switch (typeof value) { + case 'boolean': return 'boolean'; + case 'number': return 'number'; + case 'string': return 'string'; + case 'object': { + if (!value) { + return 'null'; + } else if (Array.isArray(value)) { + return 'array'; + } + return 'object'; + } + default: return 'null'; + } +} diff --git a/src/vs/base/common/jsonEdit.ts b/src/vs/base/common/jsonEdit.ts new file mode 100644 index 0000000000..9d62ed9e66 --- /dev/null +++ b/src/vs/base/common/jsonEdit.ts @@ -0,0 +1,176 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { findNodeAtLocation, JSONPath, Node, ParseError, parseTree, Segment } from './json'; +import { Edit, format, FormattingOptions, isEOL } from './jsonFormatter'; + + +export function removeProperty(text: string, path: JSONPath, formattingOptions: FormattingOptions): Edit[] { + return setProperty(text, path, undefined, formattingOptions); +} + +export function setProperty(text: string, originalPath: JSONPath, value: any, formattingOptions: FormattingOptions, getInsertionIndex?: (properties: string[]) => number): Edit[] { + const path = originalPath.slice(); + const errors: ParseError[] = []; + const root = parseTree(text, errors); + let parent: Node | undefined = undefined; + + let lastSegment: Segment | undefined = undefined; + while (path.length > 0) { + lastSegment = path.pop(); + parent = findNodeAtLocation(root, path); + if (parent === undefined && value !== undefined) { + if (typeof lastSegment === 'string') { + value = { [lastSegment]: value }; + } else { + value = [value]; + } + } else { + break; + } + } + + if (!parent) { + // empty document + if (value === undefined) { // delete + return []; // property does not exist, nothing to do + } + return withFormatting(text, { offset: root ? root.offset : 0, length: root ? root.length : 0, content: JSON.stringify(value) }, formattingOptions); + } else if (parent.type === 'object' && typeof lastSegment === 'string' && Array.isArray(parent.children)) { + const existing = findNodeAtLocation(parent, [lastSegment]); + if (existing !== undefined) { + if (value === undefined) { // delete + if (!existing.parent) { + throw new Error('Malformed AST'); + } + const propertyIndex = parent.children.indexOf(existing.parent); + let removeBegin: number; + let removeEnd = existing.parent.offset + existing.parent.length; + if (propertyIndex > 0) { + // remove the comma of the previous node + const previous = parent.children[propertyIndex - 1]; + removeBegin = previous.offset + previous.length; + } else { + removeBegin = parent.offset + 1; + if (parent.children.length > 1) { + // remove the comma of the next node + const next = parent.children[1]; + removeEnd = next.offset; + } + } + return withFormatting(text, { offset: removeBegin, length: removeEnd - removeBegin, content: '' }, formattingOptions); + } else { + // set value of existing property + return withFormatting(text, { offset: existing.offset, length: existing.length, content: JSON.stringify(value) }, formattingOptions); + } + } else { + if (value === undefined) { // delete + return []; // property does not exist, nothing to do + } + const newProperty = `${JSON.stringify(lastSegment)}: ${JSON.stringify(value)}`; + const index = getInsertionIndex ? getInsertionIndex(parent.children.map(p => p.children![0].value)) : parent.children.length; + let edit: Edit; + if (index > 0) { + const previous = parent.children[index - 1]; + edit = { offset: previous.offset + previous.length, length: 0, content: ',' + newProperty }; + } else if (parent.children.length === 0) { + edit = { offset: parent.offset + 1, length: 0, content: newProperty }; + } else { + edit = { offset: parent.offset + 1, length: 0, content: newProperty + ',' }; + } + return withFormatting(text, edit, formattingOptions); + } + } else if (parent.type === 'array' && typeof lastSegment === 'number' && Array.isArray(parent.children)) { + if (value !== undefined) { + // Insert + const newProperty = `${JSON.stringify(value)}`; + let edit: Edit; + if (parent.children.length === 0 || lastSegment === 0) { + edit = { offset: parent.offset + 1, length: 0, content: parent.children.length === 0 ? newProperty : newProperty + ',' }; + } else { + const index = lastSegment === -1 || lastSegment > parent.children.length ? parent.children.length : lastSegment; + const previous = parent.children[index - 1]; + edit = { offset: previous.offset + previous.length, length: 0, content: ',' + newProperty }; + } + return withFormatting(text, edit, formattingOptions); + } else { + //Removal + const removalIndex = lastSegment; + const toRemove = parent.children[removalIndex]; + let edit: Edit; + if (parent.children.length === 1) { + // only item + edit = { offset: parent.offset + 1, length: parent.length - 2, content: '' }; + } else if (parent.children.length - 1 === removalIndex) { + // last item + const previous = parent.children[removalIndex - 1]; + const offset = previous.offset + previous.length; + const parentEndOffset = parent.offset + parent.length; + edit = { offset, length: parentEndOffset - 2 - offset, content: '' }; + } else { + edit = { offset: toRemove.offset, length: parent.children[removalIndex + 1].offset - toRemove.offset, content: '' }; + } + return withFormatting(text, edit, formattingOptions); + } + } else { + throw new Error(`Can not add ${typeof lastSegment !== 'number' ? 'index' : 'property'} to parent of type ${parent.type}`); + } +} + +export function withFormatting(text: string, edit: Edit, formattingOptions: FormattingOptions): Edit[] { + // apply the edit + let newText = applyEdit(text, edit); + + // format the new text + let begin = edit.offset; + let end = edit.offset + edit.content.length; + if (edit.length === 0 || edit.content.length === 0) { // insert or remove + while (begin > 0 && !isEOL(newText, begin - 1)) { + begin--; + } + while (end < newText.length && !isEOL(newText, end)) { + end++; + } + } + + const edits = format(newText, { offset: begin, length: end - begin }, formattingOptions); + + // apply the formatting edits and track the begin and end offsets of the changes + for (let i = edits.length - 1; i >= 0; i--) { + const curr = edits[i]; + newText = applyEdit(newText, curr); + begin = Math.min(begin, curr.offset); + end = Math.max(end, curr.offset + curr.length); + end += curr.content.length - curr.length; + } + // create a single edit with all changes + const editLength = text.length - (newText.length - end) - begin; + return [{ offset: begin, length: editLength, content: newText.substring(begin, end) }]; +} + +export function applyEdit(text: string, edit: Edit): string { + return text.substring(0, edit.offset) + edit.content + text.substring(edit.offset + edit.length); +} + +export function applyEdits(text: string, edits: Edit[]): string { + const sortedEdits = edits.slice(0).sort((a, b) => { + const diff = a.offset - b.offset; + if (diff === 0) { + return a.length - b.length; + } + return diff; + }); + let lastModifiedOffset = text.length; + for (let i = sortedEdits.length - 1; i >= 0; i--) { + const e = sortedEdits[i]; + if (e.offset + e.length <= lastModifiedOffset) { + text = applyEdit(text, e); + } else { + throw new Error('Overlapping edit'); + } + lastModifiedOffset = e.offset; + } + return text; +} diff --git a/src/vs/base/common/jsonErrorMessages.ts b/src/vs/base/common/jsonErrorMessages.ts new file mode 100644 index 0000000000..49b5b988cf --- /dev/null +++ b/src/vs/base/common/jsonErrorMessages.ts @@ -0,0 +1,26 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Extracted from json.ts to keep json nls free. + */ +import { localize } from 'vs/nls'; +import { ParseErrorCode } from './json'; + +export function getParseErrorMessage(errorCode: ParseErrorCode): string { + switch (errorCode) { + case ParseErrorCode.InvalidSymbol: return localize('error.invalidSymbol', 'Invalid symbol'); + case ParseErrorCode.InvalidNumberFormat: return localize('error.invalidNumberFormat', 'Invalid number format'); + case ParseErrorCode.PropertyNameExpected: return localize('error.propertyNameExpected', 'Property name expected'); + case ParseErrorCode.ValueExpected: return localize('error.valueExpected', 'Value expected'); + case ParseErrorCode.ColonExpected: return localize('error.colonExpected', 'Colon expected'); + case ParseErrorCode.CommaExpected: return localize('error.commaExpected', 'Comma expected'); + case ParseErrorCode.CloseBraceExpected: return localize('error.closeBraceExpected', 'Closing brace expected'); + case ParseErrorCode.CloseBracketExpected: return localize('error.closeBracketExpected', 'Closing bracket expected'); + case ParseErrorCode.EndOfFileExpected: return localize('error.endOfFileExpected', 'End of file expected'); + default: + return ''; + } +} diff --git a/src/vs/base/common/jsonFormatter.ts b/src/vs/base/common/jsonFormatter.ts new file mode 100644 index 0000000000..18fc0d5338 --- /dev/null +++ b/src/vs/base/common/jsonFormatter.ts @@ -0,0 +1,261 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { createScanner, ScanError, SyntaxKind } from './json'; + +export interface FormattingOptions { + /** + * If indentation is based on spaces (`insertSpaces` = true), then what is the number of spaces that make an indent? + */ + tabSize?: number; + /** + * Is indentation based on spaces? + */ + insertSpaces?: boolean; + /** + * The default 'end of line' character. If not set, '\n' is used as default. + */ + eol?: string; +} + +/** + * Represents a text modification + */ +export interface Edit { + /** + * The start offset of the modification. + */ + offset: number; + /** + * The length of the modification. Must not be negative. Empty length represents an *insert*. + */ + length: number; + /** + * The new content. Empty content represents a *remove*. + */ + content: string; +} + +/** + * A text range in the document +*/ +export interface Range { + /** + * The start offset of the range. + */ + offset: number; + /** + * The length of the range. Must not be negative. + */ + length: number; +} + + +export function format(documentText: string, range: Range | undefined, options: FormattingOptions): Edit[] { + let initialIndentLevel: number; + let formatText: string; + let formatTextStart: number; + let rangeStart: number; + let rangeEnd: number; + if (range) { + rangeStart = range.offset; + rangeEnd = rangeStart + range.length; + + formatTextStart = rangeStart; + while (formatTextStart > 0 && !isEOL(documentText, formatTextStart - 1)) { + formatTextStart--; + } + let endOffset = rangeEnd; + while (endOffset < documentText.length && !isEOL(documentText, endOffset)) { + endOffset++; + } + formatText = documentText.substring(formatTextStart, endOffset); + initialIndentLevel = computeIndentLevel(formatText, options); + } else { + formatText = documentText; + initialIndentLevel = 0; + formatTextStart = 0; + rangeStart = 0; + rangeEnd = documentText.length; + } + const eol = getEOL(options, documentText); + + let lineBreak = false; + let indentLevel = 0; + let indentValue: string; + if (options.insertSpaces) { + indentValue = repeat(' ', options.tabSize || 4); + } else { + indentValue = '\t'; + } + + const scanner = createScanner(formatText, false); + let hasError = false; + + function newLineAndIndent(): string { + return eol + repeat(indentValue, initialIndentLevel + indentLevel); + } + function scanNext(): SyntaxKind { + let token = scanner.scan(); + lineBreak = false; + while (token === SyntaxKind.Trivia || token === SyntaxKind.LineBreakTrivia) { + lineBreak = lineBreak || (token === SyntaxKind.LineBreakTrivia); + token = scanner.scan(); + } + hasError = token === SyntaxKind.Unknown || scanner.getTokenError() !== ScanError.None; + return token; + } + const editOperations: Edit[] = []; + function addEdit(text: string, startOffset: number, endOffset: number) { + if (!hasError && startOffset < rangeEnd && endOffset > rangeStart && documentText.substring(startOffset, endOffset) !== text) { + editOperations.push({ offset: startOffset, length: endOffset - startOffset, content: text }); + } + } + + let firstToken = scanNext(); + + if (firstToken !== SyntaxKind.EOF) { + const firstTokenStart = scanner.getTokenOffset() + formatTextStart; + const initialIndent = repeat(indentValue, initialIndentLevel); + addEdit(initialIndent, formatTextStart, firstTokenStart); + } + + while (firstToken !== SyntaxKind.EOF) { + let firstTokenEnd = scanner.getTokenOffset() + scanner.getTokenLength() + formatTextStart; + let secondToken = scanNext(); + + let replaceContent = ''; + while (!lineBreak && (secondToken === SyntaxKind.LineCommentTrivia || secondToken === SyntaxKind.BlockCommentTrivia)) { + // comments on the same line: keep them on the same line, but ignore them otherwise + const commentTokenStart = scanner.getTokenOffset() + formatTextStart; + addEdit(' ', firstTokenEnd, commentTokenStart); + firstTokenEnd = scanner.getTokenOffset() + scanner.getTokenLength() + formatTextStart; + replaceContent = secondToken === SyntaxKind.LineCommentTrivia ? newLineAndIndent() : ''; + secondToken = scanNext(); + } + + if (secondToken === SyntaxKind.CloseBraceToken) { + if (firstToken !== SyntaxKind.OpenBraceToken) { + indentLevel--; + replaceContent = newLineAndIndent(); + } + } else if (secondToken === SyntaxKind.CloseBracketToken) { + if (firstToken !== SyntaxKind.OpenBracketToken) { + indentLevel--; + replaceContent = newLineAndIndent(); + } + } else { + switch (firstToken) { + case SyntaxKind.OpenBracketToken: + case SyntaxKind.OpenBraceToken: + indentLevel++; + replaceContent = newLineAndIndent(); + break; + case SyntaxKind.CommaToken: + case SyntaxKind.LineCommentTrivia: + replaceContent = newLineAndIndent(); + break; + case SyntaxKind.BlockCommentTrivia: + if (lineBreak) { + replaceContent = newLineAndIndent(); + } else { + // symbol following comment on the same line: keep on same line, separate with ' ' + replaceContent = ' '; + } + break; + case SyntaxKind.ColonToken: + replaceContent = ' '; + break; + case SyntaxKind.StringLiteral: + if (secondToken === SyntaxKind.ColonToken) { + replaceContent = ''; + break; + } + // fall through + case SyntaxKind.NullKeyword: + case SyntaxKind.TrueKeyword: + case SyntaxKind.FalseKeyword: + case SyntaxKind.NumericLiteral: + case SyntaxKind.CloseBraceToken: + case SyntaxKind.CloseBracketToken: + if (secondToken === SyntaxKind.LineCommentTrivia || secondToken === SyntaxKind.BlockCommentTrivia) { + replaceContent = ' '; + } else if (secondToken !== SyntaxKind.CommaToken && secondToken !== SyntaxKind.EOF) { + hasError = true; + } + break; + case SyntaxKind.Unknown: + hasError = true; + break; + } + if (lineBreak && (secondToken === SyntaxKind.LineCommentTrivia || secondToken === SyntaxKind.BlockCommentTrivia)) { + replaceContent = newLineAndIndent(); + } + + } + const secondTokenStart = scanner.getTokenOffset() + formatTextStart; + addEdit(replaceContent, firstTokenEnd, secondTokenStart); + firstToken = secondToken; + } + return editOperations; +} + +/** + * Creates a formatted string out of the object passed as argument, using the given formatting options + * @param any The object to stringify and format + * @param options The formatting options to use + */ +export function toFormattedString(obj: any, options: FormattingOptions) { + const content = JSON.stringify(obj, undefined, options.insertSpaces ? options.tabSize || 4 : '\t'); + if (options.eol !== undefined) { + return content.replace(/\r\n|\r|\n/g, options.eol); + } + return content; +} + +function repeat(s: string, count: number): string { + let result = ''; + for (let i = 0; i < count; i++) { + result += s; + } + return result; +} + +function computeIndentLevel(content: string, options: FormattingOptions): number { + let i = 0; + let nChars = 0; + const tabSize = options.tabSize || 4; + while (i < content.length) { + const ch = content.charAt(i); + if (ch === ' ') { + nChars++; + } else if (ch === '\t') { + nChars += tabSize; + } else { + break; + } + i++; + } + return Math.floor(nChars / tabSize); +} + +export function getEOL(options: FormattingOptions, text: string): string { + for (let i = 0; i < text.length; i++) { + const ch = text.charAt(i); + if (ch === '\r') { + if (i + 1 < text.length && text.charAt(i + 1) === '\n') { + return '\r\n'; + } + return '\r'; + } else if (ch === '\n') { + return '\n'; + } + } + return (options && options.eol) || '\n'; +} + +export function isEOL(text: string, offset: number) { + return '\r\n'.indexOf(text.charAt(offset)) !== -1; +} diff --git a/src/vs/base/common/jsonSchema.ts b/src/vs/base/common/jsonSchema.ts new file mode 100644 index 0000000000..fbf6d9813f --- /dev/null +++ b/src/vs/base/common/jsonSchema.ts @@ -0,0 +1,267 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export type JSONSchemaType = 'string' | 'number' | 'integer' | 'boolean' | 'null' | 'array' | 'object'; + +export interface IJSONSchema { + id?: string; + $id?: string; + $schema?: string; + type?: JSONSchemaType | JSONSchemaType[]; + title?: string; + default?: any; + definitions?: IJSONSchemaMap; + description?: string; + properties?: IJSONSchemaMap; + patternProperties?: IJSONSchemaMap; + additionalProperties?: boolean | IJSONSchema; + minProperties?: number; + maxProperties?: number; + dependencies?: IJSONSchemaMap | { [prop: string]: string[] }; + items?: IJSONSchema | IJSONSchema[]; + minItems?: number; + maxItems?: number; + uniqueItems?: boolean; + additionalItems?: boolean | IJSONSchema; + pattern?: string; + minLength?: number; + maxLength?: number; + minimum?: number; + maximum?: number; + exclusiveMinimum?: boolean | number; + exclusiveMaximum?: boolean | number; + multipleOf?: number; + required?: string[]; + $ref?: string; + anyOf?: IJSONSchema[]; + allOf?: IJSONSchema[]; + oneOf?: IJSONSchema[]; + not?: IJSONSchema; + enum?: any[]; + format?: string; + + // schema draft 06 + const?: any; + contains?: IJSONSchema; + propertyNames?: IJSONSchema; + examples?: any[]; + + // schema draft 07 + $comment?: string; + if?: IJSONSchema; + then?: IJSONSchema; + else?: IJSONSchema; + + // schema 2019-09 + unevaluatedProperties?: boolean | IJSONSchema; + unevaluatedItems?: boolean | IJSONSchema; + minContains?: number; + maxContains?: number; + deprecated?: boolean; + dependentRequired?: { [prop: string]: string[] }; + dependentSchemas?: IJSONSchemaMap; + $defs?: { [name: string]: IJSONSchema }; + $anchor?: string; + $recursiveRef?: string; + $recursiveAnchor?: string; + $vocabulary?: any; + + // schema 2020-12 + prefixItems?: IJSONSchema[]; + $dynamicRef?: string; + $dynamicAnchor?: string; + + // VSCode extensions + + defaultSnippets?: IJSONSchemaSnippet[]; + errorMessage?: string; + patternErrorMessage?: string; + deprecationMessage?: string; + markdownDeprecationMessage?: string; + enumDescriptions?: string[]; + markdownEnumDescriptions?: string[]; + markdownDescription?: string; + doNotSuggest?: boolean; + suggestSortText?: string; + allowComments?: boolean; + allowTrailingCommas?: boolean; +} + +export interface IJSONSchemaMap { + [name: string]: IJSONSchema; +} + +export interface IJSONSchemaSnippet { + label?: string; + description?: string; + body?: any; // a object that will be JSON stringified + bodyText?: string; // an already stringified JSON object that can contain new lines (\n) and tabs (\t) +} + +/** + * Converts a basic JSON schema to a TypeScript type. + * + * TODO: only supports basic schemas. Doesn't support all JSON schema features. + */ +export type SchemaToType = T extends { type: 'string' } + ? string + : T extends { type: 'number' } + ? number + : T extends { type: 'boolean' } + ? boolean + : T extends { type: 'null' } + ? null + : T extends { type: 'object'; properties: infer P } + ? { [K in keyof P]: SchemaToType } + : T extends { type: 'array'; items: infer I } + ? Array> + : never; + +interface Equals { schemas: IJSONSchema[]; id?: string } + +export function getCompressedContent(schema: IJSONSchema): string { + let hasDups = false; + + + // visit all schema nodes and collect the ones that are equal + const equalsByString = new Map(); + const nodeToEquals = new Map(); + const visitSchemas = (next: IJSONSchema) => { + if (schema === next) { + return true; + } + const val = JSON.stringify(next); + if (val.length < 30) { + // the $ref takes around 25 chars, so we don't save anything + return true; + } + const eq = equalsByString.get(val); + if (!eq) { + const newEq = { schemas: [next] }; + equalsByString.set(val, newEq); + nodeToEquals.set(next, newEq); + return true; + } + eq.schemas.push(next); + nodeToEquals.set(next, eq); + hasDups = true; + return false; + }; + traverseNodes(schema, visitSchemas); + equalsByString.clear(); + + if (!hasDups) { + return JSON.stringify(schema); + } + + let defNodeName = '$defs'; + while (schema.hasOwnProperty(defNodeName)) { + defNodeName += '_'; + } + + // used to collect all schemas that are later put in `$defs`. The index in the array is the id of the schema. + const definitions: IJSONSchema[] = []; + + function stringify(root: IJSONSchema): string { + return JSON.stringify(root, (_key: string, value: any) => { + if (value !== root) { + const eq = nodeToEquals.get(value); + if (eq && eq.schemas.length > 1) { + if (!eq.id) { + eq.id = `_${definitions.length}`; + definitions.push(eq.schemas[0]); + } + return { $ref: `#/${defNodeName}/${eq.id}` }; + } + } + return value; + }); + } + + // stringify the schema and replace duplicate subtrees with $ref + // this will add new items to the definitions array + const str = stringify(schema); + + // now stringify the definitions. Each invication of stringify cann add new items to the definitions array, so the length can grow while we iterate + const defStrings: string[] = []; + for (let i = 0; i < definitions.length; i++) { + defStrings.push(`"_${i}":${stringify(definitions[i])}`); + } + if (defStrings.length) { + return `${str.substring(0, str.length - 1)},"${defNodeName}":{${defStrings.join(',')}}}`; + } + return str; +} + +type IJSONSchemaRef = IJSONSchema | boolean; + +function isObject(thing: any): thing is object { + return typeof thing === 'object' && thing !== null; +} + +/* + * Traverse a JSON schema and visit each schema node +*/ +function traverseNodes(root: IJSONSchema, visit: (schema: IJSONSchema) => boolean) { + if (!root || typeof root !== 'object') { + return; + } + const collectEntries = (...entries: (IJSONSchemaRef | undefined)[]) => { + for (const entry of entries) { + if (isObject(entry)) { + toWalk.push(entry); + } + } + }; + const collectMapEntries = (...maps: (IJSONSchemaMap | undefined)[]) => { + for (const map of maps) { + if (isObject(map)) { + for (const key in map) { + const entry = map[key]; + if (isObject(entry)) { + toWalk.push(entry); + } + } + } + } + }; + const collectArrayEntries = (...arrays: (IJSONSchemaRef[] | undefined)[]) => { + for (const array of arrays) { + if (Array.isArray(array)) { + for (const entry of array) { + if (isObject(entry)) { + toWalk.push(entry); + } + } + } + } + }; + const collectEntryOrArrayEntries = (items: (IJSONSchemaRef[] | IJSONSchemaRef | undefined)) => { + if (Array.isArray(items)) { + for (const entry of items) { + if (isObject(entry)) { + toWalk.push(entry); + } + } + } else if (isObject(items)) { + toWalk.push(items); + } + }; + + const toWalk: IJSONSchema[] = [root]; + + let next = toWalk.pop(); + while (next) { + const visitChildern = visit(next); + if (visitChildern) { + collectEntries(next.additionalItems, next.additionalProperties, next.not, next.contains, next.propertyNames, next.if, next.then, next.else, next.unevaluatedItems, next.unevaluatedProperties); + collectMapEntries(next.definitions, next.$defs, next.properties, next.patternProperties, next.dependencies, next.dependentSchemas); + collectArrayEntries(next.anyOf, next.allOf, next.oneOf, next.prefixItems); + collectEntryOrArrayEntries(next.items); + } + next = toWalk.pop(); + } +} + diff --git a/src/vs/base/common/jsonc.d.ts b/src/vs/base/common/jsonc.d.ts new file mode 100644 index 0000000000..504e6c60f9 --- /dev/null +++ b/src/vs/base/common/jsonc.d.ts @@ -0,0 +1,24 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * A drop-in replacement for JSON.parse that can parse + * JSON with comments and trailing commas. + * + * @param content the content to strip comments from + * @returns the parsed content as JSON +*/ +export function parse(content: string): any; + +/** + * Strips single and multi line JavaScript comments from JSON + * content. Ignores characters in strings BUT doesn't support + * string continuation across multiple lines since it is not + * supported in JSON. + * + * @param content the content to strip comments from + * @returns the content without comments +*/ +export function stripComments(content: string): string; diff --git a/src/vs/base/common/jsonc.js b/src/vs/base/common/jsonc.js new file mode 100644 index 0000000000..21e3b7eae4 --- /dev/null +++ b/src/vs/base/common/jsonc.js @@ -0,0 +1,89 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/// + +//@ts-check +'use strict'; + +// ESM-uncomment-begin +// const module = { exports: {} }; +// ESM-uncomment-end + +(function () { + + function factory() { + // First group matches a double quoted string + // Second group matches a single quoted string + // Third group matches a multi line comment + // Forth group matches a single line comment + // Fifth group matches a trailing comma + const regexp = /("[^"\\]*(?:\\.[^"\\]*)*")|('[^'\\]*(?:\\.[^'\\]*)*')|(\/\*[^\/\*]*(?:(?:\*|\/)[^\/\*]*)*?\*\/)|(\/{2,}.*?(?:(?:\r?\n)|$))|(,\s*[}\]])/g; + + /** + * @param {string} content + * @returns {string} + */ + function stripComments(content) { + return content.replace(regexp, function (match, _m1, _m2, m3, m4, m5) { + // Only one of m1, m2, m3, m4, m5 matches + if (m3) { + // A block comment. Replace with nothing + return ''; + } else if (m4) { + // Since m4 is a single line comment is is at least of length 2 (e.g. //) + // If it ends in \r?\n then keep it. + const length = m4.length; + if (m4[length - 1] === '\n') { + return m4[length - 2] === '\r' ? '\r\n' : '\n'; + } + else { + return ''; + } + } else if (m5) { + // Remove the trailing comma + return match.substring(1); + } else { + // We match a string + return match; + } + }); + } + + /** + * @param {string} content + * @returns {any} + */ + function parse(content) { + const commentsStripped = stripComments(content); + + try { + return JSON.parse(commentsStripped); + } catch (error) { + const trailingCommasStriped = commentsStripped.replace(/,\s*([}\]])/g, '$1'); + return JSON.parse(trailingCommasStriped); + } + } + return { + stripComments, + parse + }; + } + + if (typeof define === 'function') { + // amd + define([], function () { return factory(); }); + } else if (typeof module === 'object' && typeof module.exports === 'object') { + // commonjs + module.exports = factory(); + } else { + console.trace('jsonc defined in UNKNOWN context (neither requirejs or commonjs)'); + } +})(); + +// ESM-uncomment-begin +// export const stripComments = module.exports.stripComments; +// export const parse = module.exports.parse; +// ESM-uncomment-end diff --git a/src/vs/base/common/keyCodes.ts b/src/vs/base/common/keyCodes.ts new file mode 100644 index 0000000000..1d336dfaee --- /dev/null +++ b/src/vs/base/common/keyCodes.ts @@ -0,0 +1,526 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Virtual Key Codes, the value does not hold any inherent meaning. + * Inspired somewhat from https://msdn.microsoft.com/en-us/library/windows/desktop/dd375731(v=vs.85).aspx + * But these are "more general", as they should work across browsers & OS`s. + */ +export const enum KeyCode { + DependsOnKbLayout = -1, + + /** + * Placed first to cover the 0 value of the enum. + */ + Unknown = 0, + + Backspace, + Tab, + Enter, + Shift, + Ctrl, + Alt, + PauseBreak, + CapsLock, + Escape, + Space, + PageUp, + PageDown, + End, + Home, + LeftArrow, + UpArrow, + RightArrow, + DownArrow, + Insert, + Delete, + + Digit0, + Digit1, + Digit2, + Digit3, + Digit4, + Digit5, + Digit6, + Digit7, + Digit8, + Digit9, + + KeyA, + KeyB, + KeyC, + KeyD, + KeyE, + KeyF, + KeyG, + KeyH, + KeyI, + KeyJ, + KeyK, + KeyL, + KeyM, + KeyN, + KeyO, + KeyP, + KeyQ, + KeyR, + KeyS, + KeyT, + KeyU, + KeyV, + KeyW, + KeyX, + KeyY, + KeyZ, + + Meta, + ContextMenu, + + F1, + F2, + F3, + F4, + F5, + F6, + F7, + F8, + F9, + F10, + F11, + F12, + F13, + F14, + F15, + F16, + F17, + F18, + F19, + F20, + F21, + F22, + F23, + F24, + + NumLock, + ScrollLock, + + /** + * Used for miscellaneous characters; it can vary by keyboard. + * For the US standard keyboard, the ';:' key + */ + Semicolon, + /** + * For any country/region, the '+' key + * For the US standard keyboard, the '=+' key + */ + Equal, + /** + * For any country/region, the ',' key + * For the US standard keyboard, the ',<' key + */ + Comma, + /** + * For any country/region, the '-' key + * For the US standard keyboard, the '-_' key + */ + Minus, + /** + * For any country/region, the '.' key + * For the US standard keyboard, the '.>' key + */ + Period, + /** + * Used for miscellaneous characters; it can vary by keyboard. + * For the US standard keyboard, the '/?' key + */ + Slash, + /** + * Used for miscellaneous characters; it can vary by keyboard. + * For the US standard keyboard, the '`~' key + */ + Backquote, + /** + * Used for miscellaneous characters; it can vary by keyboard. + * For the US standard keyboard, the '[{' key + */ + BracketLeft, + /** + * Used for miscellaneous characters; it can vary by keyboard. + * For the US standard keyboard, the '\|' key + */ + Backslash, + /** + * Used for miscellaneous characters; it can vary by keyboard. + * For the US standard keyboard, the ']}' key + */ + BracketRight, + /** + * Used for miscellaneous characters; it can vary by keyboard. + * For the US standard keyboard, the ''"' key + */ + Quote, + /** + * Used for miscellaneous characters; it can vary by keyboard. + */ + OEM_8, + /** + * Either the angle bracket key or the backslash key on the RT 102-key keyboard. + */ + IntlBackslash, + + Numpad0, // VK_NUMPAD0, 0x60, Numeric keypad 0 key + Numpad1, // VK_NUMPAD1, 0x61, Numeric keypad 1 key + Numpad2, // VK_NUMPAD2, 0x62, Numeric keypad 2 key + Numpad3, // VK_NUMPAD3, 0x63, Numeric keypad 3 key + Numpad4, // VK_NUMPAD4, 0x64, Numeric keypad 4 key + Numpad5, // VK_NUMPAD5, 0x65, Numeric keypad 5 key + Numpad6, // VK_NUMPAD6, 0x66, Numeric keypad 6 key + Numpad7, // VK_NUMPAD7, 0x67, Numeric keypad 7 key + Numpad8, // VK_NUMPAD8, 0x68, Numeric keypad 8 key + Numpad9, // VK_NUMPAD9, 0x69, Numeric keypad 9 key + + NumpadMultiply, // VK_MULTIPLY, 0x6A, Multiply key + NumpadAdd, // VK_ADD, 0x6B, Add key + NUMPAD_SEPARATOR, // VK_SEPARATOR, 0x6C, Separator key + NumpadSubtract, // VK_SUBTRACT, 0x6D, Subtract key + NumpadDecimal, // VK_DECIMAL, 0x6E, Decimal key + NumpadDivide, // VK_DIVIDE, 0x6F, + + /** + * Cover all key codes when IME is processing input. + */ + KEY_IN_COMPOSITION, + + ABNT_C1, // Brazilian (ABNT) Keyboard + ABNT_C2, // Brazilian (ABNT) Keyboard + + AudioVolumeMute, + AudioVolumeUp, + AudioVolumeDown, + + BrowserSearch, + BrowserHome, + BrowserBack, + BrowserForward, + + MediaTrackNext, + MediaTrackPrevious, + MediaStop, + MediaPlayPause, + LaunchMediaPlayer, + LaunchMail, + LaunchApp2, + + /** + * VK_CLEAR, 0x0C, CLEAR key + */ + Clear, + + /** + * Placed last to cover the length of the enum. + * Please do not depend on this value! + */ + MAX_VALUE +} + +/** + * keyboardEvent.code + */ +export const enum ScanCode { + DependsOnKbLayout = -1, + None, + Hyper, + Super, + Fn, + FnLock, + Suspend, + Resume, + Turbo, + Sleep, + WakeUp, + KeyA, + KeyB, + KeyC, + KeyD, + KeyE, + KeyF, + KeyG, + KeyH, + KeyI, + KeyJ, + KeyK, + KeyL, + KeyM, + KeyN, + KeyO, + KeyP, + KeyQ, + KeyR, + KeyS, + KeyT, + KeyU, + KeyV, + KeyW, + KeyX, + KeyY, + KeyZ, + Digit1, + Digit2, + Digit3, + Digit4, + Digit5, + Digit6, + Digit7, + Digit8, + Digit9, + Digit0, + Enter, + Escape, + Backspace, + Tab, + Space, + Minus, + Equal, + BracketLeft, + BracketRight, + Backslash, + IntlHash, + Semicolon, + Quote, + Backquote, + Comma, + Period, + Slash, + CapsLock, + F1, + F2, + F3, + F4, + F5, + F6, + F7, + F8, + F9, + F10, + F11, + F12, + PrintScreen, + ScrollLock, + Pause, + Insert, + Home, + PageUp, + Delete, + End, + PageDown, + ArrowRight, + ArrowLeft, + ArrowDown, + ArrowUp, + NumLock, + NumpadDivide, + NumpadMultiply, + NumpadSubtract, + NumpadAdd, + NumpadEnter, + Numpad1, + Numpad2, + Numpad3, + Numpad4, + Numpad5, + Numpad6, + Numpad7, + Numpad8, + Numpad9, + Numpad0, + NumpadDecimal, + IntlBackslash, + ContextMenu, + Power, + NumpadEqual, + F13, + F14, + F15, + F16, + F17, + F18, + F19, + F20, + F21, + F22, + F23, + F24, + Open, + Help, + Select, + Again, + Undo, + Cut, + Copy, + Paste, + Find, + AudioVolumeMute, + AudioVolumeUp, + AudioVolumeDown, + NumpadComma, + IntlRo, + KanaMode, + IntlYen, + Convert, + NonConvert, + Lang1, + Lang2, + Lang3, + Lang4, + Lang5, + Abort, + Props, + NumpadParenLeft, + NumpadParenRight, + NumpadBackspace, + NumpadMemoryStore, + NumpadMemoryRecall, + NumpadMemoryClear, + NumpadMemoryAdd, + NumpadMemorySubtract, + NumpadClear, + NumpadClearEntry, + ControlLeft, + ShiftLeft, + AltLeft, + MetaLeft, + ControlRight, + ShiftRight, + AltRight, + MetaRight, + BrightnessUp, + BrightnessDown, + MediaPlay, + MediaRecord, + MediaFastForward, + MediaRewind, + MediaTrackNext, + MediaTrackPrevious, + MediaStop, + Eject, + MediaPlayPause, + MediaSelect, + LaunchMail, + LaunchApp2, + LaunchApp1, + SelectTask, + LaunchScreenSaver, + BrowserSearch, + BrowserHome, + BrowserBack, + BrowserForward, + BrowserStop, + BrowserRefresh, + BrowserFavorites, + ZoomToggle, + MailReply, + MailForward, + MailSend, + + MAX_VALUE +} + +class KeyCodeStrMap { + + public _keyCodeToStr: string[]; + public _strToKeyCode: { [str: string]: KeyCode }; + + constructor() { + this._keyCodeToStr = []; + this._strToKeyCode = Object.create(null); + } + + define(keyCode: KeyCode, str: string): void { + this._keyCodeToStr[keyCode] = str; + this._strToKeyCode[str.toLowerCase()] = keyCode; + } + + keyCodeToStr(keyCode: KeyCode): string { + return this._keyCodeToStr[keyCode]; + } + + strToKeyCode(str: string): KeyCode { + return this._strToKeyCode[str.toLowerCase()] || KeyCode.Unknown; + } +} + +const uiMap = new KeyCodeStrMap(); +const userSettingsUSMap = new KeyCodeStrMap(); +const userSettingsGeneralMap = new KeyCodeStrMap(); +export const EVENT_KEY_CODE_MAP: { [keyCode: number]: KeyCode } = new Array(230); +export const NATIVE_WINDOWS_KEY_CODE_TO_KEY_CODE: { [nativeKeyCode: string]: KeyCode } = {}; +const scanCodeIntToStr: string[] = []; +const scanCodeStrToInt: { [code: string]: number } = Object.create(null); +const scanCodeLowerCaseStrToInt: { [code: string]: number } = Object.create(null); + +export const ScanCodeUtils = { + lowerCaseToEnum: (scanCode: string) => scanCodeLowerCaseStrToInt[scanCode] || ScanCode.None, + toEnum: (scanCode: string) => scanCodeStrToInt[scanCode] || ScanCode.None, + toString: (scanCode: ScanCode) => scanCodeIntToStr[scanCode] || 'None' +}; + + +export namespace KeyCodeUtils { + export function toString(keyCode: KeyCode): string { + return uiMap.keyCodeToStr(keyCode); + } + export function fromString(key: string): KeyCode { + return uiMap.strToKeyCode(key); + } + + export function toUserSettingsUS(keyCode: KeyCode): string { + return userSettingsUSMap.keyCodeToStr(keyCode); + } + export function toUserSettingsGeneral(keyCode: KeyCode): string { + return userSettingsGeneralMap.keyCodeToStr(keyCode); + } + export function fromUserSettings(key: string): KeyCode { + return userSettingsUSMap.strToKeyCode(key) || userSettingsGeneralMap.strToKeyCode(key); + } + + export function toElectronAccelerator(keyCode: KeyCode): string | null { + if (keyCode >= KeyCode.Numpad0 && keyCode <= KeyCode.NumpadDivide) { + // [Electron Accelerators] Electron is able to parse numpad keys, but unfortunately it + // renders them just as regular keys in menus. For example, num0 is rendered as "0", + // numdiv is rendered as "/", numsub is rendered as "-". + // + // This can lead to incredible confusion, as it makes numpad based keybindings indistinguishable + // from keybindings based on regular keys. + // + // We therefore need to fall back to custom rendering for numpad keys. + return null; + } + + switch (keyCode) { + case KeyCode.UpArrow: + return 'Up'; + case KeyCode.DownArrow: + return 'Down'; + case KeyCode.LeftArrow: + return 'Left'; + case KeyCode.RightArrow: + return 'Right'; + } + + return uiMap.keyCodeToStr(keyCode); + } +} + +export const enum KeyMod { + CtrlCmd = (1 << 11) >>> 0, + Shift = (1 << 10) >>> 0, + Alt = (1 << 9) >>> 0, + WinCtrl = (1 << 8) >>> 0, +} + +export function KeyChord(firstPart: number, secondPart: number): number { + const chordPart = ((secondPart & 0x0000FFFF) << 16) >>> 0; + return (firstPart | chordPart) >>> 0; +} diff --git a/src/vs/base/common/keybindingLabels.ts b/src/vs/base/common/keybindingLabels.ts new file mode 100644 index 0000000000..9eb9327359 --- /dev/null +++ b/src/vs/base/common/keybindingLabels.ts @@ -0,0 +1,184 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Modifiers } from 'vs/base/common/keybindings'; +import { OperatingSystem } from 'vs/base/common/platform'; +import * as nls from 'vs/nls'; + +export interface ModifierLabels { + readonly ctrlKey: string; + readonly shiftKey: string; + readonly altKey: string; + readonly metaKey: string; + readonly separator: string; +} + +export interface KeyLabelProvider { + (keybinding: T): string | null; +} + +export class ModifierLabelProvider { + + public readonly modifierLabels: ModifierLabels[]; + + constructor(mac: ModifierLabels, windows: ModifierLabels, linux: ModifierLabels = windows) { + this.modifierLabels = [null!]; // index 0 will never me accessed. + this.modifierLabels[OperatingSystem.Macintosh] = mac; + this.modifierLabels[OperatingSystem.Windows] = windows; + this.modifierLabels[OperatingSystem.Linux] = linux; + } + + public toLabel(OS: OperatingSystem, chords: readonly T[], keyLabelProvider: KeyLabelProvider): string | null { + if (chords.length === 0) { + return null; + } + + const result: string[] = []; + for (let i = 0, len = chords.length; i < len; i++) { + const chord = chords[i]; + const keyLabel = keyLabelProvider(chord); + if (keyLabel === null) { + // this keybinding cannot be expressed... + return null; + } + result[i] = _simpleAsString(chord, keyLabel, this.modifierLabels[OS]); + } + return result.join(' '); + } +} + +/** + * A label provider that prints modifiers in a suitable format for displaying in the UI. + */ +export const UILabelProvider = new ModifierLabelProvider( + { + ctrlKey: '\u2303', + shiftKey: '⇧', + altKey: '⌥', + metaKey: '⌘', + separator: '', + }, + { + ctrlKey: nls.localize({ key: 'ctrlKey', comment: ['This is the short form for the Control key on the keyboard'] }, "Ctrl"), + shiftKey: nls.localize({ key: 'shiftKey', comment: ['This is the short form for the Shift key on the keyboard'] }, "Shift"), + altKey: nls.localize({ key: 'altKey', comment: ['This is the short form for the Alt key on the keyboard'] }, "Alt"), + metaKey: nls.localize({ key: 'windowsKey', comment: ['This is the short form for the Windows key on the keyboard'] }, "Windows"), + separator: '+', + }, + { + ctrlKey: nls.localize({ key: 'ctrlKey', comment: ['This is the short form for the Control key on the keyboard'] }, "Ctrl"), + shiftKey: nls.localize({ key: 'shiftKey', comment: ['This is the short form for the Shift key on the keyboard'] }, "Shift"), + altKey: nls.localize({ key: 'altKey', comment: ['This is the short form for the Alt key on the keyboard'] }, "Alt"), + metaKey: nls.localize({ key: 'superKey', comment: ['This is the short form for the Super key on the keyboard'] }, "Super"), + separator: '+', + } +); + +/** + * A label provider that prints modifiers in a suitable format for ARIA. + */ +export const AriaLabelProvider = new ModifierLabelProvider( + { + ctrlKey: nls.localize({ key: 'ctrlKey.long', comment: ['This is the long form for the Control key on the keyboard'] }, "Control"), + shiftKey: nls.localize({ key: 'shiftKey.long', comment: ['This is the long form for the Shift key on the keyboard'] }, "Shift"), + altKey: nls.localize({ key: 'optKey.long', comment: ['This is the long form for the Alt/Option key on the keyboard'] }, "Option"), + metaKey: nls.localize({ key: 'cmdKey.long', comment: ['This is the long form for the Command key on the keyboard'] }, "Command"), + separator: '+', + }, + { + ctrlKey: nls.localize({ key: 'ctrlKey.long', comment: ['This is the long form for the Control key on the keyboard'] }, "Control"), + shiftKey: nls.localize({ key: 'shiftKey.long', comment: ['This is the long form for the Shift key on the keyboard'] }, "Shift"), + altKey: nls.localize({ key: 'altKey.long', comment: ['This is the long form for the Alt key on the keyboard'] }, "Alt"), + metaKey: nls.localize({ key: 'windowsKey.long', comment: ['This is the long form for the Windows key on the keyboard'] }, "Windows"), + separator: '+', + }, + { + ctrlKey: nls.localize({ key: 'ctrlKey.long', comment: ['This is the long form for the Control key on the keyboard'] }, "Control"), + shiftKey: nls.localize({ key: 'shiftKey.long', comment: ['This is the long form for the Shift key on the keyboard'] }, "Shift"), + altKey: nls.localize({ key: 'altKey.long', comment: ['This is the long form for the Alt key on the keyboard'] }, "Alt"), + metaKey: nls.localize({ key: 'superKey.long', comment: ['This is the long form for the Super key on the keyboard'] }, "Super"), + separator: '+', + } +); + +/** + * A label provider that prints modifiers in a suitable format for Electron Accelerators. + * See https://github.com/electron/electron/blob/master/docs/api/accelerator.md + */ +export const ElectronAcceleratorLabelProvider = new ModifierLabelProvider( + { + ctrlKey: 'Ctrl', + shiftKey: 'Shift', + altKey: 'Alt', + metaKey: 'Cmd', + separator: '+', + }, + { + ctrlKey: 'Ctrl', + shiftKey: 'Shift', + altKey: 'Alt', + metaKey: 'Super', + separator: '+', + } +); + +/** + * A label provider that prints modifiers in a suitable format for user settings. + */ +export const UserSettingsLabelProvider = new ModifierLabelProvider( + { + ctrlKey: 'ctrl', + shiftKey: 'shift', + altKey: 'alt', + metaKey: 'cmd', + separator: '+', + }, + { + ctrlKey: 'ctrl', + shiftKey: 'shift', + altKey: 'alt', + metaKey: 'win', + separator: '+', + }, + { + ctrlKey: 'ctrl', + shiftKey: 'shift', + altKey: 'alt', + metaKey: 'meta', + separator: '+', + } +); + +function _simpleAsString(modifiers: Modifiers, key: string, labels: ModifierLabels): string { + if (key === null) { + return ''; + } + + const result: string[] = []; + + // translate modifier keys: Ctrl-Shift-Alt-Meta + if (modifiers.ctrlKey) { + result.push(labels.ctrlKey); + } + + if (modifiers.shiftKey) { + result.push(labels.shiftKey); + } + + if (modifiers.altKey) { + result.push(labels.altKey); + } + + if (modifiers.metaKey) { + result.push(labels.metaKey); + } + + // the actual key + if (key !== '') { + result.push(key); + } + + return result.join(labels.separator); +} diff --git a/src/vs/base/common/keybindingParser.ts b/src/vs/base/common/keybindingParser.ts new file mode 100644 index 0000000000..3c36b8103f --- /dev/null +++ b/src/vs/base/common/keybindingParser.ts @@ -0,0 +1,102 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { KeyCodeUtils, ScanCodeUtils } from 'vs/base/common/keyCodes'; +import { KeyCodeChord, ScanCodeChord, Keybinding, Chord } from 'vs/base/common/keybindings'; + +export class KeybindingParser { + + private static _readModifiers(input: string) { + input = input.toLowerCase().trim(); + + let ctrl = false; + let shift = false; + let alt = false; + let meta = false; + + let matchedModifier: boolean; + + do { + matchedModifier = false; + if (/^ctrl(\+|\-)/.test(input)) { + ctrl = true; + input = input.substr('ctrl-'.length); + matchedModifier = true; + } + if (/^shift(\+|\-)/.test(input)) { + shift = true; + input = input.substr('shift-'.length); + matchedModifier = true; + } + if (/^alt(\+|\-)/.test(input)) { + alt = true; + input = input.substr('alt-'.length); + matchedModifier = true; + } + if (/^meta(\+|\-)/.test(input)) { + meta = true; + input = input.substr('meta-'.length); + matchedModifier = true; + } + if (/^win(\+|\-)/.test(input)) { + meta = true; + input = input.substr('win-'.length); + matchedModifier = true; + } + if (/^cmd(\+|\-)/.test(input)) { + meta = true; + input = input.substr('cmd-'.length); + matchedModifier = true; + } + } while (matchedModifier); + + let key: string; + + const firstSpaceIdx = input.indexOf(' '); + if (firstSpaceIdx > 0) { + key = input.substring(0, firstSpaceIdx); + input = input.substring(firstSpaceIdx); + } else { + key = input; + input = ''; + } + + return { + remains: input, + ctrl, + shift, + alt, + meta, + key + }; + } + + private static parseChord(input: string): [Chord, string] { + const mods = this._readModifiers(input); + const scanCodeMatch = mods.key.match(/^\[([^\]]+)\]$/); + if (scanCodeMatch) { + const strScanCode = scanCodeMatch[1]; + const scanCode = ScanCodeUtils.lowerCaseToEnum(strScanCode); + return [new ScanCodeChord(mods.ctrl, mods.shift, mods.alt, mods.meta, scanCode), mods.remains]; + } + const keyCode = KeyCodeUtils.fromUserSettings(mods.key); + return [new KeyCodeChord(mods.ctrl, mods.shift, mods.alt, mods.meta, keyCode), mods.remains]; + } + + static parseKeybinding(input: string): Keybinding | null { + if (!input) { + return null; + } + + const chords: Chord[] = []; + let chord: Chord; + + while (input.length > 0) { + [chord, input] = this.parseChord(input); + chords.push(chord); + } + return (chords.length > 0 ? new Keybinding(chords) : null); + } +} diff --git a/src/vs/base/common/keybindings.ts b/src/vs/base/common/keybindings.ts new file mode 100644 index 0000000000..375256237a --- /dev/null +++ b/src/vs/base/common/keybindings.ts @@ -0,0 +1,284 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { illegalArgument } from 'vs/base/common/errors'; +import { KeyCode, ScanCode } from 'vs/base/common/keyCodes'; +import { OperatingSystem } from 'vs/base/common/platform'; + +/** + * Binary encoding strategy: + * ``` + * 1111 11 + * 5432 1098 7654 3210 + * ---- CSAW KKKK KKKK + * C = bit 11 = ctrlCmd flag + * S = bit 10 = shift flag + * A = bit 9 = alt flag + * W = bit 8 = winCtrl flag + * K = bits 0-7 = key code + * ``` + */ +const enum BinaryKeybindingsMask { + CtrlCmd = (1 << 11) >>> 0, + Shift = (1 << 10) >>> 0, + Alt = (1 << 9) >>> 0, + WinCtrl = (1 << 8) >>> 0, + KeyCode = 0x000000FF +} + +export function decodeKeybinding(keybinding: number | number[], OS: OperatingSystem): Keybinding | null { + if (typeof keybinding === 'number') { + if (keybinding === 0) { + return null; + } + const firstChord = (keybinding & 0x0000FFFF) >>> 0; + const secondChord = (keybinding & 0xFFFF0000) >>> 16; + if (secondChord !== 0) { + return new Keybinding([ + createSimpleKeybinding(firstChord, OS), + createSimpleKeybinding(secondChord, OS) + ]); + } + return new Keybinding([createSimpleKeybinding(firstChord, OS)]); + } else { + const chords = []; + for (let i = 0; i < keybinding.length; i++) { + chords.push(createSimpleKeybinding(keybinding[i], OS)); + } + return new Keybinding(chords); + } +} + +export function createSimpleKeybinding(keybinding: number, OS: OperatingSystem): KeyCodeChord { + + const ctrlCmd = (keybinding & BinaryKeybindingsMask.CtrlCmd ? true : false); + const winCtrl = (keybinding & BinaryKeybindingsMask.WinCtrl ? true : false); + + const ctrlKey = (OS === OperatingSystem.Macintosh ? winCtrl : ctrlCmd); + const shiftKey = (keybinding & BinaryKeybindingsMask.Shift ? true : false); + const altKey = (keybinding & BinaryKeybindingsMask.Alt ? true : false); + const metaKey = (OS === OperatingSystem.Macintosh ? ctrlCmd : winCtrl); + const keyCode = (keybinding & BinaryKeybindingsMask.KeyCode); + + return new KeyCodeChord(ctrlKey, shiftKey, altKey, metaKey, keyCode); +} + +export interface Modifiers { + readonly ctrlKey: boolean; + readonly shiftKey: boolean; + readonly altKey: boolean; + readonly metaKey: boolean; +} + +/** + * Represents a chord which uses the `keyCode` field of keyboard events. + * A chord is a combination of keys pressed simultaneously. + */ +export class KeyCodeChord implements Modifiers { + + constructor( + public readonly ctrlKey: boolean, + public readonly shiftKey: boolean, + public readonly altKey: boolean, + public readonly metaKey: boolean, + public readonly keyCode: KeyCode + ) { } + + public equals(other: Chord): boolean { + return ( + other instanceof KeyCodeChord + && this.ctrlKey === other.ctrlKey + && this.shiftKey === other.shiftKey + && this.altKey === other.altKey + && this.metaKey === other.metaKey + && this.keyCode === other.keyCode + ); + } + + public getHashCode(): string { + const ctrl = this.ctrlKey ? '1' : '0'; + const shift = this.shiftKey ? '1' : '0'; + const alt = this.altKey ? '1' : '0'; + const meta = this.metaKey ? '1' : '0'; + return `K${ctrl}${shift}${alt}${meta}${this.keyCode}`; + } + + public isModifierKey(): boolean { + return ( + this.keyCode === KeyCode.Unknown + || this.keyCode === KeyCode.Ctrl + || this.keyCode === KeyCode.Meta + || this.keyCode === KeyCode.Alt + || this.keyCode === KeyCode.Shift + ); + } + + public toKeybinding(): Keybinding { + return new Keybinding([this]); + } + + /** + * Does this keybinding refer to the key code of a modifier and it also has the modifier flag? + */ + public isDuplicateModifierCase(): boolean { + return ( + (this.ctrlKey && this.keyCode === KeyCode.Ctrl) + || (this.shiftKey && this.keyCode === KeyCode.Shift) + || (this.altKey && this.keyCode === KeyCode.Alt) + || (this.metaKey && this.keyCode === KeyCode.Meta) + ); + } +} + +/** + * Represents a chord which uses the `code` field of keyboard events. + * A chord is a combination of keys pressed simultaneously. + */ +export class ScanCodeChord implements Modifiers { + + constructor( + public readonly ctrlKey: boolean, + public readonly shiftKey: boolean, + public readonly altKey: boolean, + public readonly metaKey: boolean, + public readonly scanCode: ScanCode + ) { } + + public equals(other: Chord): boolean { + return ( + other instanceof ScanCodeChord + && this.ctrlKey === other.ctrlKey + && this.shiftKey === other.shiftKey + && this.altKey === other.altKey + && this.metaKey === other.metaKey + && this.scanCode === other.scanCode + ); + } + + public getHashCode(): string { + const ctrl = this.ctrlKey ? '1' : '0'; + const shift = this.shiftKey ? '1' : '0'; + const alt = this.altKey ? '1' : '0'; + const meta = this.metaKey ? '1' : '0'; + return `S${ctrl}${shift}${alt}${meta}${this.scanCode}`; + } + + /** + * Does this keybinding refer to the key code of a modifier and it also has the modifier flag? + */ + public isDuplicateModifierCase(): boolean { + return ( + (this.ctrlKey && (this.scanCode === ScanCode.ControlLeft || this.scanCode === ScanCode.ControlRight)) + || (this.shiftKey && (this.scanCode === ScanCode.ShiftLeft || this.scanCode === ScanCode.ShiftRight)) + || (this.altKey && (this.scanCode === ScanCode.AltLeft || this.scanCode === ScanCode.AltRight)) + || (this.metaKey && (this.scanCode === ScanCode.MetaLeft || this.scanCode === ScanCode.MetaRight)) + ); + } +} + +export type Chord = KeyCodeChord | ScanCodeChord; + +/** + * A keybinding is a sequence of chords. + */ +export class Keybinding { + + public readonly chords: Chord[]; + + constructor(chords: Chord[]) { + if (chords.length === 0) { + throw illegalArgument(`chords`); + } + this.chords = chords; + } + + public getHashCode(): string { + let result = ''; + for (let i = 0, len = this.chords.length; i < len; i++) { + if (i !== 0) { + result += ';'; + } + result += this.chords[i].getHashCode(); + } + return result; + } + + public equals(other: Keybinding | null): boolean { + if (other === null) { + return false; + } + if (this.chords.length !== other.chords.length) { + return false; + } + for (let i = 0; i < this.chords.length; i++) { + if (!this.chords[i].equals(other.chords[i])) { + return false; + } + } + return true; + } +} + +export class ResolvedChord { + constructor( + public readonly ctrlKey: boolean, + public readonly shiftKey: boolean, + public readonly altKey: boolean, + public readonly metaKey: boolean, + public readonly keyLabel: string | null, + public readonly keyAriaLabel: string | null + ) { } +} + +export type SingleModifierChord = 'ctrl' | 'shift' | 'alt' | 'meta'; + +/** + * A resolved keybinding. Consists of one or multiple chords. + */ +export abstract class ResolvedKeybinding { + /** + * This prints the binding in a format suitable for displaying in the UI. + */ + public abstract getLabel(): string | null; + /** + * This prints the binding in a format suitable for ARIA. + */ + public abstract getAriaLabel(): string | null; + /** + * This prints the binding in a format suitable for electron's accelerators. + * See https://github.com/electron/electron/blob/master/docs/api/accelerator.md + */ + public abstract getElectronAccelerator(): string | null; + /** + * This prints the binding in a format suitable for user settings. + */ + public abstract getUserSettingsLabel(): string | null; + /** + * Is the user settings label reflecting the label? + */ + public abstract isWYSIWYG(): boolean; + /** + * Does the keybinding consist of more than one chord? + */ + public abstract hasMultipleChords(): boolean; + /** + * Returns the chords that comprise of the keybinding. + */ + public abstract getChords(): ResolvedChord[]; + /** + * Returns the chords as strings useful for dispatching. + * Returns null for modifier only chords. + * @example keybinding "Shift" -> null + * @example keybinding ("D" with shift == true) -> "shift+D" + */ + public abstract getDispatchChords(): (string | null)[]; + /** + * Returns the modifier only chords as strings useful for dispatching. + * Returns null for chords that contain more than one modifier or a regular key. + * @example keybinding "Shift" -> "shift" + * @example keybinding ("D" with shift == true") -> null + */ + public abstract getSingleModifierDispatchChords(): (SingleModifierChord | null)[]; +} diff --git a/src/vs/base/common/lazy.ts b/src/vs/base/common/lazy.ts new file mode 100644 index 0000000000..7114ece99b --- /dev/null +++ b/src/vs/base/common/lazy.ts @@ -0,0 +1,47 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export class Lazy { + + private _didRun: boolean = false; + private _value?: T; + private _error: Error | undefined; + + constructor( + private readonly executor: () => T, + ) { } + + /** + * True if the lazy value has been resolved. + */ + get hasValue() { return this._didRun; } + + /** + * Get the wrapped value. + * + * This will force evaluation of the lazy value if it has not been resolved yet. Lazy values are only + * resolved once. `getValue` will re-throw exceptions that are hit while resolving the value + */ + get value(): T { + if (!this._didRun) { + try { + this._value = this.executor(); + } catch (err) { + this._error = err; + } finally { + this._didRun = true; + } + } + if (this._error) { + throw this._error; + } + return this._value!; + } + + /** + * Get the wrapped value without forcing evaluation. + */ + get rawValue(): T | undefined { return this._value; } +} diff --git a/src/vs/base/common/lifecycle.ts b/src/vs/base/common/lifecycle.ts new file mode 100644 index 0000000000..568a0124c1 --- /dev/null +++ b/src/vs/base/common/lifecycle.ts @@ -0,0 +1,801 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { compareBy, numberComparator } from 'vs/base/common/arrays'; +import { groupBy } from 'vs/base/common/collections'; +import { SetMap } from './map'; +import { createSingleCallFunction } from 'vs/base/common/functional'; +import { Iterable } from 'vs/base/common/iterator'; + +// #region Disposable Tracking + +/** + * Enables logging of potentially leaked disposables. + * + * A disposable is considered leaked if it is not disposed or not registered as the child of + * another disposable. This tracking is very simple an only works for classes that either + * extend Disposable or use a DisposableStore. This means there are a lot of false positives. + */ +const TRACK_DISPOSABLES = false; +let disposableTracker: IDisposableTracker | null = null; + +export interface IDisposableTracker { + /** + * Is called on construction of a disposable. + */ + trackDisposable(disposable: IDisposable): void; + + /** + * Is called when a disposable is registered as child of another disposable (e.g. {@link DisposableStore}). + * If parent is `null`, the disposable is removed from its former parent. + */ + setParent(child: IDisposable, parent: IDisposable | null): void; + + /** + * Is called after a disposable is disposed. + */ + markAsDisposed(disposable: IDisposable): void; + + /** + * Indicates that the given object is a singleton which does not need to be disposed. + */ + markAsSingleton(disposable: IDisposable): void; +} + +export interface DisposableInfo { + value: IDisposable; + source: string | null; + parent: IDisposable | null; + isSingleton: boolean; + idx: number; +} + +export class DisposableTracker implements IDisposableTracker { + private static idx = 0; + + private readonly livingDisposables = new Map(); + + private getDisposableData(d: IDisposable): DisposableInfo { + let val = this.livingDisposables.get(d); + if (!val) { + val = { parent: null, source: null, isSingleton: false, value: d, idx: DisposableTracker.idx++ }; + this.livingDisposables.set(d, val); + } + return val; + } + + trackDisposable(d: IDisposable): void { + const data = this.getDisposableData(d); + if (!data.source) { + data.source = + new Error().stack!; + } + } + + setParent(child: IDisposable, parent: IDisposable | null): void { + const data = this.getDisposableData(child); + data.parent = parent; + } + + markAsDisposed(x: IDisposable): void { + this.livingDisposables.delete(x); + } + + markAsSingleton(disposable: IDisposable): void { + this.getDisposableData(disposable).isSingleton = true; + } + + private getRootParent(data: DisposableInfo, cache: Map): DisposableInfo { + const cacheValue = cache.get(data); + if (cacheValue) { + return cacheValue; + } + + const result = data.parent ? this.getRootParent(this.getDisposableData(data.parent), cache) : data; + cache.set(data, result); + return result; + } + + getTrackedDisposables(): IDisposable[] { + const rootParentCache = new Map(); + + const leaking = [...this.livingDisposables.entries()] + .filter(([, v]) => v.source !== null && !this.getRootParent(v, rootParentCache).isSingleton) + .flatMap(([k]) => k); + + return leaking; + } + + computeLeakingDisposables(maxReported = 10, preComputedLeaks?: DisposableInfo[]): { leaks: DisposableInfo[]; details: string } | undefined { + let uncoveredLeakingObjs: DisposableInfo[] | undefined; + if (preComputedLeaks) { + uncoveredLeakingObjs = preComputedLeaks; + } else { + const rootParentCache = new Map(); + + const leakingObjects = [...this.livingDisposables.values()] + .filter((info) => info.source !== null && !this.getRootParent(info, rootParentCache).isSingleton); + + if (leakingObjects.length === 0) { + return; + } + const leakingObjsSet = new Set(leakingObjects.map(o => o.value)); + + // Remove all objects that are a child of other leaking objects. Assumes there are no cycles. + uncoveredLeakingObjs = leakingObjects.filter(l => { + return !(l.parent && leakingObjsSet.has(l.parent)); + }); + + if (uncoveredLeakingObjs.length === 0) { + throw new Error('There are cyclic diposable chains!'); + } + } + + if (!uncoveredLeakingObjs) { + return undefined; + } + + function getStackTracePath(leaking: DisposableInfo): string[] { + function removePrefix(array: string[], linesToRemove: (string | RegExp)[]) { + while (array.length > 0 && linesToRemove.some(regexp => typeof regexp === 'string' ? regexp === array[0] : array[0].match(regexp))) { + array.shift(); + } + } + + const lines = leaking.source!.split('\n').map(p => p.trim().replace('at ', '')).filter(l => l !== ''); + removePrefix(lines, ['Error', /^trackDisposable \(.*\)$/, /^DisposableTracker.trackDisposable \(.*\)$/]); + return lines.reverse(); + } + + const stackTraceStarts = new SetMap(); + for (const leaking of uncoveredLeakingObjs) { + const stackTracePath = getStackTracePath(leaking); + for (let i = 0; i <= stackTracePath.length; i++) { + stackTraceStarts.add(stackTracePath.slice(0, i).join('\n'), leaking); + } + } + + // Put earlier leaks first + uncoveredLeakingObjs.sort(compareBy(l => l.idx, numberComparator)); + + let message = ''; + + let i = 0; + for (const leaking of uncoveredLeakingObjs.slice(0, maxReported)) { + i++; + const stackTracePath = getStackTracePath(leaking); + const stackTraceFormattedLines = []; + + for (let i = 0; i < stackTracePath.length; i++) { + let line = stackTracePath[i]; + const starts = stackTraceStarts.get(stackTracePath.slice(0, i + 1).join('\n')); + line = `(shared with ${starts.size}/${uncoveredLeakingObjs.length} leaks) at ${line}`; + + const prevStarts = stackTraceStarts.get(stackTracePath.slice(0, i).join('\n')); + const continuations = groupBy([...prevStarts].map(d => getStackTracePath(d)[i]), v => v); + delete continuations[stackTracePath[i]]; + for (const [cont, set] of Object.entries(continuations)) { + stackTraceFormattedLines.unshift(` - stacktraces of ${set.length} other leaks continue with ${cont}`); + } + + stackTraceFormattedLines.unshift(line); + } + + message += `\n\n\n==================== Leaking disposable ${i}/${uncoveredLeakingObjs.length}: ${leaking.value.constructor.name} ====================\n${stackTraceFormattedLines.join('\n')}\n============================================================\n\n`; + } + + if (uncoveredLeakingObjs.length > maxReported) { + message += `\n\n\n... and ${uncoveredLeakingObjs.length - maxReported} more leaking disposables\n\n`; + } + + return { leaks: uncoveredLeakingObjs, details: message }; + } +} + +export function setDisposableTracker(tracker: IDisposableTracker | null): void { + disposableTracker = tracker; +} + +if (TRACK_DISPOSABLES) { + const __is_disposable_tracked__ = '__is_disposable_tracked__'; + setDisposableTracker(new class implements IDisposableTracker { + trackDisposable(x: IDisposable): void { + const stack = new Error('Potentially leaked disposable').stack!; + setTimeout(() => { + if (!(x as any)[__is_disposable_tracked__]) { + console.log(stack); + } + }, 3000); + } + + setParent(child: IDisposable, parent: IDisposable | null): void { + if (child && child !== Disposable.None) { + try { + (child as any)[__is_disposable_tracked__] = true; + } catch { + // noop + } + } + } + + markAsDisposed(disposable: IDisposable): void { + if (disposable && disposable !== Disposable.None) { + try { + (disposable as any)[__is_disposable_tracked__] = true; + } catch { + // noop + } + } + } + markAsSingleton(disposable: IDisposable): void { } + }); +} + +export function trackDisposable(x: T): T { + disposableTracker?.trackDisposable(x); + return x; +} + +export function markAsDisposed(disposable: IDisposable): void { + disposableTracker?.markAsDisposed(disposable); +} + +function setParentOfDisposable(child: IDisposable, parent: IDisposable | null): void { + disposableTracker?.setParent(child, parent); +} + +function setParentOfDisposables(children: IDisposable[], parent: IDisposable | null): void { + if (!disposableTracker) { + return; + } + for (const child of children) { + disposableTracker.setParent(child, parent); + } +} + +/** + * Indicates that the given object is a singleton which does not need to be disposed. +*/ +export function markAsSingleton(singleton: T): T { + disposableTracker?.markAsSingleton(singleton); + return singleton; +} + +// #endregion + +/** + * An object that performs a cleanup operation when `.dispose()` is called. + * + * Some examples of how disposables are used: + * + * - An event listener that removes itself when `.dispose()` is called. + * - A resource such as a file system watcher that cleans up the resource when `.dispose()` is called. + * - The return value from registering a provider. When `.dispose()` is called, the provider is unregistered. + */ +export interface IDisposable { + dispose(): void; +} + +/** + * Check if `thing` is {@link IDisposable disposable}. + */ +export function isDisposable(thing: E): thing is E & IDisposable { + return typeof thing === 'object' && thing !== null && typeof (thing).dispose === 'function' && (thing).dispose.length === 0; +} + +/** + * Disposes of the value(s) passed in. + */ +export function dispose(disposable: T): T; +export function dispose(disposable: T | undefined): T | undefined; +export function dispose = Iterable>(disposables: A): A; +export function dispose(disposables: Array): Array; +export function dispose(disposables: ReadonlyArray): ReadonlyArray; +export function dispose(arg: T | Iterable | undefined): any { + if (Iterable.is(arg)) { + const errors: any[] = []; + + for (const d of arg) { + if (d) { + try { + d.dispose(); + } catch (e) { + errors.push(e); + } + } + } + + if (errors.length === 1) { + throw errors[0]; + } else if (errors.length > 1) { + throw new AggregateError(errors, 'Encountered errors while disposing of store'); + } + + return Array.isArray(arg) ? [] : arg; + } else if (arg) { + arg.dispose(); + return arg; + } +} + +export function disposeIfDisposable(disposables: Array): Array { + for (const d of disposables) { + if (isDisposable(d)) { + d.dispose(); + } + } + return []; +} + +/** + * Combine multiple disposable values into a single {@link IDisposable}. + */ +export function combinedDisposable(...disposables: IDisposable[]): IDisposable { + const parent = toDisposable(() => dispose(disposables)); + setParentOfDisposables(disposables, parent); + return parent; +} + +/** + * Turn a function that implements dispose into an {@link IDisposable}. + * + * @param fn Clean up function, guaranteed to be called only **once**. + */ +export function toDisposable(fn: () => void): IDisposable { + const self = trackDisposable({ + dispose: createSingleCallFunction(() => { + markAsDisposed(self); + fn(); + }) + }); + return self; +} + +/** + * Manages a collection of disposable values. + * + * This is the preferred way to manage multiple disposables. A `DisposableStore` is safer to work with than an + * `IDisposable[]` as it considers edge cases, such as registering the same value multiple times or adding an item to a + * store that has already been disposed of. + */ +export class DisposableStore implements IDisposable { + + static DISABLE_DISPOSED_WARNING = false; + + private readonly _toDispose = new Set(); + private _isDisposed = false; + + constructor() { + trackDisposable(this); + } + + /** + * Dispose of all registered disposables and mark this object as disposed. + * + * Any future disposables added to this object will be disposed of on `add`. + */ + public dispose(): void { + if (this._isDisposed) { + return; + } + + markAsDisposed(this); + this._isDisposed = true; + this.clear(); + } + + /** + * @return `true` if this object has been disposed of. + */ + public get isDisposed(): boolean { + return this._isDisposed; + } + + /** + * Dispose of all registered disposables but do not mark this object as disposed. + */ + public clear(): void { + if (this._toDispose.size === 0) { + return; + } + + try { + dispose(this._toDispose); + } finally { + this._toDispose.clear(); + } + } + + /** + * Add a new {@link IDisposable disposable} to the collection. + */ + public add(o: T): T { + if (!o) { + return o; + } + if ((o as unknown as DisposableStore) === this) { + throw new Error('Cannot register a disposable on itself!'); + } + + setParentOfDisposable(o, this); + if (this._isDisposed) { + if (!DisposableStore.DISABLE_DISPOSED_WARNING) { + console.warn(new Error('Trying to add a disposable to a DisposableStore that has already been disposed of. The added object will be leaked!').stack); + } + } else { + this._toDispose.add(o); + } + + return o; + } + + /** + * Deletes a disposable from store and disposes of it. This will not throw or warn and proceed to dispose the + * disposable even when the disposable is not part in the store. + */ + public delete(o: T): void { + if (!o) { + return; + } + if ((o as unknown as DisposableStore) === this) { + throw new Error('Cannot dispose a disposable on itself!'); + } + this._toDispose.delete(o); + o.dispose(); + } + + /** + * Deletes the value from the store, but does not dispose it. + */ + public deleteAndLeak(o: T): void { + if (!o) { + return; + } + if (this._toDispose.has(o)) { + this._toDispose.delete(o); + setParentOfDisposable(o, null); + } + } +} + +/** + * Abstract base class for a {@link IDisposable disposable} object. + * + * Subclasses can {@linkcode _register} disposables that will be automatically cleaned up when this object is disposed of. + */ +export abstract class Disposable implements IDisposable { + + /** + * A disposable that does nothing when it is disposed of. + * + * TODO: This should not be a static property. + */ + static readonly None = Object.freeze({ dispose() { } }); + + protected readonly _store = new DisposableStore(); + + constructor() { + trackDisposable(this); + setParentOfDisposable(this._store, this); + } + + public dispose(): void { + markAsDisposed(this); + + this._store.dispose(); + } + + /** + * Adds `o` to the collection of disposables managed by this object. + */ + protected _register(o: T): T { + if ((o as unknown as Disposable) === this) { + throw new Error('Cannot register a disposable on itself!'); + } + return this._store.add(o); + } +} + +/** + * Manages the lifecycle of a disposable value that may be changed. + * + * This ensures that when the disposable value is changed, the previously held disposable is disposed of. You can + * also register a `MutableDisposable` on a `Disposable` to ensure it is automatically cleaned up. + */ +export class MutableDisposable implements IDisposable { + private _value?: T; + private _isDisposed = false; + + constructor() { + trackDisposable(this); + } + + get value(): T | undefined { + return this._isDisposed ? undefined : this._value; + } + + set value(value: T | undefined) { + if (this._isDisposed || value === this._value) { + return; + } + + this._value?.dispose(); + if (value) { + setParentOfDisposable(value, this); + } + this._value = value; + } + + /** + * Resets the stored value and disposed of the previously stored value. + */ + clear(): void { + this.value = undefined; + } + + dispose(): void { + this._isDisposed = true; + markAsDisposed(this); + this._value?.dispose(); + this._value = undefined; + } + + /** + * Clears the value, but does not dispose it. + * The old value is returned. + */ + clearAndLeak(): T | undefined { + const oldValue = this._value; + this._value = undefined; + if (oldValue) { + setParentOfDisposable(oldValue, null); + } + return oldValue; + } +} + +/** + * Manages the lifecycle of a disposable value that may be changed like {@link MutableDisposable}, but the value must + * exist and cannot be undefined. + */ +export class MandatoryMutableDisposable implements IDisposable { + private readonly _disposable = new MutableDisposable(); + private _isDisposed = false; + + constructor(initialValue: T) { + this._disposable.value = initialValue; + } + + get value(): T { + return this._disposable.value!; + } + + set value(value: T) { + if (this._isDisposed || value === this._disposable.value) { + return; + } + this._disposable.value = value; + } + + dispose() { + this._isDisposed = true; + this._disposable.dispose(); + } +} + +export class RefCountedDisposable { + + private _counter: number = 1; + + constructor( + private readonly _disposable: IDisposable, + ) { } + + acquire() { + this._counter++; + return this; + } + + release() { + if (--this._counter === 0) { + this._disposable.dispose(); + } + return this; + } +} + +/** + * A safe disposable can be `unset` so that a leaked reference (listener) + * can be cut-off. + */ +export class SafeDisposable implements IDisposable { + + dispose: () => void = () => { }; + unset: () => void = () => { }; + isset: () => boolean = () => false; + + constructor() { + trackDisposable(this); + } + + set(fn: Function) { + let callback: Function | undefined = fn; + this.unset = () => callback = undefined; + this.isset = () => callback !== undefined; + this.dispose = () => { + if (callback) { + callback(); + callback = undefined; + markAsDisposed(this); + } + }; + return this; + } +} + +export interface IReference extends IDisposable { + readonly object: T; +} + +export abstract class ReferenceCollection { + + private readonly references: Map = new Map(); + + acquire(key: string, ...args: any[]): IReference { + let reference = this.references.get(key); + + if (!reference) { + reference = { counter: 0, object: this.createReferencedObject(key, ...args) }; + this.references.set(key, reference); + } + + const { object } = reference; + const dispose = createSingleCallFunction(() => { + if (--reference.counter === 0) { + this.destroyReferencedObject(key, reference.object); + this.references.delete(key); + } + }); + + reference.counter++; + + return { object, dispose }; + } + + protected abstract createReferencedObject(key: string, ...args: any[]): T; + protected abstract destroyReferencedObject(key: string, object: T): void; +} + +/** + * Unwraps a reference collection of promised values. Makes sure + * references are disposed whenever promises get rejected. + */ +export class AsyncReferenceCollection { + + constructor(private referenceCollection: ReferenceCollection>) { } + + async acquire(key: string, ...args: any[]): Promise> { + const ref = this.referenceCollection.acquire(key, ...args); + + try { + const object = await ref.object; + + return { + object, + dispose: () => ref.dispose() + }; + } catch (error) { + ref.dispose(); + throw error; + } + } +} + +export class ImmortalReference implements IReference { + constructor(public object: T) { } + dispose(): void { /* noop */ } +} + +export function disposeOnReturn(fn: (store: DisposableStore) => void): void { + const store = new DisposableStore(); + try { + fn(store); + } finally { + store.dispose(); + } +} + +/** + * A map the manages the lifecycle of the values that it stores. + */ +export class DisposableMap implements IDisposable { + + private readonly _store = new Map(); + private _isDisposed = false; + + constructor() { + trackDisposable(this); + } + + /** + * Disposes of all stored values and mark this object as disposed. + * + * Trying to use this object after it has been disposed of is an error. + */ + dispose(): void { + markAsDisposed(this); + this._isDisposed = true; + this.clearAndDisposeAll(); + } + + /** + * Disposes of all stored values and clear the map, but DO NOT mark this object as disposed. + */ + clearAndDisposeAll(): void { + if (!this._store.size) { + return; + } + + try { + dispose(this._store.values()); + } finally { + this._store.clear(); + } + } + + has(key: K): boolean { + return this._store.has(key); + } + + get size(): number { + return this._store.size; + } + + get(key: K): V | undefined { + return this._store.get(key); + } + + set(key: K, value: V, skipDisposeOnOverwrite = false): void { + if (this._isDisposed) { + console.warn(new Error('Trying to add a disposable to a DisposableMap that has already been disposed of. The added object will be leaked!').stack); + } + + if (!skipDisposeOnOverwrite) { + this._store.get(key)?.dispose(); + } + + this._store.set(key, value); + } + + /** + * Delete the value stored for `key` from this map and also dispose of it. + */ + deleteAndDispose(key: K): void { + this._store.get(key)?.dispose(); + this._store.delete(key); + } + + /** + * Delete the value stored for `key` from this map but return it. The caller is + * responsible for disposing of the value. + */ + deleteAndLeak(key: K): V | undefined { + const value = this._store.get(key); + this._store.delete(key); + return value; + } + + keys(): IterableIterator { + return this._store.keys(); + } + + values(): IterableIterator { + return this._store.values(); + } + + [Symbol.iterator](): IterableIterator<[K, V]> { + return this._store[Symbol.iterator](); + } +} diff --git a/src/vs/base/common/linkedList.ts b/src/vs/base/common/linkedList.ts new file mode 100644 index 0000000000..42a1c2aad9 --- /dev/null +++ b/src/vs/base/common/linkedList.ts @@ -0,0 +1,142 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +class Node { + + static readonly Undefined = new Node(undefined); + + element: E; + next: Node; + prev: Node; + + constructor(element: E) { + this.element = element; + this.next = Node.Undefined; + this.prev = Node.Undefined; + } +} + +export class LinkedList { + + private _first: Node = Node.Undefined; + private _last: Node = Node.Undefined; + private _size: number = 0; + + get size(): number { + return this._size; + } + + isEmpty(): boolean { + return this._first === Node.Undefined; + } + + clear(): void { + let node = this._first; + while (node !== Node.Undefined) { + const next = node.next; + node.prev = Node.Undefined; + node.next = Node.Undefined; + node = next; + } + + this._first = Node.Undefined; + this._last = Node.Undefined; + this._size = 0; + } + + unshift(element: E): () => void { + return this._insert(element, false); + } + + push(element: E): () => void { + return this._insert(element, true); + } + + private _insert(element: E, atTheEnd: boolean): () => void { + const newNode = new Node(element); + if (this._first === Node.Undefined) { + this._first = newNode; + this._last = newNode; + + } else if (atTheEnd) { + // push + const oldLast = this._last; + this._last = newNode; + newNode.prev = oldLast; + oldLast.next = newNode; + + } else { + // unshift + const oldFirst = this._first; + this._first = newNode; + newNode.next = oldFirst; + oldFirst.prev = newNode; + } + this._size += 1; + + let didRemove = false; + return () => { + if (!didRemove) { + didRemove = true; + this._remove(newNode); + } + }; + } + + shift(): E | undefined { + if (this._first === Node.Undefined) { + return undefined; + } else { + const res = this._first.element; + this._remove(this._first); + return res; + } + } + + pop(): E | undefined { + if (this._last === Node.Undefined) { + return undefined; + } else { + const res = this._last.element; + this._remove(this._last); + return res; + } + } + + private _remove(node: Node): void { + if (node.prev !== Node.Undefined && node.next !== Node.Undefined) { + // middle + const anchor = node.prev; + anchor.next = node.next; + node.next.prev = anchor; + + } else if (node.prev === Node.Undefined && node.next === Node.Undefined) { + // only node + this._first = Node.Undefined; + this._last = Node.Undefined; + + } else if (node.next === Node.Undefined) { + // last + this._last = this._last.prev!; + this._last.next = Node.Undefined; + + } else if (node.prev === Node.Undefined) { + // first + this._first = this._first.next!; + this._first.prev = Node.Undefined; + } + + // done + this._size -= 1; + } + + *[Symbol.iterator](): Iterator { + let node = this._first; + while (node !== Node.Undefined) { + yield node.element; + node = node.next; + } + } +} diff --git a/src/vs/base/common/linkedText.ts b/src/vs/base/common/linkedText.ts new file mode 100644 index 0000000000..89f6da527d --- /dev/null +++ b/src/vs/base/common/linkedText.ts @@ -0,0 +1,55 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { memoize } from 'vs/base/common/decorators'; + +export interface ILink { + readonly label: string; + readonly href: string; + readonly title?: string; +} + +export type LinkedTextNode = string | ILink; + +export class LinkedText { + + constructor(readonly nodes: LinkedTextNode[]) { } + + @memoize + toString(): string { + return this.nodes.map(node => typeof node === 'string' ? node : node.label).join(''); + } +} + +const LINK_REGEX = /\[([^\]]+)\]\(((?:https?:\/\/|command:|file:)[^\)\s]+)(?: (["'])(.+?)(\3))?\)/gi; + +export function parseLinkedText(text: string): LinkedText { + const result: LinkedTextNode[] = []; + + let index = 0; + let match: RegExpExecArray | null; + + while (match = LINK_REGEX.exec(text)) { + if (match.index - index > 0) { + result.push(text.substring(index, match.index)); + } + + const [, label, href, , title] = match; + + if (title) { + result.push({ label, href, title }); + } else { + result.push({ label, href }); + } + + index = match.index + match[0].length; + } + + if (index < text.length) { + result.push(text.substring(index)); + } + + return new LinkedText(result); +} diff --git a/src/vs/base/common/map.ts b/src/vs/base/common/map.ts new file mode 100644 index 0000000000..5aa55f4842 --- /dev/null +++ b/src/vs/base/common/map.ts @@ -0,0 +1,202 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export function getOrSet(map: Map, key: K, value: V): V { + let result = map.get(key); + if (result === undefined) { + result = value; + map.set(key, result); + } + + return result; +} + +export function mapToString(map: Map): string { + const entries: string[] = []; + map.forEach((value, key) => { + entries.push(`${key} => ${value}`); + }); + + return `Map(${map.size}) {${entries.join(', ')}}`; +} + +export function setToString(set: Set): string { + const entries: K[] = []; + set.forEach(value => { + entries.push(value); + }); + + return `Set(${set.size}) {${entries.join(', ')}}`; +} + +export const enum Touch { + None = 0, + AsOld = 1, + AsNew = 2 +} + +export class CounterSet { + + private map = new Map(); + + add(value: T): CounterSet { + this.map.set(value, (this.map.get(value) || 0) + 1); + return this; + } + + delete(value: T): boolean { + let counter = this.map.get(value) || 0; + + if (counter === 0) { + return false; + } + + counter--; + + if (counter === 0) { + this.map.delete(value); + } else { + this.map.set(value, counter); + } + + return true; + } + + has(value: T): boolean { + return this.map.has(value); + } +} + +/** + * A map that allows access both by keys and values. + * **NOTE**: values need to be unique. + */ +export class BidirectionalMap { + + private readonly _m1 = new Map(); + private readonly _m2 = new Map(); + + constructor(entries?: readonly (readonly [K, V])[]) { + if (entries) { + for (const [key, value] of entries) { + this.set(key, value); + } + } + } + + clear(): void { + this._m1.clear(); + this._m2.clear(); + } + + set(key: K, value: V): void { + this._m1.set(key, value); + this._m2.set(value, key); + } + + get(key: K): V | undefined { + return this._m1.get(key); + } + + getKey(value: V): K | undefined { + return this._m2.get(value); + } + + delete(key: K): boolean { + const value = this._m1.get(key); + if (value === undefined) { + return false; + } + this._m1.delete(key); + this._m2.delete(value); + return true; + } + + forEach(callbackfn: (value: V, key: K, map: BidirectionalMap) => void, thisArg?: any): void { + this._m1.forEach((value, key) => { + callbackfn.call(thisArg, value, key, this); + }); + } + + keys(): IterableIterator { + return this._m1.keys(); + } + + values(): IterableIterator { + return this._m1.values(); + } +} + +export class SetMap { + + private map = new Map>(); + + add(key: K, value: V): void { + let values = this.map.get(key); + + if (!values) { + values = new Set(); + this.map.set(key, values); + } + + values.add(value); + } + + delete(key: K, value: V): void { + const values = this.map.get(key); + + if (!values) { + return; + } + + values.delete(value); + + if (values.size === 0) { + this.map.delete(key); + } + } + + forEach(key: K, fn: (value: V) => void): void { + const values = this.map.get(key); + + if (!values) { + return; + } + + values.forEach(fn); + } + + get(key: K): ReadonlySet { + const values = this.map.get(key); + if (!values) { + return new Set(); + } + return values; + } +} + +export function mapsStrictEqualIgnoreOrder(a: Map, b: Map): boolean { + if (a === b) { + return true; + } + + if (a.size !== b.size) { + return false; + } + + for (const [key, value] of a) { + if (!b.has(key) || b.get(key) !== value) { + return false; + } + } + + for (const [key] of b) { + if (!a.has(key)) { + return false; + } + } + + return true; +} diff --git a/src/vs/base/common/mime.ts b/src/vs/base/common/mime.ts new file mode 100644 index 0000000000..288a8daf33 --- /dev/null +++ b/src/vs/base/common/mime.ts @@ -0,0 +1,126 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { extname } from 'vs/base/common/path'; + +export const Mimes = Object.freeze({ + text: 'text/plain', + binary: 'application/octet-stream', + unknown: 'application/unknown', + markdown: 'text/markdown', + latex: 'text/latex', + uriList: 'text/uri-list', +}); + +interface MapExtToMediaMimes { + [index: string]: string; +} + +const mapExtToTextMimes: MapExtToMediaMimes = { + '.css': 'text/css', + '.csv': 'text/csv', + '.htm': 'text/html', + '.html': 'text/html', + '.ics': 'text/calendar', + '.js': 'text/javascript', + '.mjs': 'text/javascript', + '.txt': 'text/plain', + '.xml': 'text/xml' +}; + +// Known media mimes that we can handle +const mapExtToMediaMimes: MapExtToMediaMimes = { + '.aac': 'audio/x-aac', + '.avi': 'video/x-msvideo', + '.bmp': 'image/bmp', + '.flv': 'video/x-flv', + '.gif': 'image/gif', + '.ico': 'image/x-icon', + '.jpe': 'image/jpg', + '.jpeg': 'image/jpg', + '.jpg': 'image/jpg', + '.m1v': 'video/mpeg', + '.m2a': 'audio/mpeg', + '.m2v': 'video/mpeg', + '.m3a': 'audio/mpeg', + '.mid': 'audio/midi', + '.midi': 'audio/midi', + '.mk3d': 'video/x-matroska', + '.mks': 'video/x-matroska', + '.mkv': 'video/x-matroska', + '.mov': 'video/quicktime', + '.movie': 'video/x-sgi-movie', + '.mp2': 'audio/mpeg', + '.mp2a': 'audio/mpeg', + '.mp3': 'audio/mpeg', + '.mp4': 'video/mp4', + '.mp4a': 'audio/mp4', + '.mp4v': 'video/mp4', + '.mpe': 'video/mpeg', + '.mpeg': 'video/mpeg', + '.mpg': 'video/mpeg', + '.mpg4': 'video/mp4', + '.mpga': 'audio/mpeg', + '.oga': 'audio/ogg', + '.ogg': 'audio/ogg', + '.opus': 'audio/opus', + '.ogv': 'video/ogg', + '.png': 'image/png', + '.psd': 'image/vnd.adobe.photoshop', + '.qt': 'video/quicktime', + '.spx': 'audio/ogg', + '.svg': 'image/svg+xml', + '.tga': 'image/x-tga', + '.tif': 'image/tiff', + '.tiff': 'image/tiff', + '.wav': 'audio/x-wav', + '.webm': 'video/webm', + '.webp': 'image/webp', + '.wma': 'audio/x-ms-wma', + '.wmv': 'video/x-ms-wmv', + '.woff': 'application/font-woff', +}; + +export function getMediaOrTextMime(path: string): string | undefined { + const ext = extname(path); + const textMime = mapExtToTextMimes[ext.toLowerCase()]; + if (textMime !== undefined) { + return textMime; + } else { + return getMediaMime(path); + } +} + +export function getMediaMime(path: string): string | undefined { + const ext = extname(path); + return mapExtToMediaMimes[ext.toLowerCase()]; +} + +export function getExtensionForMimeType(mimeType: string): string | undefined { + for (const extension in mapExtToMediaMimes) { + if (mapExtToMediaMimes[extension] === mimeType) { + return extension; + } + } + + return undefined; +} + +const _simplePattern = /^(.+)\/(.+?)(;.+)?$/; + +export function normalizeMimeType(mimeType: string): string; +export function normalizeMimeType(mimeType: string, strict: true): string | undefined; +export function normalizeMimeType(mimeType: string, strict?: true): string | undefined { + + const match = _simplePattern.exec(mimeType); + if (!match) { + return strict + ? undefined + : mimeType; + } + // https://datatracker.ietf.org/doc/html/rfc2045#section-5.1 + // media and subtype must ALWAYS be lowercase, parameter not + return `${match[1].toLowerCase()}/${match[2].toLowerCase()}${match[3] ?? ''}`; +} diff --git a/src/vs/base/common/navigator.ts b/src/vs/base/common/navigator.ts new file mode 100644 index 0000000000..ba7feffef5 --- /dev/null +++ b/src/vs/base/common/navigator.ts @@ -0,0 +1,50 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export interface INavigator { + current(): T | null; + previous(): T | null; + first(): T | null; + last(): T | null; + next(): T | null; +} + +export class ArrayNavigator implements INavigator { + + constructor( + private readonly items: readonly T[], + protected start: number = 0, + protected end: number = items.length, + protected index = start - 1 + ) { } + + current(): T | null { + if (this.index === this.start - 1 || this.index === this.end) { + return null; + } + + return this.items[this.index]; + } + + next(): T | null { + this.index = Math.min(this.index + 1, this.end); + return this.current(); + } + + previous(): T | null { + this.index = Math.max(this.index - 1, this.start - 1); + return this.current(); + } + + first(): T | null { + this.index = this.start; + return this.current(); + } + + last(): T | null { + this.index = this.end - 1; + return this.current(); + } +} diff --git a/src/vs/base/common/network.ts b/src/vs/base/common/network.ts new file mode 100644 index 0000000000..5662c57a3e --- /dev/null +++ b/src/vs/base/common/network.ts @@ -0,0 +1,128 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export namespace Schemas { + + /** + * A schema that is used for models that exist in memory + * only and that have no correspondence on a server or such. + */ + export const inMemory = 'inmemory'; + + /** + * A schema that is used for setting files + */ + export const vscode = 'vscode'; + + /** + * A schema that is used for internal private files + */ + export const internal = 'private'; + + /** + * A walk-through document. + */ + export const walkThrough = 'walkThrough'; + + /** + * An embedded code snippet. + */ + export const walkThroughSnippet = 'walkThroughSnippet'; + + export const http = 'http'; + + export const https = 'https'; + + export const file = 'file'; + + export const mailto = 'mailto'; + + export const untitled = 'untitled'; + + export const data = 'data'; + + export const command = 'command'; + + export const vscodeRemote = 'vscode-remote'; + + export const vscodeRemoteResource = 'vscode-remote-resource'; + + export const vscodeManagedRemoteResource = 'vscode-managed-remote-resource'; + + export const vscodeUserData = 'vscode-userdata'; + + export const vscodeCustomEditor = 'vscode-custom-editor'; + + export const vscodeNotebookCell = 'vscode-notebook-cell'; + export const vscodeNotebookCellMetadata = 'vscode-notebook-cell-metadata'; + export const vscodeNotebookCellOutput = 'vscode-notebook-cell-output'; + export const vscodeInteractiveInput = 'vscode-interactive-input'; + + export const vscodeSettings = 'vscode-settings'; + + export const vscodeWorkspaceTrust = 'vscode-workspace-trust'; + + export const vscodeTerminal = 'vscode-terminal'; + + /** Scheme used for code blocks in chat. */ + export const vscodeChatCodeBlock = 'vscode-chat-code-block'; + + /** + * Scheme used for backing documents created by copilot for chat. + */ + export const vscodeCopilotBackingChatCodeBlock = 'vscode-copilot-chat-code-block'; + + /** Scheme used for LHS of code compare (aka diff) blocks in chat. */ + export const vscodeChatCodeCompareBlock = 'vscode-chat-code-compare-block'; + + /** Scheme used for the chat input editor. */ + export const vscodeChatSesssion = 'vscode-chat-editor'; + + /** + * Scheme used internally for webviews that aren't linked to a resource (i.e. not custom editors) + */ + export const webviewPanel = 'webview-panel'; + + /** + * Scheme used for loading the wrapper html and script in webviews. + */ + export const vscodeWebview = 'vscode-webview'; + + /** + * Scheme used for extension pages + */ + export const extension = 'extension'; + + /** + * Scheme used as a replacement of `file` scheme to load + * files with our custom protocol handler (desktop only). + */ + export const vscodeFileResource = 'vscode-file'; + + /** + * Scheme used for temporary resources + */ + export const tmp = 'tmp'; + + /** + * Scheme used vs live share + */ + export const vsls = 'vsls'; + + /** + * Scheme used for the Source Control commit input's text document + */ + export const vscodeSourceControl = 'vscode-scm'; + + /** + * Scheme used for input box for creating comments. + */ + export const commentsInput = 'comment'; + + /** + * Scheme used for special rendering of settings in the release notes + */ + export const codeSetting = 'code-setting'; +} diff --git a/src/vs/base/common/numbers.ts b/src/vs/base/common/numbers.ts new file mode 100644 index 0000000000..ab4c9f92e0 --- /dev/null +++ b/src/vs/base/common/numbers.ts @@ -0,0 +1,98 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export function clamp(value: number, min: number, max: number): number { + return Math.min(Math.max(value, min), max); +} + +export function rot(index: number, modulo: number): number { + return (modulo + (index % modulo)) % modulo; +} + +export class Counter { + private _next = 0; + + getNext(): number { + return this._next++; + } +} + +export class MovingAverage { + + private _n = 1; + private _val = 0; + + update(value: number): number { + this._val = this._val + (value - this._val) / this._n; + this._n += 1; + return this._val; + } + + get value(): number { + return this._val; + } +} + +export class SlidingWindowAverage { + + private _n: number = 0; + private _val = 0; + + private readonly _values: number[] = []; + private _index: number = 0; + private _sum = 0; + + constructor(size: number) { + this._values = new Array(size); + this._values.fill(0, 0, size); + } + + update(value: number): number { + const oldValue = this._values[this._index]; + this._values[this._index] = value; + this._index = (this._index + 1) % this._values.length; + + this._sum -= oldValue; + this._sum += value; + + if (this._n < this._values.length) { + this._n += 1; + } + + this._val = this._sum / this._n; + return this._val; + } + + get value(): number { + return this._val; + } +} + +/** Returns whether the point is within the triangle formed by the following 6 x/y point pairs */ +export function isPointWithinTriangle( + x: number, y: number, + ax: number, ay: number, + bx: number, by: number, + cx: number, cy: number +) { + const v0x = cx - ax; + const v0y = cy - ay; + const v1x = bx - ax; + const v1y = by - ay; + const v2x = x - ax; + const v2y = y - ay; + + const dot00 = v0x * v0x + v0y * v0y; + const dot01 = v0x * v1x + v0y * v1y; + const dot02 = v0x * v2x + v0y * v2y; + const dot11 = v1x * v1x + v1y * v1y; + const dot12 = v1x * v2x + v1y * v2y; + + const invDenom = 1 / (dot00 * dot11 - dot01 * dot01); + const u = (dot11 * dot02 - dot01 * dot12) * invDenom; + const v = (dot00 * dot12 - dot01 * dot02) * invDenom; + + return u >= 0 && v >= 0 && u + v < 1; +} diff --git a/src/vs/base/common/objects.ts b/src/vs/base/common/objects.ts new file mode 100644 index 0000000000..14ec0e7197 --- /dev/null +++ b/src/vs/base/common/objects.ts @@ -0,0 +1,274 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { isTypedArray, isObject, isUndefinedOrNull } from 'vs/base/common/types'; + +export function deepClone(obj: T): T { + if (!obj || typeof obj !== 'object') { + return obj; + } + if (obj instanceof RegExp) { + return obj; + } + const result: any = Array.isArray(obj) ? [] : {}; + Object.entries(obj).forEach(([key, value]) => { + result[key] = value && typeof value === 'object' ? deepClone(value) : value; + }); + return result; +} + +export function deepFreeze(obj: T): T { + if (!obj || typeof obj !== 'object') { + return obj; + } + const stack: any[] = [obj]; + while (stack.length > 0) { + const obj = stack.shift(); + Object.freeze(obj); + for (const key in obj) { + if (_hasOwnProperty.call(obj, key)) { + const prop = obj[key]; + if (typeof prop === 'object' && !Object.isFrozen(prop) && !isTypedArray(prop)) { + stack.push(prop); + } + } + } + } + return obj; +} + +const _hasOwnProperty = Object.prototype.hasOwnProperty; + + +export function cloneAndChange(obj: any, changer: (orig: any) => any): any { + return _cloneAndChange(obj, changer, new Set()); +} + +function _cloneAndChange(obj: any, changer: (orig: any) => any, seen: Set): any { + if (isUndefinedOrNull(obj)) { + return obj; + } + + const changed = changer(obj); + if (typeof changed !== 'undefined') { + return changed; + } + + if (Array.isArray(obj)) { + const r1: any[] = []; + for (const e of obj) { + r1.push(_cloneAndChange(e, changer, seen)); + } + return r1; + } + + if (isObject(obj)) { + if (seen.has(obj)) { + throw new Error('Cannot clone recursive data-structure'); + } + seen.add(obj); + const r2 = {}; + for (const i2 in obj) { + if (_hasOwnProperty.call(obj, i2)) { + (r2 as any)[i2] = _cloneAndChange(obj[i2], changer, seen); + } + } + seen.delete(obj); + return r2; + } + + return obj; +} + +/** + * Copies all properties of source into destination. The optional parameter "overwrite" allows to control + * if existing properties on the destination should be overwritten or not. Defaults to true (overwrite). + */ +export function mixin(destination: any, source: any, overwrite: boolean = true): any { + if (!isObject(destination)) { + return source; + } + + if (isObject(source)) { + Object.keys(source).forEach(key => { + if (key in destination) { + if (overwrite) { + if (isObject(destination[key]) && isObject(source[key])) { + mixin(destination[key], source[key], overwrite); + } else { + destination[key] = source[key]; + } + } + } else { + destination[key] = source[key]; + } + }); + } + return destination; +} + +export function equals(one: any, other: any): boolean { + if (one === other) { + return true; + } + if (one === null || one === undefined || other === null || other === undefined) { + return false; + } + if (typeof one !== typeof other) { + return false; + } + if (typeof one !== 'object') { + return false; + } + if ((Array.isArray(one)) !== (Array.isArray(other))) { + return false; + } + + let i: number; + let key: string; + + if (Array.isArray(one)) { + if (one.length !== other.length) { + return false; + } + for (i = 0; i < one.length; i++) { + if (!equals(one[i], other[i])) { + return false; + } + } + } else { + const oneKeys: string[] = []; + + for (key in one) { + oneKeys.push(key); + } + oneKeys.sort(); + const otherKeys: string[] = []; + for (key in other) { + otherKeys.push(key); + } + otherKeys.sort(); + if (!equals(oneKeys, otherKeys)) { + return false; + } + for (i = 0; i < oneKeys.length; i++) { + if (!equals(one[oneKeys[i]], other[oneKeys[i]])) { + return false; + } + } + } + return true; +} + +/** + * Calls `JSON.Stringify` with a replacer to break apart any circular references. + * This prevents `JSON`.stringify` from throwing the exception + * "Uncaught TypeError: Converting circular structure to JSON" + */ +export function safeStringify(obj: any): string { + const seen = new Set(); + return JSON.stringify(obj, (key, value) => { + if (isObject(value) || Array.isArray(value)) { + if (seen.has(value)) { + return '[Circular]'; + } else { + seen.add(value); + } + } + if (typeof value === 'bigint') { + return `[BigInt ${value.toString()}]`; + } + return value; + }); +} + +type obj = { [key: string]: any }; +/** + * Returns an object that has keys for each value that is different in the base object. Keys + * that do not exist in the target but in the base object are not considered. + * + * Note: This is not a deep-diffing method, so the values are strictly taken into the resulting + * object if they differ. + * + * @param base the object to diff against + * @param obj the object to use for diffing + */ +export function distinct(base: obj, target: obj): obj { + const result = Object.create(null); + + if (!base || !target) { + return result; + } + + const targetKeys = Object.keys(target); + targetKeys.forEach(k => { + const baseValue = base[k]; + const targetValue = target[k]; + + if (!equals(baseValue, targetValue)) { + result[k] = targetValue; + } + }); + + return result; +} + +export function getCaseInsensitive(target: obj, key: string): any { + const lowercaseKey = key.toLowerCase(); + const equivalentKey = Object.keys(target).find(k => k.toLowerCase() === lowercaseKey); + return equivalentKey ? target[equivalentKey] : target[key]; +} + +export function filter(obj: obj, predicate: (key: string, value: any) => boolean): obj { + const result = Object.create(null); + for (const [key, value] of Object.entries(obj)) { + if (predicate(key, value)) { + result[key] = value; + } + } + return result; +} + +export function getAllPropertyNames(obj: object): string[] { + let res: string[] = []; + while (Object.prototype !== obj) { + res = res.concat(Object.getOwnPropertyNames(obj)); + obj = Object.getPrototypeOf(obj); + } + return res; +} + +export function getAllMethodNames(obj: object): string[] { + const methods: string[] = []; + for (const prop of getAllPropertyNames(obj)) { + if (typeof (obj as any)[prop] === 'function') { + methods.push(prop); + } + } + return methods; +} + +export function createProxyObject(methodNames: string[], invoke: (method: string, args: unknown[]) => unknown): T { + const createProxyMethod = (method: string): () => unknown => { + return function () { + const args = Array.prototype.slice.call(arguments, 0); + return invoke(method, args); + }; + }; + + const result = {} as T; + for (const methodName of methodNames) { + (result)[methodName] = createProxyMethod(methodName); + } + return result; +} + +export function mapValues(obj: T, fn: (value: T[keyof T], key: string) => R): { [K in keyof T]: R } { + const result: { [key: string]: R } = {}; + for (const [key, value] of Object.entries(obj)) { + result[key] = fn(value, key); + } + return result as { [K in keyof T]: R }; +} diff --git a/src/vs/base/common/observable.ts b/src/vs/base/common/observable.ts new file mode 100644 index 0000000000..c090a27206 --- /dev/null +++ b/src/vs/base/common/observable.ts @@ -0,0 +1,76 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// This is a facade for the observable implementation. Only import from here! + +export type { + IObservable, + IObserver, + IReader, + ISettable, + ISettableObservable, + ITransaction, + IChangeContext, + IChangeTracker, +} from 'vs/base/common/observableInternal/base'; + +export { + observableValue, + disposableObservableValue, + transaction, + subtransaction, +} from 'vs/base/common/observableInternal/base'; +export { + derived, + derivedOpts, + derivedHandleChanges, + derivedWithStore, +} from 'vs/base/common/observableInternal/derived'; +export { + autorun, + autorunDelta, + autorunHandleChanges, + autorunWithStore, + autorunOpts, + autorunWithStoreHandleChanges, +} from 'vs/base/common/observableInternal/autorun'; +export type { + IObservableSignal, +} from 'vs/base/common/observableInternal/utils'; +export { + constObservable, + debouncedObservable, + derivedObservableWithCache, + derivedObservableWithWritableCache, + keepObserved, + recomputeInitiallyAndOnChange, + observableFromEvent, + observableFromPromise, + observableSignal, + observableSignalFromEvent, + wasEventTriggeredRecently, +} from 'vs/base/common/observableInternal/utils'; +export { + ObservableLazy, + ObservableLazyPromise, + ObservablePromise, + PromiseResult, + waitForState, + derivedWithCancellationToken, +} from 'vs/base/common/observableInternal/promise'; +export { + observableValueOpts +} from 'vs/base/common/observableInternal/api'; + +import { ConsoleObservableLogger, setLogger } from 'vs/base/common/observableInternal/logging'; + +// Remove "//" in the next line to enable logging +const enableLogging = false + // || Boolean("true") // done "weirdly" so that a lint warning prevents you from pushing this + ; + +if (enableLogging) { + setLogger(new ConsoleObservableLogger()); +} diff --git a/src/vs/base/common/observableInternal/api.ts b/src/vs/base/common/observableInternal/api.ts new file mode 100644 index 0000000000..6e56671b7e --- /dev/null +++ b/src/vs/base/common/observableInternal/api.ts @@ -0,0 +1,31 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { EqualityComparer, strictEquals } from 'vs/base/common/equals'; +import { ISettableObservable } from 'vs/base/common/observable'; +import { ObservableValue } from 'vs/base/common/observableInternal/base'; +import { IDebugNameData, DebugNameData } from 'vs/base/common/observableInternal/debugName'; +import { LazyObservableValue } from 'vs/base/common/observableInternal/lazyObservableValue'; + +export function observableValueOpts( + options: IDebugNameData & { + equalsFn?: EqualityComparer; + lazy?: boolean; + }, + initialValue: T +): ISettableObservable { + if (options.lazy) { + return new LazyObservableValue( + new DebugNameData(options.owner, options.debugName, undefined), + initialValue, + options.equalsFn ?? strictEquals, + ); + } + return new ObservableValue( + new DebugNameData(options.owner, options.debugName, undefined), + initialValue, + options.equalsFn ?? strictEquals, + ); +} diff --git a/src/vs/base/common/observableInternal/autorun.ts b/src/vs/base/common/observableInternal/autorun.ts new file mode 100644 index 0000000000..845e870d65 --- /dev/null +++ b/src/vs/base/common/observableInternal/autorun.ts @@ -0,0 +1,281 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { assertFn } from 'vs/base/common/assert'; +import { DisposableStore, IDisposable, markAsDisposed, toDisposable, trackDisposable } from 'vs/base/common/lifecycle'; +import { IReader, IObservable, IObserver, IChangeContext } from 'vs/base/common/observableInternal/base'; +import { DebugNameData, IDebugNameData } from 'vs/base/common/observableInternal/debugName'; +import { getLogger } from 'vs/base/common/observableInternal/logging'; + +/** + * Runs immediately and whenever a transaction ends and an observed observable changed. + * {@link fn} should start with a JS Doc using `@description` to name the autorun. + */ +export function autorun(fn: (reader: IReader) => void): IDisposable { + return new AutorunObserver( + new DebugNameData(undefined, undefined, fn), + fn, + undefined, + undefined + ); +} + +/** + * Runs immediately and whenever a transaction ends and an observed observable changed. + * {@link fn} should start with a JS Doc using `@description` to name the autorun. + */ +export function autorunOpts(options: IDebugNameData & {}, fn: (reader: IReader) => void): IDisposable { + return new AutorunObserver( + new DebugNameData(options.owner, options.debugName, options.debugReferenceFn ?? fn), + fn, + undefined, + undefined + ); +} + +/** + * Runs immediately and whenever a transaction ends and an observed observable changed. + * {@link fn} should start with a JS Doc using `@description` to name the autorun. + * + * Use `createEmptyChangeSummary` to create a "change summary" that can collect the changes. + * Use `handleChange` to add a reported change to the change summary. + * The run function is given the last change summary. + * The change summary is discarded after the run function was called. + * + * @see autorun + */ +export function autorunHandleChanges( + options: IDebugNameData & { + createEmptyChangeSummary?: () => TChangeSummary; + handleChange: (context: IChangeContext, changeSummary: TChangeSummary) => boolean; + }, + fn: (reader: IReader, changeSummary: TChangeSummary) => void +): IDisposable { + return new AutorunObserver( + new DebugNameData(options.owner, options.debugName, options.debugReferenceFn ?? fn), + fn, + options.createEmptyChangeSummary, + options.handleChange + ); +} + +/** + * @see autorunHandleChanges (but with a disposable store that is cleared before the next run or on dispose) + */ +export function autorunWithStoreHandleChanges( + options: IDebugNameData & { + createEmptyChangeSummary?: () => TChangeSummary; + handleChange: (context: IChangeContext, changeSummary: TChangeSummary) => boolean; + }, + fn: (reader: IReader, changeSummary: TChangeSummary, store: DisposableStore) => void +): IDisposable { + const store = new DisposableStore(); + const disposable = autorunHandleChanges( + { + owner: options.owner, + debugName: options.debugName, + debugReferenceFn: options.debugReferenceFn ?? fn, + createEmptyChangeSummary: options.createEmptyChangeSummary, + handleChange: options.handleChange, + }, + (reader, changeSummary) => { + store.clear(); + fn(reader, changeSummary, store); + } + ); + return toDisposable(() => { + disposable.dispose(); + store.dispose(); + }); +} + +/** + * @see autorun (but with a disposable store that is cleared before the next run or on dispose) + */ +export function autorunWithStore(fn: (reader: IReader, store: DisposableStore) => void): IDisposable { + const store = new DisposableStore(); + const disposable = autorunOpts( + { + owner: undefined, + debugName: undefined, + debugReferenceFn: fn, + }, + reader => { + store.clear(); + fn(reader, store); + } + ); + return toDisposable(() => { + disposable.dispose(); + store.dispose(); + }); +} + +export function autorunDelta( + observable: IObservable, + handler: (args: { lastValue: T | undefined; newValue: T }) => void +): IDisposable { + let _lastValue: T | undefined; + return autorunOpts({ debugReferenceFn: handler }, (reader) => { + const newValue = observable.read(reader); + const lastValue = _lastValue; + _lastValue = newValue; + handler({ lastValue, newValue }); + }); +} + + +const enum AutorunState { + /** + * A dependency could have changed. + * We need to explicitly ask them if at least one dependency changed. + */ + dependenciesMightHaveChanged = 1, + + /** + * A dependency changed and we need to recompute. + */ + stale = 2, + upToDate = 3, +} + +export class AutorunObserver implements IObserver, IReader, IDisposable { + private state = AutorunState.stale; + private updateCount = 0; + private disposed = false; + private dependencies = new Set>(); + private dependenciesToBeRemoved = new Set>(); + private changeSummary: TChangeSummary | undefined; + + public get debugName(): string { + return this._debugNameData.getDebugName(this) ?? '(anonymous)'; + } + + constructor( + public readonly _debugNameData: DebugNameData, + public readonly _runFn: (reader: IReader, changeSummary: TChangeSummary) => void, + private readonly createChangeSummary: (() => TChangeSummary) | undefined, + private readonly _handleChange: ((context: IChangeContext, summary: TChangeSummary) => boolean) | undefined, + ) { + this.changeSummary = this.createChangeSummary?.(); + getLogger()?.handleAutorunCreated(this); + this._runIfNeeded(); + + trackDisposable(this); + } + + public dispose(): void { + this.disposed = true; + for (const o of this.dependencies) { + o.removeObserver(this); + } + this.dependencies.clear(); + + markAsDisposed(this); + } + + private _runIfNeeded() { + if (this.state === AutorunState.upToDate) { + return; + } + + const emptySet = this.dependenciesToBeRemoved; + this.dependenciesToBeRemoved = this.dependencies; + this.dependencies = emptySet; + + this.state = AutorunState.upToDate; + + const isDisposed = this.disposed; + try { + if (!isDisposed) { + getLogger()?.handleAutorunTriggered(this); + const changeSummary = this.changeSummary!; + this.changeSummary = this.createChangeSummary?.(); + this._runFn(this, changeSummary); + } + } finally { + if (!isDisposed) { + getLogger()?.handleAutorunFinished(this); + } + // We don't want our observed observables to think that they are (not even temporarily) not being observed. + // Thus, we only unsubscribe from observables that are definitely not read anymore. + for (const o of this.dependenciesToBeRemoved) { + o.removeObserver(this); + } + this.dependenciesToBeRemoved.clear(); + } + } + + public toString(): string { + return `Autorun<${this.debugName}>`; + } + + // IObserver implementation + public beginUpdate(): void { + if (this.state === AutorunState.upToDate) { + this.state = AutorunState.dependenciesMightHaveChanged; + } + this.updateCount++; + } + + public endUpdate(): void { + if (this.updateCount === 1) { + do { + if (this.state === AutorunState.dependenciesMightHaveChanged) { + this.state = AutorunState.upToDate; + for (const d of this.dependencies) { + d.reportChanges(); + if (this.state as AutorunState === AutorunState.stale) { + // The other dependencies will refresh on demand + break; + } + } + } + + this._runIfNeeded(); + } while (this.state !== AutorunState.upToDate); + } + this.updateCount--; + + assertFn(() => this.updateCount >= 0); + } + + public handlePossibleChange(observable: IObservable): void { + if (this.state === AutorunState.upToDate && this.dependencies.has(observable) && !this.dependenciesToBeRemoved.has(observable)) { + this.state = AutorunState.dependenciesMightHaveChanged; + } + } + + public handleChange(observable: IObservable, change: TChange): void { + if (this.dependencies.has(observable) && !this.dependenciesToBeRemoved.has(observable)) { + const shouldReact = this._handleChange ? this._handleChange({ + changedObservable: observable, + change, + didChange: (o): this is any => o === observable as any, + }, this.changeSummary!) : true; + if (shouldReact) { + this.state = AutorunState.stale; + } + } + } + + // IReader implementation + public readObservable(observable: IObservable): T { + // In case the run action disposes the autorun + if (this.disposed) { + return observable.get(); + } + + observable.addObserver(this); + const value = observable.get(); + this.dependencies.add(observable); + this.dependenciesToBeRemoved.delete(observable); + return value; + } +} + +export namespace autorun { + export const Observer = AutorunObserver; +} diff --git a/src/vs/base/common/observableInternal/base.ts b/src/vs/base/common/observableInternal/base.ts new file mode 100644 index 0000000000..3c63a20116 --- /dev/null +++ b/src/vs/base/common/observableInternal/base.ts @@ -0,0 +1,489 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { strictEquals, EqualityComparer } from 'vs/base/common/equals'; +import { DisposableStore, IDisposable } from 'vs/base/common/lifecycle'; +import { keepObserved, recomputeInitiallyAndOnChange } from 'vs/base/common/observable'; +import { DebugNameData, DebugOwner, getFunctionName } from 'vs/base/common/observableInternal/debugName'; +import type { derivedOpts } from 'vs/base/common/observableInternal/derived'; +import { getLogger } from 'vs/base/common/observableInternal/logging'; + +/** + * Represents an observable value. + * + * @template T The type of the values the observable can hold. + * @template TChange The type used to describe value changes + * (usually `void` and only used in advanced scenarios). + * While observers can miss temporary values of an observable, + * they will receive all change values (as long as they are subscribed)! + */ +export interface IObservable { + /** + * Returns the current value. + * + * Calls {@link IObserver.handleChange} if the observable notices that the value changed. + * Must not be called from {@link IObserver.handleChange}! + */ + get(): T; + + /** + * Forces the observable to check for changes and report them. + * + * Has the same effect as calling {@link IObservable.get}, but does not force the observable + * to actually construct the value, e.g. if change deltas are used. + * Calls {@link IObserver.handleChange} if the observable notices that the value changed. + * Must not be called from {@link IObserver.handleChange}! + */ + reportChanges(): void; + + /** + * Adds the observer to the set of subscribed observers. + * This method is idempotent. + */ + addObserver(observer: IObserver): void; + + /** + * Removes the observer from the set of subscribed observers. + * This method is idempotent. + */ + removeObserver(observer: IObserver): void; + + /** + * Reads the current value and subscribes the reader to this observable. + * + * Calls {@link IReader.readObservable} if a reader is given, otherwise {@link IObservable.get} + * (see {@link ConvenientObservable.read} for the implementation). + */ + read(reader: IReader | undefined): T; + + /** + * Creates a derived observable that depends on this observable. + * Use the reader to read other observables + * (see {@link ConvenientObservable.map} for the implementation). + */ + map(fn: (value: T, reader: IReader) => TNew): IObservable; + map(owner: object, fn: (value: T, reader: IReader) => TNew): IObservable; + + /** + * Makes sure this value is computed eagerly. + */ + recomputeInitiallyAndOnChange(store: DisposableStore, handleValue?: (value: T) => void): IObservable; + + /** + * Makes sure this value is cached. + */ + keepObserved(store: DisposableStore): IObservable; + + /** + * A human-readable name for debugging purposes. + */ + readonly debugName: string; + + /** + * This property captures the type of the change object. Do not use it at runtime! + */ + readonly TChange: TChange; +} + +export interface IReader { + /** + * Reads the value of an observable and subscribes to it. + */ + readObservable(observable: IObservable): T; +} + +/** + * Represents an observer that can be subscribed to an observable. + * + * If an observer is subscribed to an observable and that observable didn't signal + * a change through one of the observer methods, the observer can assume that the + * observable didn't change. + * If an observable reported a possible change, {@link IObservable.reportChanges} forces + * the observable to report an actual change if there was one. + */ +export interface IObserver { + /** + * Signals that the given observable might have changed and a transaction potentially modifying that observable started. + * Before the given observable can call this method again, is must call {@link IObserver.endUpdate}. + * + * Implementations must not get/read the value of other observables, as they might not have received this event yet! + * The method {@link IObservable.reportChanges} can be used to force the observable to report the changes. + */ + beginUpdate(observable: IObservable): void; + + /** + * Signals that the transaction that potentially modified the given observable ended. + * This is a good place to react to (potential) changes. + */ + endUpdate(observable: IObservable): void; + + /** + * Signals that the given observable might have changed. + * The method {@link IObservable.reportChanges} can be used to force the observable to report the changes. + * + * Implementations must not get/read the value of other observables, as they might not have received this event yet! + * The change should be processed lazily or in {@link IObserver.endUpdate}. + */ + handlePossibleChange(observable: IObservable): void; + + /** + * Signals that the given {@link observable} changed. + * + * Implementations must not get/read the value of other observables, as they might not have received this event yet! + * The change should be processed lazily or in {@link IObserver.endUpdate}. + * + * @param change Indicates how or why the value changed. + */ + handleChange(observable: IObservable, change: TChange): void; +} + +export interface ISettable { + /** + * Sets the value of the observable. + * Use a transaction to batch multiple changes (with a transaction, observers only react at the end of the transaction). + * + * @param transaction When given, value changes are handled on demand or when the transaction ends. + * @param change Describes how or why the value changed. + */ + set(value: T, transaction: ITransaction | undefined, change: TChange): void; +} + +export interface ITransaction { + /** + * Calls {@link Observer.beginUpdate} immediately + * and {@link Observer.endUpdate} when the transaction ends. + */ + updateObserver(observer: IObserver, observable: IObservable): void; +} + +let _recomputeInitiallyAndOnChange: typeof recomputeInitiallyAndOnChange; +export function _setRecomputeInitiallyAndOnChange(recomputeInitiallyAndOnChange: typeof _recomputeInitiallyAndOnChange) { + _recomputeInitiallyAndOnChange = recomputeInitiallyAndOnChange; +} + +let _keepObserved: typeof keepObserved; +export function _setKeepObserved(keepObserved: typeof _keepObserved) { + _keepObserved = keepObserved; +} + + +let _derived: typeof derivedOpts; +/** + * @internal + * This is to allow splitting files. +*/ +export function _setDerivedOpts(derived: typeof _derived) { + _derived = derived; +} + +export abstract class ConvenientObservable implements IObservable { + get TChange(): TChange { return null!; } + + public abstract get(): T; + + public reportChanges(): void { + this.get(); + } + + public abstract addObserver(observer: IObserver): void; + public abstract removeObserver(observer: IObserver): void; + + /** @sealed */ + public read(reader: IReader | undefined): T { + if (reader) { + return reader.readObservable(this); + } else { + return this.get(); + } + } + + /** @sealed */ + public map(fn: (value: T, reader: IReader) => TNew): IObservable; + public map(owner: DebugOwner, fn: (value: T, reader: IReader) => TNew): IObservable; + public map(fnOrOwner: DebugOwner | ((value: T, reader: IReader) => TNew), fnOrUndefined?: (value: T, reader: IReader) => TNew): IObservable { + const owner = fnOrUndefined === undefined ? undefined : fnOrOwner as DebugOwner; + const fn = fnOrUndefined === undefined ? fnOrOwner as (value: T, reader: IReader) => TNew : fnOrUndefined; + + return _derived( + { + owner, + debugName: () => { + const name = getFunctionName(fn); + if (name !== undefined) { + return name; + } + + // regexp to match `x => x.y` or `x => x?.y` where x and y can be arbitrary identifiers (uses backref): + const regexp = /^\s*\(?\s*([a-zA-Z_$][a-zA-Z_$0-9]*)\s*\)?\s*=>\s*\1(?:\??)\.([a-zA-Z_$][a-zA-Z_$0-9]*)\s*$/; + const match = regexp.exec(fn.toString()); + if (match) { + return `${this.debugName}.${match[2]}`; + } + if (!owner) { + return `${this.debugName} (mapped)`; + } + return undefined; + }, + debugReferenceFn: fn, + }, + (reader) => fn(this.read(reader), reader), + ); + } + + public recomputeInitiallyAndOnChange(store: DisposableStore, handleValue?: (value: T) => void): IObservable { + store.add(_recomputeInitiallyAndOnChange!(this, handleValue)); + return this; + } + + /** + * Ensures that this observable is observed. This keeps the cache alive. + * However, in case of deriveds, it does not force eager evaluation (only when the value is read/get). + * Use `recomputeInitiallyAndOnChange` for eager evaluation. + */ + public keepObserved(store: DisposableStore): IObservable { + store.add(_keepObserved!(this)); + return this; + } + + public abstract get debugName(): string; + + protected get debugValue() { + return this.get(); + } +} + +export abstract class BaseObservable extends ConvenientObservable { + protected readonly observers = new Set(); + + public addObserver(observer: IObserver): void { + const len = this.observers.size; + this.observers.add(observer); + if (len === 0) { + this.onFirstObserverAdded(); + } + } + + public removeObserver(observer: IObserver): void { + const deleted = this.observers.delete(observer); + if (deleted && this.observers.size === 0) { + this.onLastObserverRemoved(); + } + } + + protected onFirstObserverAdded(): void { } + protected onLastObserverRemoved(): void { } +} + +/** + * Starts a transaction in which many observables can be changed at once. + * {@link fn} should start with a JS Doc using `@description` to give the transaction a debug name. + * Reaction run on demand or when the transaction ends. + */ + +export function transaction(fn: (tx: ITransaction) => void, getDebugName?: () => string): void { + const tx = new TransactionImpl(fn, getDebugName); + try { + fn(tx); + } finally { + tx.finish(); + } +} + +let _globalTransaction: ITransaction | undefined = undefined; + +export function globalTransaction(fn: (tx: ITransaction) => void) { + if (_globalTransaction) { + fn(_globalTransaction); + } else { + const tx = new TransactionImpl(fn, undefined); + _globalTransaction = tx; + try { + fn(tx); + } finally { + tx.finish(); // During finish, more actions might be added to the transaction. + // Which is why we only clear the global transaction after finish. + _globalTransaction = undefined; + } + } +} + +export async function asyncTransaction(fn: (tx: ITransaction) => Promise, getDebugName?: () => string): Promise { + const tx = new TransactionImpl(fn, getDebugName); + try { + await fn(tx); + } finally { + tx.finish(); + } +} + +/** + * Allows to chain transactions. + */ +export function subtransaction(tx: ITransaction | undefined, fn: (tx: ITransaction) => void, getDebugName?: () => string): void { + if (!tx) { + transaction(fn, getDebugName); + } else { + fn(tx); + } +} + +export class TransactionImpl implements ITransaction { + private updatingObservers: { observer: IObserver; observable: IObservable }[] | null = []; + + constructor(public readonly _fn: Function, private readonly _getDebugName?: () => string) { + getLogger()?.handleBeginTransaction(this); + } + + public getDebugName(): string | undefined { + if (this._getDebugName) { + return this._getDebugName(); + } + return getFunctionName(this._fn); + } + + public updateObserver(observer: IObserver, observable: IObservable): void { + // When this gets called while finish is active, they will still get considered + this.updatingObservers!.push({ observer, observable }); + observer.beginUpdate(observable); + } + + public finish(): void { + const updatingObservers = this.updatingObservers!; + for (let i = 0; i < updatingObservers.length; i++) { + const { observer, observable } = updatingObservers[i]; + observer.endUpdate(observable); + } + // Prevent anyone from updating observers from now on. + this.updatingObservers = null; + getLogger()?.handleEndTransaction(); + } +} + +/** + * A settable observable. + */ +export interface ISettableObservable extends IObservable, ISettable { +} + +/** + * Creates an observable value. + * Observers get informed when the value changes. + * @template TChange An arbitrary type to describe how or why the value changed. Defaults to `void`. + * Observers will receive every single change value. + */ +export function observableValue(name: string, initialValue: T): ISettableObservable; +export function observableValue(owner: object, initialValue: T): ISettableObservable; +export function observableValue(nameOrOwner: string | object, initialValue: T): ISettableObservable { + let debugNameData: DebugNameData; + if (typeof nameOrOwner === 'string') { + debugNameData = new DebugNameData(undefined, nameOrOwner, undefined); + } else { + debugNameData = new DebugNameData(nameOrOwner, undefined, undefined); + } + return new ObservableValue(debugNameData, initialValue, strictEquals); +} + +export class ObservableValue + extends BaseObservable + implements ISettableObservable { + protected _value: T; + + get debugName() { + return this._debugNameData.getDebugName(this) ?? 'ObservableValue'; + } + + constructor( + private readonly _debugNameData: DebugNameData, + initialValue: T, + private readonly _equalityComparator: EqualityComparer, + ) { + super(); + this._value = initialValue; + } + public override get(): T { + return this._value; + } + + public set(value: T, tx: ITransaction | undefined, change: TChange): void { + if (change === undefined && this._equalityComparator(this._value, value)) { + return; + } + + let _tx: TransactionImpl | undefined; + if (!tx) { + tx = _tx = new TransactionImpl(() => { }, () => `Setting ${this.debugName}`); + } + try { + const oldValue = this._value; + this._setValue(value); + getLogger()?.handleObservableChanged(this, { oldValue, newValue: value, change, didChange: true, hadValue: true }); + + for (const observer of this.observers) { + tx.updateObserver(observer, this); + observer.handleChange(this, change); + } + } finally { + if (_tx) { + _tx.finish(); + } + } + } + + override toString(): string { + return `${this.debugName}: ${this._value}`; + } + + protected _setValue(newValue: T): void { + this._value = newValue; + } +} + +/** + * A disposable observable. When disposed, its value is also disposed. + * When a new value is set, the previous value is disposed. + */ +export function disposableObservableValue(nameOrOwner: string | object, initialValue: T): ISettableObservable & IDisposable { + let debugNameData: DebugNameData; + if (typeof nameOrOwner === 'string') { + debugNameData = new DebugNameData(undefined, nameOrOwner, undefined); + } else { + debugNameData = new DebugNameData(nameOrOwner, undefined, undefined); + } + return new DisposableObservableValue(debugNameData, initialValue, strictEquals); +} + +export class DisposableObservableValue extends ObservableValue implements IDisposable { + protected override _setValue(newValue: T): void { + if (this._value === newValue) { + return; + } + if (this._value) { + this._value.dispose(); + } + this._value = newValue; + } + + public dispose(): void { + this._value?.dispose(); + } +} + +export interface IChangeTracker { + /** + * Returns if this change should cause an invalidation. + * Implementations can record changes. + */ + handleChange(context: IChangeContext): boolean; +} + +export interface IChangeContext { + readonly changedObservable: IObservable; + readonly change: unknown; + + /** + * Returns if the given observable caused the change. + */ + didChange(observable: IObservable): this is { change: TChange }; +} diff --git a/src/vs/base/common/observableInternal/debugName.ts b/src/vs/base/common/observableInternal/debugName.ts new file mode 100644 index 0000000000..1ff1f24435 --- /dev/null +++ b/src/vs/base/common/observableInternal/debugName.ts @@ -0,0 +1,145 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export interface IDebugNameData { + /** + * The owner object of an observable. + * Used for debugging only, such as computing a name for the observable by iterating over the fields of the owner. + */ + readonly owner?: DebugOwner | undefined; + + /** + * A string or function that returns a string that represents the name of the observable. + * Used for debugging only. + */ + readonly debugName?: DebugNameSource | undefined; + + /** + * A function that points to the defining function of the object. + * Used for debugging only. + */ + readonly debugReferenceFn?: Function | undefined; +} + +export class DebugNameData { + constructor( + public readonly owner: DebugOwner | undefined, + public readonly debugNameSource: DebugNameSource | undefined, + public readonly referenceFn: Function | undefined, + ) { } + + public getDebugName(target: object): string | undefined { + return getDebugName(target, this); + } +} + +/** + * The owning object of an observable. + * Is only used for debugging purposes, such as computing a name for the observable by iterating over the fields of the owner. + */ +export type DebugOwner = object | undefined; +export type DebugNameSource = string | (() => string | undefined); + +const countPerName = new Map(); +const cachedDebugName = new WeakMap(); + +export function getDebugName(target: object, data: DebugNameData): string | undefined { + const cached = cachedDebugName.get(target); + if (cached) { + return cached; + } + + const dbgName = computeDebugName(target, data); + if (dbgName) { + let count = countPerName.get(dbgName) ?? 0; + count++; + countPerName.set(dbgName, count); + const result = count === 1 ? dbgName : `${dbgName}#${count}`; + cachedDebugName.set(target, result); + return result; + } + return undefined; +} + +function computeDebugName(self: object, data: DebugNameData): string | undefined { + const cached = cachedDebugName.get(self); + if (cached) { + return cached; + } + + const ownerStr = data.owner ? formatOwner(data.owner) + `.` : ''; + + let result: string | undefined; + const debugNameSource = data.debugNameSource; + if (debugNameSource !== undefined) { + if (typeof debugNameSource === 'function') { + result = debugNameSource(); + if (result !== undefined) { + return ownerStr + result; + } + } else { + return ownerStr + debugNameSource; + } + } + + const referenceFn = data.referenceFn; + if (referenceFn !== undefined) { + result = getFunctionName(referenceFn); + if (result !== undefined) { + return ownerStr + result; + } + } + + if (data.owner !== undefined) { + const key = findKey(data.owner, self); + if (key !== undefined) { + return ownerStr + key; + } + } + return undefined; +} + +function findKey(obj: object, value: object): string | undefined { + for (const key in obj) { + if ((obj as any)[key] === value) { + return key; + } + } + return undefined; +} + +const countPerClassName = new Map(); +const ownerId = new WeakMap(); + +function formatOwner(owner: object): string { + const id = ownerId.get(owner); + if (id) { + return id; + } + const className = getClassName(owner); + let count = countPerClassName.get(className) ?? 0; + count++; + countPerClassName.set(className, count); + const result = count === 1 ? className : `${className}#${count}`; + ownerId.set(owner, result); + return result; +} + +function getClassName(obj: object): string { + const ctor = obj.constructor; + if (ctor) { + return ctor.name; + } + return 'Object'; +} + +export function getFunctionName(fn: Function): string | undefined { + const fnSrc = fn.toString(); + // Pattern: /** @description ... */ + const regexp = /\/\*\*\s*@description\s*([^*]*)\*\//; + const match = regexp.exec(fnSrc); + const result = match ? match[1] : undefined; + return result?.trim(); +} diff --git a/src/vs/base/common/observableInternal/derived.ts b/src/vs/base/common/observableInternal/derived.ts new file mode 100644 index 0000000000..8de22247db --- /dev/null +++ b/src/vs/base/common/observableInternal/derived.ts @@ -0,0 +1,428 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { assertFn } from 'vs/base/common/assert'; +import { EqualityComparer, strictEquals } from 'vs/base/common/equals'; +import { DisposableStore, IDisposable } from 'vs/base/common/lifecycle'; +import { BaseObservable, IChangeContext, IObservable, IObserver, IReader, ISettableObservable, ITransaction, _setDerivedOpts, } from 'vs/base/common/observableInternal/base'; +import { DebugNameData, IDebugNameData, DebugOwner } from 'vs/base/common/observableInternal/debugName'; +import { getLogger } from 'vs/base/common/observableInternal/logging'; + +/** + * Creates an observable that is derived from other observables. + * The value is only recomputed when absolutely needed. + * + * {@link computeFn} should start with a JS Doc using `@description` to name the derived. + */ +export function derived(computeFn: (reader: IReader) => T): IObservable; +export function derived(owner: DebugOwner, computeFn: (reader: IReader) => T): IObservable; +export function derived(computeFnOrOwner: ((reader: IReader) => T) | DebugOwner, computeFn?: ((reader: IReader) => T) | undefined): IObservable { + if (computeFn !== undefined) { + return new Derived( + new DebugNameData(computeFnOrOwner, undefined, computeFn), + computeFn, + undefined, + undefined, + undefined, + strictEquals + ); + } + return new Derived( + new DebugNameData(undefined, undefined, computeFnOrOwner as any), + computeFnOrOwner as any, + undefined, + undefined, + undefined, + strictEquals + ); +} + +export function derivedWithSetter(owner: DebugOwner | undefined, computeFn: (reader: IReader) => T, setter: (value: T, transaction: ITransaction | undefined) => void): ISettableObservable { + return new DerivedWithSetter( + new DebugNameData(owner, undefined, computeFn), + computeFn, + undefined, + undefined, + undefined, + strictEquals, + setter, + ); +} + +export function derivedOpts( + options: IDebugNameData & { + equalsFn?: EqualityComparer; + onLastObserverRemoved?: (() => void); + }, + computeFn: (reader: IReader) => T +): IObservable { + return new Derived( + new DebugNameData(options.owner, options.debugName, options.debugReferenceFn), + computeFn, + undefined, + undefined, + options.onLastObserverRemoved, + options.equalsFn ?? strictEquals + ); +} + +_setDerivedOpts(derivedOpts); + +/** + * Represents an observable that is derived from other observables. + * The value is only recomputed when absolutely needed. + * + * {@link computeFn} should start with a JS Doc using `@description` to name the derived. + * + * Use `createEmptyChangeSummary` to create a "change summary" that can collect the changes. + * Use `handleChange` to add a reported change to the change summary. + * The compute function is given the last change summary. + * The change summary is discarded after the compute function was called. + * + * @see derived + */ +export function derivedHandleChanges( + options: IDebugNameData & { + createEmptyChangeSummary: () => TChangeSummary; + handleChange: (context: IChangeContext, changeSummary: TChangeSummary) => boolean; + equalityComparer?: EqualityComparer; + }, + computeFn: (reader: IReader, changeSummary: TChangeSummary) => T +): IObservable { + return new Derived( + new DebugNameData(options.owner, options.debugName, undefined), + computeFn, + options.createEmptyChangeSummary, + options.handleChange, + undefined, + options.equalityComparer ?? strictEquals + ); +} + +export function derivedWithStore(computeFn: (reader: IReader, store: DisposableStore) => T): IObservable; +export function derivedWithStore(owner: object, computeFn: (reader: IReader, store: DisposableStore) => T): IObservable; +export function derivedWithStore(computeFnOrOwner: ((reader: IReader, store: DisposableStore) => T) | object, computeFnOrUndefined?: ((reader: IReader, store: DisposableStore) => T)): IObservable { + let computeFn: (reader: IReader, store: DisposableStore) => T; + let owner: DebugOwner; + if (computeFnOrUndefined === undefined) { + computeFn = computeFnOrOwner as any; + owner = undefined; + } else { + owner = computeFnOrOwner; + computeFn = computeFnOrUndefined as any; + } + + const store = new DisposableStore(); + return new Derived( + new DebugNameData(owner, undefined, computeFn), + r => { + store.clear(); + return computeFn(r, store); + }, undefined, + undefined, + () => store.dispose(), + strictEquals + ); +} + +export function derivedDisposable(computeFn: (reader: IReader) => T): IObservable; +export function derivedDisposable(owner: DebugOwner, computeFn: (reader: IReader) => T): IObservable; +export function derivedDisposable(computeFnOrOwner: ((reader: IReader) => T) | DebugOwner, computeFnOrUndefined?: ((reader: IReader) => T)): IObservable { + let computeFn: (reader: IReader) => T; + let owner: DebugOwner; + if (computeFnOrUndefined === undefined) { + computeFn = computeFnOrOwner as any; + owner = undefined; + } else { + owner = computeFnOrOwner; + computeFn = computeFnOrUndefined as any; + } + + let store: DisposableStore | undefined = undefined; + return new Derived( + new DebugNameData(owner, undefined, computeFn), + r => { + if (!store) { + store = new DisposableStore(); + } else { + store.clear(); + } + const result = computeFn(r); + if (result) { + store.add(result); + } + return result; + }, undefined, + undefined, + () => { + if (store) { + store.dispose(); + store = undefined; + } + }, + strictEquals + ); +} + +const enum DerivedState { + /** Initial state, no previous value, recomputation needed */ + initial = 0, + + /** + * A dependency could have changed. + * We need to explicitly ask them if at least one dependency changed. + */ + dependenciesMightHaveChanged = 1, + + /** + * A dependency changed and we need to recompute. + * After recomputation, we need to check the previous value to see if we changed as well. + */ + stale = 2, + + /** + * No change reported, our cached value is up to date. + */ + upToDate = 3, +} + +export class Derived extends BaseObservable implements IReader, IObserver { + private state = DerivedState.initial; + private value: T | undefined = undefined; + private updateCount = 0; + private dependencies = new Set>(); + private dependenciesToBeRemoved = new Set>(); + private changeSummary: TChangeSummary | undefined = undefined; + + public override get debugName(): string { + return this._debugNameData.getDebugName(this) ?? '(anonymous)'; + } + + constructor( + public readonly _debugNameData: DebugNameData, + public readonly _computeFn: (reader: IReader, changeSummary: TChangeSummary) => T, + private readonly createChangeSummary: (() => TChangeSummary) | undefined, + private readonly _handleChange: ((context: IChangeContext, summary: TChangeSummary) => boolean) | undefined, + private readonly _handleLastObserverRemoved: (() => void) | undefined = undefined, + private readonly _equalityComparator: EqualityComparer, + ) { + super(); + this.changeSummary = this.createChangeSummary?.(); + getLogger()?.handleDerivedCreated(this); + } + + protected override onLastObserverRemoved(): void { + /** + * We are not tracking changes anymore, thus we have to assume + * that our cache is invalid. + */ + this.state = DerivedState.initial; + this.value = undefined; + for (const d of this.dependencies) { + d.removeObserver(this); + } + this.dependencies.clear(); + + this._handleLastObserverRemoved?.(); + } + + public override get(): T { + if (this.observers.size === 0) { + // Without observers, we don't know when to clean up stuff. + // Thus, we don't cache anything to prevent memory leaks. + const result = this._computeFn(this, this.createChangeSummary?.()!); + // Clear new dependencies + this.onLastObserverRemoved(); + return result; + } else { + do { + // We might not get a notification for a dependency that changed while it is updating, + // thus we also have to ask all our depedencies if they changed in this case. + if (this.state === DerivedState.dependenciesMightHaveChanged) { + for (const d of this.dependencies) { + /** might call {@link handleChange} indirectly, which could make us stale */ + d.reportChanges(); + + if (this.state as DerivedState === DerivedState.stale) { + // The other dependencies will refresh on demand, so early break + break; + } + } + } + + // We called report changes of all dependencies. + // If we are still not stale, we can assume to be up to date again. + if (this.state === DerivedState.dependenciesMightHaveChanged) { + this.state = DerivedState.upToDate; + } + + this._recomputeIfNeeded(); + // In case recomputation changed one of our dependencies, we need to recompute again. + } while (this.state !== DerivedState.upToDate); + return this.value!; + } + } + + private _recomputeIfNeeded() { + if (this.state === DerivedState.upToDate) { + return; + } + const emptySet = this.dependenciesToBeRemoved; + this.dependenciesToBeRemoved = this.dependencies; + this.dependencies = emptySet; + + const hadValue = this.state !== DerivedState.initial; + const oldValue = this.value; + this.state = DerivedState.upToDate; + + const changeSummary = this.changeSummary!; + this.changeSummary = this.createChangeSummary?.(); + try { + /** might call {@link handleChange} indirectly, which could invalidate us */ + this.value = this._computeFn(this, changeSummary); + } finally { + // We don't want our observed observables to think that they are (not even temporarily) not being observed. + // Thus, we only unsubscribe from observables that are definitely not read anymore. + for (const o of this.dependenciesToBeRemoved) { + o.removeObserver(this); + } + this.dependenciesToBeRemoved.clear(); + } + + const didChange = hadValue && !(this._equalityComparator(oldValue!, this.value)); + + getLogger()?.handleDerivedRecomputed(this, { + oldValue, + newValue: this.value, + change: undefined, + didChange, + hadValue, + }); + + if (didChange) { + for (const r of this.observers) { + r.handleChange(this, undefined); + } + } + } + + public override toString(): string { + return `LazyDerived<${this.debugName}>`; + } + + // IObserver Implementation + public beginUpdate(_observable: IObservable): void { + this.updateCount++; + const propagateBeginUpdate = this.updateCount === 1; + if (this.state === DerivedState.upToDate) { + this.state = DerivedState.dependenciesMightHaveChanged; + // If we propagate begin update, that will already signal a possible change. + if (!propagateBeginUpdate) { + for (const r of this.observers) { + r.handlePossibleChange(this); + } + } + } + if (propagateBeginUpdate) { + for (const r of this.observers) { + r.beginUpdate(this); // This signals a possible change + } + } + } + + public endUpdate(_observable: IObservable): void { + this.updateCount--; + if (this.updateCount === 0) { + // End update could change the observer list. + const observers = [...this.observers]; + for (const r of observers) { + r.endUpdate(this); + } + } + assertFn(() => this.updateCount >= 0); + } + + public handlePossibleChange(observable: IObservable): void { + // In all other states, observers already know that we might have changed. + if (this.state === DerivedState.upToDate && this.dependencies.has(observable) && !this.dependenciesToBeRemoved.has(observable)) { + this.state = DerivedState.dependenciesMightHaveChanged; + for (const r of this.observers) { + r.handlePossibleChange(this); + } + } + } + + public handleChange(observable: IObservable, change: TChange): void { + if (this.dependencies.has(observable) && !this.dependenciesToBeRemoved.has(observable)) { + const shouldReact = this._handleChange ? this._handleChange({ + changedObservable: observable, + change, + didChange: (o): this is any => o === observable as any, + }, this.changeSummary!) : true; + const wasUpToDate = this.state === DerivedState.upToDate; + if (shouldReact && (this.state === DerivedState.dependenciesMightHaveChanged || wasUpToDate)) { + this.state = DerivedState.stale; + if (wasUpToDate) { + for (const r of this.observers) { + r.handlePossibleChange(this); + } + } + } + } + } + + // IReader Implementation + public readObservable(observable: IObservable): T { + // Subscribe before getting the value to enable caching + observable.addObserver(this); + /** This might call {@link handleChange} indirectly, which could invalidate us */ + const value = observable.get(); + // Which is why we only add the observable to the dependencies now. + this.dependencies.add(observable); + this.dependenciesToBeRemoved.delete(observable); + return value; + } + + public override addObserver(observer: IObserver): void { + const shouldCallBeginUpdate = !this.observers.has(observer) && this.updateCount > 0; + super.addObserver(observer); + + if (shouldCallBeginUpdate) { + observer.beginUpdate(this); + } + } + + public override removeObserver(observer: IObserver): void { + const shouldCallEndUpdate = this.observers.has(observer) && this.updateCount > 0; + super.removeObserver(observer); + + if (shouldCallEndUpdate) { + // Calling end update after removing the observer makes sure endUpdate cannot be called twice here. + observer.endUpdate(this); + } + } +} + + +export class DerivedWithSetter extends Derived implements ISettableObservable { + constructor( + debugNameData: DebugNameData, + computeFn: (reader: IReader, changeSummary: TChangeSummary) => T, + createChangeSummary: (() => TChangeSummary) | undefined, + handleChange: ((context: IChangeContext, summary: TChangeSummary) => boolean) | undefined, + handleLastObserverRemoved: (() => void) | undefined = undefined, + equalityComparator: EqualityComparer, + public readonly set: (value: T, tx: ITransaction | undefined) => void, + ) { + super( + debugNameData, + computeFn, + createChangeSummary, + handleChange, + handleLastObserverRemoved, + equalityComparator, + ); + } +} diff --git a/src/vs/base/common/observableInternal/lazyObservableValue.ts b/src/vs/base/common/observableInternal/lazyObservableValue.ts new file mode 100644 index 0000000000..1c35f45816 --- /dev/null +++ b/src/vs/base/common/observableInternal/lazyObservableValue.ts @@ -0,0 +1,146 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { EqualityComparer } from 'vs/base/common/equals'; +import { ISettableObservable, ITransaction } from 'vs/base/common/observable'; +import { BaseObservable, IObserver, TransactionImpl } from 'vs/base/common/observableInternal/base'; +import { DebugNameData } from 'vs/base/common/observableInternal/debugName'; + +/** + * Holds off updating observers until the value is actually read. +*/ +export class LazyObservableValue + extends BaseObservable + implements ISettableObservable { + protected _value: T; + private _isUpToDate = true; + private readonly _deltas: TChange[] = []; + + get debugName() { + return this._debugNameData.getDebugName(this) ?? 'LazyObservableValue'; + } + + constructor( + private readonly _debugNameData: DebugNameData, + initialValue: T, + private readonly _equalityComparator: EqualityComparer, + ) { + super(); + this._value = initialValue; + } + + public override get(): T { + this._update(); + return this._value; + } + + private _update(): void { + if (this._isUpToDate) { + return; + } + this._isUpToDate = true; + + if (this._deltas.length > 0) { + for (const observer of this.observers) { + for (const change of this._deltas) { + observer.handleChange(this, change); + } + } + this._deltas.length = 0; + } else { + for (const observer of this.observers) { + observer.handleChange(this, undefined); + } + } + } + + private _updateCounter = 0; + + private _beginUpdate(): void { + this._updateCounter++; + if (this._updateCounter === 1) { + for (const observer of this.observers) { + observer.beginUpdate(this); + } + } + } + + private _endUpdate(): void { + this._updateCounter--; + if (this._updateCounter === 0) { + this._update(); + + // End update could change the observer list. + const observers = [...this.observers]; + for (const r of observers) { + r.endUpdate(this); + } + } + } + + public override addObserver(observer: IObserver): void { + const shouldCallBeginUpdate = !this.observers.has(observer) && this._updateCounter > 0; + super.addObserver(observer); + + if (shouldCallBeginUpdate) { + observer.beginUpdate(this); + } + } + + public override removeObserver(observer: IObserver): void { + const shouldCallEndUpdate = this.observers.has(observer) && this._updateCounter > 0; + super.removeObserver(observer); + + if (shouldCallEndUpdate) { + // Calling end update after removing the observer makes sure endUpdate cannot be called twice here. + observer.endUpdate(this); + } + } + + public set(value: T, tx: ITransaction | undefined, change: TChange): void { + if (change === undefined && this._equalityComparator(this._value, value)) { + return; + } + + let _tx: TransactionImpl | undefined; + if (!tx) { + tx = _tx = new TransactionImpl(() => { }, () => `Setting ${this.debugName}`); + } + try { + this._isUpToDate = false; + this._setValue(value); + if (change !== undefined) { + this._deltas.push(change); + } + + tx.updateObserver({ + beginUpdate: () => this._beginUpdate(), + endUpdate: () => this._endUpdate(), + handleChange: (observable, change) => { }, + handlePossibleChange: (observable) => { }, + }, this); + + if (this._updateCounter > 1) { + // We already started begin/end update, so we need to manually call handlePossibleChange + for (const observer of this.observers) { + observer.handlePossibleChange(this); + } + } + + } finally { + if (_tx) { + _tx.finish(); + } + } + } + + override toString(): string { + return `${this.debugName}: ${this._value}`; + } + + protected _setValue(newValue: T): void { + this._value = newValue; + } +} diff --git a/src/vs/base/common/observableInternal/logging.ts b/src/vs/base/common/observableInternal/logging.ts new file mode 100644 index 0000000000..5e4712e692 --- /dev/null +++ b/src/vs/base/common/observableInternal/logging.ts @@ -0,0 +1,328 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { AutorunObserver } from 'vs/base/common/observableInternal/autorun'; +import { IObservable, ObservableValue, TransactionImpl } from 'vs/base/common/observableInternal/base'; +import { Derived } from 'vs/base/common/observableInternal/derived'; +import { FromEventObservable } from 'vs/base/common/observableInternal/utils'; + +let globalObservableLogger: IObservableLogger | undefined; + +export function setLogger(logger: IObservableLogger): void { + globalObservableLogger = logger; +} + +export function getLogger(): IObservableLogger | undefined { + return globalObservableLogger; +} + +interface IChangeInformation { + oldValue: unknown; + newValue: unknown; + change: unknown; + didChange: boolean; + hadValue: boolean; +} + +export interface IObservableLogger { + handleObservableChanged(observable: ObservableValue, info: IChangeInformation): void; + handleFromEventObservableTriggered(observable: FromEventObservable, info: IChangeInformation): void; + + handleAutorunCreated(autorun: AutorunObserver): void; + handleAutorunTriggered(autorun: AutorunObserver): void; + handleAutorunFinished(autorun: AutorunObserver): void; + + handleDerivedCreated(observable: Derived): void; + handleDerivedRecomputed(observable: Derived, info: IChangeInformation): void; + + handleBeginTransaction(transaction: TransactionImpl): void; + handleEndTransaction(): void; +} + +export class ConsoleObservableLogger implements IObservableLogger { + private indentation = 0; + + private textToConsoleArgs(text: ConsoleText): unknown[] { + return consoleTextToArgs([ + normalText(repeat('| ', this.indentation)), + text, + ]); + } + + private formatInfo(info: IChangeInformation): ConsoleText[] { + if (!info.hadValue) { + return [ + normalText(` `), + styled(formatValue(info.newValue, 60), { + color: 'green', + }), + normalText(` (initial)`), + ]; + } + return info.didChange + ? [ + normalText(` `), + styled(formatValue(info.oldValue, 70), { + color: 'red', + strikeThrough: true, + }), + normalText(` `), + styled(formatValue(info.newValue, 60), { + color: 'green', + }), + ] + : [normalText(` (unchanged)`)]; + } + + handleObservableChanged(observable: IObservable, info: IChangeInformation): void { + console.log(...this.textToConsoleArgs([ + formatKind('observable value changed'), + styled(observable.debugName, { color: 'BlueViolet' }), + ...this.formatInfo(info), + ])); + } + + private readonly changedObservablesSets = new WeakMap>>(); + + formatChanges(changes: Set>): ConsoleText | undefined { + if (changes.size === 0) { + return undefined; + } + return styled( + ' (changed deps: ' + + [...changes].map((o) => o.debugName).join(', ') + + ')', + { color: 'gray' } + ); + } + + handleDerivedCreated(derived: Derived): void { + const existingHandleChange = derived.handleChange; + this.changedObservablesSets.set(derived, new Set()); + derived.handleChange = (observable, change) => { + this.changedObservablesSets.get(derived)!.add(observable); + return existingHandleChange.apply(derived, [observable, change]); + }; + } + + handleDerivedRecomputed(derived: Derived, info: IChangeInformation): void { + const changedObservables = this.changedObservablesSets.get(derived)!; + console.log(...this.textToConsoleArgs([ + formatKind('derived recomputed'), + styled(derived.debugName, { color: 'BlueViolet' }), + ...this.formatInfo(info), + this.formatChanges(changedObservables), + { data: [{ fn: derived._debugNameData.referenceFn ?? derived._computeFn }] } + ])); + changedObservables.clear(); + } + + handleFromEventObservableTriggered(observable: FromEventObservable, info: IChangeInformation): void { + console.log(...this.textToConsoleArgs([ + formatKind('observable from event triggered'), + styled(observable.debugName, { color: 'BlueViolet' }), + ...this.formatInfo(info), + { data: [{ fn: observable._getValue }] } + ])); + } + + handleAutorunCreated(autorun: AutorunObserver): void { + const existingHandleChange = autorun.handleChange; + this.changedObservablesSets.set(autorun, new Set()); + autorun.handleChange = (observable, change) => { + this.changedObservablesSets.get(autorun)!.add(observable); + return existingHandleChange.apply(autorun, [observable, change]); + }; + } + + handleAutorunTriggered(autorun: AutorunObserver): void { + const changedObservables = this.changedObservablesSets.get(autorun)!; + console.log(...this.textToConsoleArgs([ + formatKind('autorun'), + styled(autorun.debugName, { color: 'BlueViolet' }), + this.formatChanges(changedObservables), + { data: [{ fn: autorun._debugNameData.referenceFn ?? autorun._runFn }] } + ])); + changedObservables.clear(); + this.indentation++; + } + + handleAutorunFinished(autorun: AutorunObserver): void { + this.indentation--; + } + + handleBeginTransaction(transaction: TransactionImpl): void { + let transactionName = transaction.getDebugName(); + if (transactionName === undefined) { + transactionName = ''; + } + console.log(...this.textToConsoleArgs([ + formatKind('transaction'), + styled(transactionName, { color: 'BlueViolet' }), + { data: [{ fn: transaction._fn }] } + ])); + this.indentation++; + } + + handleEndTransaction(): void { + this.indentation--; + } +} + +type ConsoleText = + | (ConsoleText | undefined)[] + | { text: string; style: string; data?: unknown[] } + | { data: unknown[] }; + +function consoleTextToArgs(text: ConsoleText): unknown[] { + const styles = new Array(); + const data: unknown[] = []; + let firstArg = ''; + + function process(t: ConsoleText): void { + if ('length' in t) { + for (const item of t) { + if (item) { + process(item); + } + } + } else if ('text' in t) { + firstArg += `%c${t.text}`; + styles.push(t.style); + if (t.data) { + data.push(...t.data); + } + } else if ('data' in t) { + data.push(...t.data); + } + } + + process(text); + + const result = [firstArg, ...styles]; + result.push(...data); + return result; +} + +function normalText(text: string): ConsoleText { + return styled(text, { color: 'black' }); +} + +function formatKind(kind: string): ConsoleText { + return styled(padStr(`${kind}: `, 10), { color: 'black', bold: true }); +} + +function styled( + text: string, + options: { color: string; strikeThrough?: boolean; bold?: boolean } = { + color: 'black', + } +): ConsoleText { + function objToCss(styleObj: Record): string { + return Object.entries(styleObj).reduce( + (styleString, [propName, propValue]) => { + return `${styleString}${propName}:${propValue};`; + }, + '' + ); + } + + const style: Record = { + color: options.color, + }; + if (options.strikeThrough) { + style['text-decoration'] = 'line-through'; + } + if (options.bold) { + style['font-weight'] = 'bold'; + } + + return { + text, + style: objToCss(style), + }; +} + +function formatValue(value: unknown, availableLen: number): string { + switch (typeof value) { + case 'number': + return '' + value; + case 'string': + if (value.length + 2 <= availableLen) { + return `"${value}"`; + } + return `"${value.substr(0, availableLen - 7)}"+...`; + + case 'boolean': + return value ? 'true' : 'false'; + case 'undefined': + return 'undefined'; + case 'object': + if (value === null) { + return 'null'; + } + if (Array.isArray(value)) { + return formatArray(value, availableLen); + } + return formatObject(value, availableLen); + case 'symbol': + return value.toString(); + case 'function': + return `[[Function${value.name ? ' ' + value.name : ''}]]`; + default: + return '' + value; + } +} + +function formatArray(value: unknown[], availableLen: number): string { + let result = '[ '; + let first = true; + for (const val of value) { + if (!first) { + result += ', '; + } + if (result.length - 5 > availableLen) { + result += '...'; + break; + } + first = false; + result += `${formatValue(val, availableLen - result.length)}`; + } + result += ' ]'; + return result; +} + +function formatObject(value: object, availableLen: number): string { + let result = '{ '; + let first = true; + for (const [key, val] of Object.entries(value)) { + if (!first) { + result += ', '; + } + if (result.length - 5 > availableLen) { + result += '...'; + break; + } + first = false; + result += `${key}: ${formatValue(val, availableLen - result.length)}`; + } + result += ' }'; + return result; +} + +function repeat(str: string, count: number): string { + let result = ''; + for (let i = 1; i <= count; i++) { + result += str; + } + return result; +} + +function padStr(str: string, length: number): string { + while (str.length < length) { + str += ' '; + } + return str; +} diff --git a/src/vs/base/common/observableInternal/promise.ts b/src/vs/base/common/observableInternal/promise.ts new file mode 100644 index 0000000000..80d269c16b --- /dev/null +++ b/src/vs/base/common/observableInternal/promise.ts @@ -0,0 +1,209 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import { autorun } from 'vs/base/common/observableInternal/autorun'; +import { IObservable, IReader, observableValue, transaction } from './base'; +import { Derived, derived } from 'vs/base/common/observableInternal/derived'; +import { CancellationToken, CancellationTokenSource } from 'vs/base/common/cancellation'; +import { DebugNameData, DebugOwner } from 'vs/base/common/observableInternal/debugName'; +import { strictEquals } from 'vs/base/common/equals'; +import { CancellationError } from 'vs/base/common/errors'; + +export class ObservableLazy { + private readonly _value = observableValue(this, undefined); + + /** + * The cached value. + * Does not force a computation of the value. + */ + public get cachedValue(): IObservable { return this._value; } + + constructor(private readonly _computeValue: () => T) { + } + + /** + * Returns the cached value. + * Computes the value if the value has not been cached yet. + */ + public getValue() { + let v = this._value.get(); + if (!v) { + v = this._computeValue(); + this._value.set(v, undefined); + } + return v; + } +} + +/** + * A promise whose state is observable. + */ +export class ObservablePromise { + public static fromFn(fn: () => Promise): ObservablePromise { + return new ObservablePromise(fn()); + } + + private readonly _value = observableValue | undefined>(this, undefined); + + /** + * The promise that this object wraps. + */ + public readonly promise: Promise; + + /** + * The current state of the promise. + * Is `undefined` if the promise didn't resolve yet. + */ + public readonly promiseResult: IObservable | undefined> = this._value; + + constructor(promise: Promise) { + this.promise = promise.then(value => { + transaction(tx => { + /** @description onPromiseResolved */ + this._value.set(new PromiseResult(value, undefined), tx); + }); + return value; + }, error => { + transaction(tx => { + /** @description onPromiseRejected */ + this._value.set(new PromiseResult(undefined, error), tx); + }); + throw error; + }); + } +} + +export class PromiseResult { + constructor( + /** + * The value of the resolved promise. + * Undefined if the promise rejected. + */ + public readonly data: T | undefined, + + /** + * The error in case of a rejected promise. + * Undefined if the promise resolved. + */ + public readonly error: unknown | undefined, + ) { + } + + /** + * Returns the value if the promise resolved, otherwise throws the error. + */ + public getDataOrThrow(): T { + if (this.error) { + throw this.error; + } + return this.data!; + } +} + +/** + * A lazy promise whose state is observable. + */ +export class ObservableLazyPromise { + private readonly _lazyValue = new ObservableLazy(() => new ObservablePromise(this._computePromise())); + + /** + * Does not enforce evaluation of the promise compute function. + * Is undefined if the promise has not been computed yet. + */ + public readonly cachedPromiseResult = derived(this, reader => this._lazyValue.cachedValue.read(reader)?.promiseResult.read(reader)); + + constructor(private readonly _computePromise: () => Promise) { + } + + public getPromise(): Promise { + return this._lazyValue.getValue().promise; + } +} + +/** + * Resolves the promise when the observables state matches the predicate. + */ +export function waitForState(observable: IObservable): Promise; +export function waitForState(observable: IObservable, predicate: (state: T) => state is TState, isError?: (state: T) => boolean | unknown | undefined, cancellationToken?: CancellationToken): Promise; +export function waitForState(observable: IObservable, predicate: (state: T) => boolean, isError?: (state: T) => boolean | unknown | undefined, cancellationToken?: CancellationToken): Promise; +export function waitForState(observable: IObservable, predicate?: (state: T) => boolean, isError?: (state: T) => boolean | unknown | undefined, cancellationToken?: CancellationToken): Promise { + if (!predicate) { + predicate = state => state !== null && state !== undefined; + } + return new Promise((resolve, reject) => { + let isImmediateRun = true; + let shouldDispose = false; + const stateObs = observable.map(state => { + /** @description waitForState.state */ + return { + isFinished: predicate(state), + error: isError ? isError(state) : false, + state + }; + }); + const d = autorun(reader => { + /** @description waitForState */ + const { isFinished, error, state } = stateObs.read(reader); + if (isFinished || error) { + if (isImmediateRun) { + // The variable `d` is not initialized yet + shouldDispose = true; + } else { + d.dispose(); + } + if (error) { + reject(error === true ? state : error); + } else { + resolve(state); + } + } + }); + if (cancellationToken) { + const dc = cancellationToken.onCancellationRequested(() => { + d.dispose(); + dc.dispose(); + reject(new CancellationError()); + }); + if (cancellationToken.isCancellationRequested) { + d.dispose(); + dc.dispose(); + reject(new CancellationError()); + return; + } + } + isImmediateRun = false; + if (shouldDispose) { + d.dispose(); + } + }); +} + +export function derivedWithCancellationToken(computeFn: (reader: IReader, cancellationToken: CancellationToken) => T): IObservable; +export function derivedWithCancellationToken(owner: object, computeFn: (reader: IReader, cancellationToken: CancellationToken) => T): IObservable; +export function derivedWithCancellationToken(computeFnOrOwner: ((reader: IReader, cancellationToken: CancellationToken) => T) | object, computeFnOrUndefined?: ((reader: IReader, cancellationToken: CancellationToken) => T)): IObservable { + let computeFn: (reader: IReader, store: CancellationToken) => T; + let owner: DebugOwner; + if (computeFnOrUndefined === undefined) { + computeFn = computeFnOrOwner as any; + owner = undefined; + } else { + owner = computeFnOrOwner; + computeFn = computeFnOrUndefined as any; + } + + let cancellationTokenSource: CancellationTokenSource | undefined = undefined; + return new Derived( + new DebugNameData(owner, undefined, computeFn), + r => { + if (cancellationTokenSource) { + cancellationTokenSource.dispose(true); + } + cancellationTokenSource = new CancellationTokenSource(); + return computeFn(r, cancellationTokenSource.token); + }, undefined, + undefined, + () => cancellationTokenSource?.dispose(), + strictEquals, + ); +} diff --git a/src/vs/base/common/observableInternal/utils.ts b/src/vs/base/common/observableInternal/utils.ts new file mode 100644 index 0000000000..1d012de3d5 --- /dev/null +++ b/src/vs/base/common/observableInternal/utils.ts @@ -0,0 +1,610 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Event } from 'vs/base/common/event'; +import { DisposableStore, IDisposable, toDisposable } from 'vs/base/common/lifecycle'; +import { autorun, autorunOpts } from 'vs/base/common/observableInternal/autorun'; +import { BaseObservable, ConvenientObservable, IObservable, IObserver, IReader, ITransaction, _setKeepObserved, _setRecomputeInitiallyAndOnChange, observableValue, subtransaction, transaction } from 'vs/base/common/observableInternal/base'; +import { DebugNameData, IDebugNameData, DebugOwner, getDebugName, } from 'vs/base/common/observableInternal/debugName'; +import { derived, derivedOpts } from 'vs/base/common/observableInternal/derived'; +import { getLogger } from 'vs/base/common/observableInternal/logging'; +import { IValueWithChangeEvent } from '../event'; +import { BugIndicatingError } from 'vs/base/common/errors'; +import { EqualityComparer, strictEquals } from 'vs/base/common/equals'; + +/** + * Represents an efficient observable whose value never changes. + */ +export function constObservable(value: T): IObservable { + return new ConstObservable(value); +} + +class ConstObservable extends ConvenientObservable { + constructor(private readonly value: T) { + super(); + } + + public override get debugName(): string { + return this.toString(); + } + + public get(): T { + return this.value; + } + public addObserver(observer: IObserver): void { + // NO OP + } + public removeObserver(observer: IObserver): void { + // NO OP + } + + override toString(): string { + return `Const: ${this.value}`; + } +} + + +export function observableFromPromise(promise: Promise): IObservable<{ value?: T }> { + const observable = observableValue<{ value?: T }>('promiseValue', {}); + promise.then((value) => { + observable.set({ value }, undefined); + }); + return observable; +} + + +export function observableFromEvent( + owner: DebugOwner, + event: Event, + getValue: (args: TArgs | undefined) => T, +): IObservable; +export function observableFromEvent( + event: Event, + getValue: (args: TArgs | undefined) => T, +): IObservable; +export function observableFromEvent(...args: + [owner: DebugOwner, event: Event, getValue: (args: any | undefined) => any] + | [event: Event, getValue: (args: any | undefined) => any] +): IObservable { + let owner; + let event; + let getValue; + if (args.length === 3) { + [owner, event, getValue] = args; + } else { + [event, getValue] = args; + } + return new FromEventObservable( + new DebugNameData(owner, undefined, getValue), + event, + getValue, + () => FromEventObservable.globalTransaction, + strictEquals + ); +} + +export function observableFromEventOpts( + options: IDebugNameData & { + equalsFn?: EqualityComparer; + }, + event: Event, + getValue: (args: TArgs | undefined) => T, +): IObservable { + return new FromEventObservable( + new DebugNameData(options.owner, options.debugName, options.debugReferenceFn ?? getValue), + event, + getValue, () => FromEventObservable.globalTransaction, options.equalsFn ?? strictEquals + ); +} + +export class FromEventObservable extends BaseObservable { + public static globalTransaction: ITransaction | undefined; + + private value: T | undefined; + private hasValue = false; + private subscription: IDisposable | undefined; + + constructor( + private readonly _debugNameData: DebugNameData, + private readonly event: Event, + public readonly _getValue: (args: TArgs | undefined) => T, + private readonly _getTransaction: () => ITransaction | undefined, + private readonly _equalityComparator: EqualityComparer + ) { + super(); + } + + private getDebugName(): string | undefined { + return this._debugNameData.getDebugName(this); + } + + public get debugName(): string { + const name = this.getDebugName(); + return 'From Event' + (name ? `: ${name}` : ''); + } + + protected override onFirstObserverAdded(): void { + this.subscription = this.event(this.handleEvent); + } + + private readonly handleEvent = (args: TArgs | undefined) => { + const newValue = this._getValue(args); + const oldValue = this.value; + + const didChange = !this.hasValue || !(this._equalityComparator(oldValue!, newValue)); + let didRunTransaction = false; + + if (didChange) { + this.value = newValue; + + if (this.hasValue) { + didRunTransaction = true; + subtransaction( + this._getTransaction(), + (tx) => { + getLogger()?.handleFromEventObservableTriggered(this, { oldValue, newValue, change: undefined, didChange, hadValue: this.hasValue }); + + for (const o of this.observers) { + tx.updateObserver(o, this); + o.handleChange(this, undefined); + } + }, + () => { + const name = this.getDebugName(); + return 'Event fired' + (name ? `: ${name}` : ''); + } + ); + } + this.hasValue = true; + } + + if (!didRunTransaction) { + getLogger()?.handleFromEventObservableTriggered(this, { oldValue, newValue, change: undefined, didChange, hadValue: this.hasValue }); + } + }; + + protected override onLastObserverRemoved(): void { + this.subscription!.dispose(); + this.subscription = undefined; + this.hasValue = false; + this.value = undefined; + } + + public get(): T { + if (this.subscription) { + if (!this.hasValue) { + this.handleEvent(undefined); + } + return this.value!; + } else { + // no cache, as there are no subscribers to keep it updated + const value = this._getValue(undefined); + return value; + } + } +} + +export namespace observableFromEvent { + export const Observer = FromEventObservable; + + export function batchEventsGlobally(tx: ITransaction, fn: () => void): void { + let didSet = false; + if (FromEventObservable.globalTransaction === undefined) { + FromEventObservable.globalTransaction = tx; + didSet = true; + } + try { + fn(); + } finally { + if (didSet) { + FromEventObservable.globalTransaction = undefined; + } + } + } +} + +export function observableSignalFromEvent( + debugName: string, + event: Event +): IObservable { + return new FromEventObservableSignal(debugName, event); +} + +class FromEventObservableSignal extends BaseObservable { + private subscription: IDisposable | undefined; + + constructor( + public readonly debugName: string, + private readonly event: Event, + ) { + super(); + } + + protected override onFirstObserverAdded(): void { + this.subscription = this.event(this.handleEvent); + } + + private readonly handleEvent = () => { + transaction( + (tx) => { + for (const o of this.observers) { + tx.updateObserver(o, this); + o.handleChange(this, undefined); + } + }, + () => this.debugName + ); + }; + + protected override onLastObserverRemoved(): void { + this.subscription!.dispose(); + this.subscription = undefined; + } + + public override get(): void { + // NO OP + } +} + +/** + * Creates a signal that can be triggered to invalidate observers. + * Signals don't have a value - when they are triggered they indicate a change. + * However, signals can carry a delta that is passed to observers. + */ +export function observableSignal(debugName: string): IObservableSignal; +export function observableSignal(owner: object): IObservableSignal; +export function observableSignal(debugNameOrOwner: string | object): IObservableSignal { + if (typeof debugNameOrOwner === 'string') { + return new ObservableSignal(debugNameOrOwner); + } else { + return new ObservableSignal(undefined, debugNameOrOwner); + } +} + +export interface IObservableSignal extends IObservable { + trigger(tx: ITransaction | undefined, change: TChange): void; +} + +class ObservableSignal extends BaseObservable implements IObservableSignal { + public get debugName() { + return new DebugNameData(this._owner, this._debugName, undefined).getDebugName(this) ?? 'Observable Signal'; + } + + public override toString(): string { + return this.debugName; + } + + constructor( + private readonly _debugName: string | undefined, + private readonly _owner?: object, + ) { + super(); + } + + public trigger(tx: ITransaction | undefined, change: TChange): void { + if (!tx) { + transaction(tx => { + this.trigger(tx, change); + }, () => `Trigger signal ${this.debugName}`); + return; + } + + for (const o of this.observers) { + tx.updateObserver(o, this); + o.handleChange(this, change); + } + } + + public override get(): void { + // NO OP + } +} + +/** + * @deprecated Use `debouncedObservable2` instead. + */ +export function debouncedObservable(observable: IObservable, debounceMs: number, disposableStore: DisposableStore): IObservable { + const debouncedObservable = observableValue('debounced', undefined); + + let timeout: any = undefined; + + disposableStore.add(autorun(reader => { + /** @description debounce */ + const value = observable.read(reader); + + if (timeout) { + clearTimeout(timeout); + } + timeout = setTimeout(() => { + transaction(tx => { + debouncedObservable.set(value, tx); + }); + }, debounceMs); + + })); + + return debouncedObservable; +} + +/** + * Creates an observable that debounces the input observable. + */ +export function debouncedObservable2(observable: IObservable, debounceMs: number): IObservable { + let hasValue = false; + let lastValue: T | undefined; + + let timeout: any = undefined; + + return observableFromEvent(cb => { + const d = autorun(reader => { + const value = observable.read(reader); + + if (!hasValue) { + hasValue = true; + lastValue = value; + } else { + if (timeout) { + clearTimeout(timeout); + } + timeout = setTimeout(() => { + lastValue = value; + cb(); + }, debounceMs); + } + }); + return { + dispose() { + d.dispose(); + hasValue = false; + lastValue = undefined; + }, + }; + }, () => { + if (hasValue) { + return lastValue!; + } else { + return observable.get(); + } + }); +} + +export function wasEventTriggeredRecently(event: Event, timeoutMs: number, disposableStore: DisposableStore): IObservable { + const observable = observableValue('triggeredRecently', false); + + let timeout: any = undefined; + + disposableStore.add(event(() => { + observable.set(true, undefined); + + if (timeout) { + clearTimeout(timeout); + } + timeout = setTimeout(() => { + observable.set(false, undefined); + }, timeoutMs); + })); + + return observable; +} + +/** + * This makes sure the observable is being observed and keeps its cache alive. + */ +export function keepObserved(observable: IObservable): IDisposable { + const o = new KeepAliveObserver(false, undefined); + observable.addObserver(o); + return toDisposable(() => { + observable.removeObserver(o); + }); +} + +_setKeepObserved(keepObserved); + +/** + * This converts the given observable into an autorun. + */ +export function recomputeInitiallyAndOnChange(observable: IObservable, handleValue?: (value: T) => void): IDisposable { + const o = new KeepAliveObserver(true, handleValue); + observable.addObserver(o); + if (handleValue) { + handleValue(observable.get()); + } else { + observable.reportChanges(); + } + + return toDisposable(() => { + observable.removeObserver(o); + }); +} + +_setRecomputeInitiallyAndOnChange(recomputeInitiallyAndOnChange); + +export class KeepAliveObserver implements IObserver { + private _counter = 0; + + constructor( + private readonly _forceRecompute: boolean, + private readonly _handleValue: ((value: any) => void) | undefined, + ) { } + + beginUpdate(observable: IObservable): void { + this._counter++; + } + + endUpdate(observable: IObservable): void { + this._counter--; + if (this._counter === 0 && this._forceRecompute) { + if (this._handleValue) { + this._handleValue(observable.get()); + } else { + observable.reportChanges(); + } + } + } + + handlePossibleChange(observable: IObservable): void { + // NO OP + } + + handleChange(observable: IObservable, change: TChange): void { + // NO OP + } +} + +export function derivedObservableWithCache(owner: DebugOwner, computeFn: (reader: IReader, lastValue: T | undefined) => T): IObservable { + let lastValue: T | undefined = undefined; + const observable = derivedOpts({ owner, debugReferenceFn: computeFn }, reader => { + lastValue = computeFn(reader, lastValue); + return lastValue; + }); + return observable; +} + +export function derivedObservableWithWritableCache(owner: object, computeFn: (reader: IReader, lastValue: T | undefined) => T): IObservable + & { clearCache(transaction: ITransaction): void; setCache(newValue: T | undefined, tx: ITransaction | undefined): void } { + let lastValue: T | undefined = undefined; + const onChange = observableSignal('derivedObservableWithWritableCache'); + const observable = derived(owner, reader => { + onChange.read(reader); + lastValue = computeFn(reader, lastValue); + return lastValue; + }); + return Object.assign(observable, { + clearCache: (tx: ITransaction) => { + lastValue = undefined; + onChange.trigger(tx); + }, + setCache: (newValue: T | undefined, tx: ITransaction | undefined) => { + lastValue = newValue; + onChange.trigger(tx); + } + }); +} + +/** + * When the items array changes, referential equal items are not mapped again. + */ +export function mapObservableArrayCached(owner: DebugOwner, items: IObservable, map: (input: TIn, store: DisposableStore) => TOut, keySelector?: (input: TIn) => TKey): IObservable { + let m = new ArrayMap(map, keySelector); + const self = derivedOpts({ + debugReferenceFn: map, + owner, + onLastObserverRemoved: () => { + m.dispose(); + m = new ArrayMap(map); + } + }, (reader) => { + m.setItems(items.read(reader)); + return m.getItems(); + }); + return self; +} + +class ArrayMap implements IDisposable { + private readonly _cache = new Map(); + private _items: TOut[] = []; + constructor( + private readonly _map: (input: TIn, store: DisposableStore) => TOut, + private readonly _keySelector?: (input: TIn) => TKey, + ) { + } + + public dispose(): void { + this._cache.forEach(entry => entry.store.dispose()); + this._cache.clear(); + } + + public setItems(items: readonly TIn[]): void { + const newItems: TOut[] = []; + const itemsToRemove = new Set(this._cache.keys()); + + for (const item of items) { + const key = this._keySelector ? this._keySelector(item) : item as unknown as TKey; + + let entry = this._cache.get(key); + if (!entry) { + const store = new DisposableStore(); + const out = this._map(item, store); + entry = { out, store }; + this._cache.set(key, entry); + } else { + itemsToRemove.delete(key); + } + newItems.push(entry.out); + } + + for (const item of itemsToRemove) { + const entry = this._cache.get(item)!; + entry.store.dispose(); + this._cache.delete(item); + } + + this._items = newItems; + } + + public getItems(): TOut[] { + return this._items; + } +} + +export class ValueWithChangeEventFromObservable implements IValueWithChangeEvent { + constructor(public readonly observable: IObservable) { + } + + get onDidChange(): Event { + return Event.fromObservableLight(this.observable); + } + + get value(): T { + return this.observable.get(); + } +} + +export function observableFromValueWithChangeEvent(owner: DebugOwner, value: IValueWithChangeEvent): IObservable { + if (value instanceof ValueWithChangeEventFromObservable) { + return value.observable; + } + return observableFromEvent(owner, value.onDidChange, () => value.value); +} + +/** + * Creates an observable that has the latest changed value of the given observables. + * Initially (and when not observed), it has the value of the last observable. + * When observed and any of the observables change, it has the value of the last changed observable. + * If multiple observables change in the same transaction, the last observable wins. +*/ +export function latestChangedValue[]>(owner: DebugOwner, observables: T): IObservable> { + if (observables.length === 0) { + throw new BugIndicatingError(); + } + + let hasLastChangedValue = false; + let lastChangedValue: any = undefined; + + const result = observableFromEvent(owner, cb => { + const store = new DisposableStore(); + for (const o of observables) { + store.add(autorunOpts({ debugName: () => getDebugName(result, new DebugNameData(owner, undefined, undefined)) + '.updateLastChangedValue' }, reader => { + hasLastChangedValue = true; + lastChangedValue = o.read(reader); + cb(); + })); + } + store.add({ + dispose() { + hasLastChangedValue = false; + lastChangedValue = undefined; + }, + }); + return store; + }, () => { + if (hasLastChangedValue) { + return lastChangedValue; + } else { + return observables[observables.length - 1].get(); + } + }); + return result; +} diff --git a/src/vs/base/common/paging.ts b/src/vs/base/common/paging.ts new file mode 100644 index 0000000000..c13957cc0d --- /dev/null +++ b/src/vs/base/common/paging.ts @@ -0,0 +1,189 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { range } from 'vs/base/common/arrays'; +import { CancellationToken, CancellationTokenSource } from 'vs/base/common/cancellation'; +import { CancellationError } from 'vs/base/common/errors'; + +/** + * A Pager is a stateless abstraction over a paged collection. + */ +export interface IPager { + firstPage: T[]; + total: number; + pageSize: number; + getPage(pageIndex: number, cancellationToken: CancellationToken): Promise; +} + +interface IPage { + isResolved: boolean; + promise: Promise | null; + cts: CancellationTokenSource | null; + promiseIndexes: Set; + elements: T[]; +} + +function createPage(elements?: T[]): IPage { + return { + isResolved: !!elements, + promise: null, + cts: null, + promiseIndexes: new Set(), + elements: elements || [] + }; +} + +/** + * A PagedModel is a stateful model over an abstracted paged collection. + */ +export interface IPagedModel { + length: number; + isResolved(index: number): boolean; + get(index: number): T; + resolve(index: number, cancellationToken: CancellationToken): Promise; +} + +export function singlePagePager(elements: T[]): IPager { + return { + firstPage: elements, + total: elements.length, + pageSize: elements.length, + getPage: (pageIndex: number, cancellationToken: CancellationToken): Promise => { + return Promise.resolve(elements); + } + }; +} + +export class PagedModel implements IPagedModel { + + private pager: IPager; + private pages: IPage[] = []; + + get length(): number { return this.pager.total; } + + constructor(arg: IPager | T[]) { + this.pager = Array.isArray(arg) ? singlePagePager(arg) : arg; + + const totalPages = Math.ceil(this.pager.total / this.pager.pageSize); + + this.pages = [ + createPage(this.pager.firstPage.slice()), + ...range(totalPages - 1).map(() => createPage()) + ]; + } + + isResolved(index: number): boolean { + const pageIndex = Math.floor(index / this.pager.pageSize); + const page = this.pages[pageIndex]; + + return !!page.isResolved; + } + + get(index: number): T { + const pageIndex = Math.floor(index / this.pager.pageSize); + const indexInPage = index % this.pager.pageSize; + const page = this.pages[pageIndex]; + + return page.elements[indexInPage]; + } + + resolve(index: number, cancellationToken: CancellationToken): Promise { + if (cancellationToken.isCancellationRequested) { + return Promise.reject(new CancellationError()); + } + + const pageIndex = Math.floor(index / this.pager.pageSize); + const indexInPage = index % this.pager.pageSize; + const page = this.pages[pageIndex]; + + if (page.isResolved) { + return Promise.resolve(page.elements[indexInPage]); + } + + if (!page.promise) { + page.cts = new CancellationTokenSource(); + page.promise = this.pager.getPage(pageIndex, page.cts.token) + .then(elements => { + page.elements = elements; + page.isResolved = true; + page.promise = null; + page.cts = null; + }, err => { + page.isResolved = false; + page.promise = null; + page.cts = null; + return Promise.reject(err); + }); + } + + const listener = cancellationToken.onCancellationRequested(() => { + if (!page.cts) { + return; + } + + page.promiseIndexes.delete(index); + + if (page.promiseIndexes.size === 0) { + page.cts.cancel(); + } + }); + + page.promiseIndexes.add(index); + + return page.promise.then(() => page.elements[indexInPage]) + .finally(() => listener.dispose()); + } +} + +export class DelayedPagedModel implements IPagedModel { + + get length(): number { return this.model.length; } + + constructor(private model: IPagedModel, private timeout: number = 500) { } + + isResolved(index: number): boolean { + return this.model.isResolved(index); + } + + get(index: number): T { + return this.model.get(index); + } + + resolve(index: number, cancellationToken: CancellationToken): Promise { + return new Promise((c, e) => { + if (cancellationToken.isCancellationRequested) { + return e(new CancellationError()); + } + + const timer = setTimeout(() => { + if (cancellationToken.isCancellationRequested) { + return e(new CancellationError()); + } + + timeoutCancellation.dispose(); + this.model.resolve(index, cancellationToken).then(c, e); + }, this.timeout); + + const timeoutCancellation = cancellationToken.onCancellationRequested(() => { + clearTimeout(timer); + timeoutCancellation.dispose(); + e(new CancellationError()); + }); + }); + } +} + +/** + * Similar to array.map, `mapPager` lets you map the elements of an + * abstract paged collection to another type. + */ +export function mapPager(pager: IPager, fn: (t: T) => R): IPager { + return { + firstPage: pager.firstPage.map(fn), + total: pager.total, + pageSize: pager.pageSize, + getPage: (pageIndex, token) => pager.getPage(pageIndex, token).then(r => r.map(fn)) + }; +} diff --git a/src/vs/base/common/parsers.ts b/src/vs/base/common/parsers.ts new file mode 100644 index 0000000000..070103093a --- /dev/null +++ b/src/vs/base/common/parsers.ts @@ -0,0 +1,79 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export const enum ValidationState { + OK = 0, + Info = 1, + Warning = 2, + Error = 3, + Fatal = 4 +} + +export class ValidationStatus { + private _state: ValidationState; + + constructor() { + this._state = ValidationState.OK; + } + + public get state(): ValidationState { + return this._state; + } + + public set state(value: ValidationState) { + if (value > this._state) { + this._state = value; + } + } + + public isOK(): boolean { + return this._state === ValidationState.OK; + } + + public isFatal(): boolean { + return this._state === ValidationState.Fatal; + } +} + +export interface IProblemReporter { + info(message: string): void; + warn(message: string): void; + error(message: string): void; + fatal(message: string): void; + status: ValidationStatus; +} + +export abstract class Parser { + + private _problemReporter: IProblemReporter; + + constructor(problemReporter: IProblemReporter) { + this._problemReporter = problemReporter; + } + + public reset(): void { + this._problemReporter.status.state = ValidationState.OK; + } + + public get problemReporter(): IProblemReporter { + return this._problemReporter; + } + + public info(message: string): void { + this._problemReporter.info(message); + } + + public warn(message: string): void { + this._problemReporter.warn(message); + } + + public error(message: string): void { + this._problemReporter.error(message); + } + + public fatal(message: string): void { + this._problemReporter.fatal(message); + } +} diff --git a/src/vs/base/common/path.ts b/src/vs/base/common/path.ts new file mode 100644 index 0000000000..6f40f7d035 --- /dev/null +++ b/src/vs/base/common/path.ts @@ -0,0 +1,1529 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// NOTE: VSCode's copy of nodejs path library to be usable in common (non-node) namespace +// Copied from: https://github.com/nodejs/node/commits/v20.9.0/lib/path.js +// Excluding: the change that adds primordials +// (https://github.com/nodejs/node/commit/187a862d221dec42fa9a5c4214e7034d9092792f and others) + +/** + * Copyright Joyent, Inc. and other Node contributors. + * + * Permission is hereby granted, free of charge, to any person obtaining a + * copy of this software and associated documentation files (the + * "Software"), to deal in the Software without restriction, including + * without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit + * persons to whom the Software is furnished to do so, subject to the + * following conditions: + * + * The above copyright notice and this permission notice shall be included + * in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS + * OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN + * NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR + * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE + * USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import * as process from 'vs/base/common/process'; + +const CHAR_UPPERCASE_A = 65;/* A */ +const CHAR_LOWERCASE_A = 97; /* a */ +const CHAR_UPPERCASE_Z = 90; /* Z */ +const CHAR_LOWERCASE_Z = 122; /* z */ +const CHAR_DOT = 46; /* . */ +const CHAR_FORWARD_SLASH = 47; /* / */ +const CHAR_BACKWARD_SLASH = 92; /* \ */ +const CHAR_COLON = 58; /* : */ +const CHAR_QUESTION_MARK = 63; /* ? */ + +class ErrorInvalidArgType extends Error { + code: 'ERR_INVALID_ARG_TYPE'; + constructor(name: string, expected: string, actual: unknown) { + // determiner: 'must be' or 'must not be' + let determiner; + if (typeof expected === 'string' && expected.indexOf('not ') === 0) { + determiner = 'must not be'; + expected = expected.replace(/^not /, ''); + } else { + determiner = 'must be'; + } + + const type = name.indexOf('.') !== -1 ? 'property' : 'argument'; + let msg = `The "${name}" ${type} ${determiner} of type ${expected}`; + + msg += `. Received type ${typeof actual}`; + super(msg); + + this.code = 'ERR_INVALID_ARG_TYPE'; + } +} + +function validateObject(pathObject: object, name: string) { + if (pathObject === null || typeof pathObject !== 'object') { + throw new ErrorInvalidArgType(name, 'Object', pathObject); + } +} + +function validateString(value: string, name: string) { + if (typeof value !== 'string') { + throw new ErrorInvalidArgType(name, 'string', value); + } +} + +const platformIsWin32 = (process.platform === 'win32'); + +function isPathSeparator(code: number | undefined) { + return code === CHAR_FORWARD_SLASH || code === CHAR_BACKWARD_SLASH; +} + +function isPosixPathSeparator(code: number | undefined) { + return code === CHAR_FORWARD_SLASH; +} + +function isWindowsDeviceRoot(code: number) { + return (code >= CHAR_UPPERCASE_A && code <= CHAR_UPPERCASE_Z) || + (code >= CHAR_LOWERCASE_A && code <= CHAR_LOWERCASE_Z); +} + +// Resolves . and .. elements in a path with directory names +function normalizeString(path: string, allowAboveRoot: boolean, separator: string, isPathSeparator: (code?: number) => boolean) { + let res = ''; + let lastSegmentLength = 0; + let lastSlash = -1; + let dots = 0; + let code = 0; + for (let i = 0; i <= path.length; ++i) { + if (i < path.length) { + code = path.charCodeAt(i); + } + else if (isPathSeparator(code)) { + break; + } + else { + code = CHAR_FORWARD_SLASH; + } + + if (isPathSeparator(code)) { + if (lastSlash === i - 1 || dots === 1) { + // NOOP + } else if (dots === 2) { + if (res.length < 2 || lastSegmentLength !== 2 || + res.charCodeAt(res.length - 1) !== CHAR_DOT || + res.charCodeAt(res.length - 2) !== CHAR_DOT) { + if (res.length > 2) { + const lastSlashIndex = res.lastIndexOf(separator); + if (lastSlashIndex === -1) { + res = ''; + lastSegmentLength = 0; + } else { + res = res.slice(0, lastSlashIndex); + lastSegmentLength = res.length - 1 - res.lastIndexOf(separator); + } + lastSlash = i; + dots = 0; + continue; + } else if (res.length !== 0) { + res = ''; + lastSegmentLength = 0; + lastSlash = i; + dots = 0; + continue; + } + } + if (allowAboveRoot) { + res += res.length > 0 ? `${separator}..` : '..'; + lastSegmentLength = 2; + } + } else { + if (res.length > 0) { + res += `${separator}${path.slice(lastSlash + 1, i)}`; + } + else { + res = path.slice(lastSlash + 1, i); + } + lastSegmentLength = i - lastSlash - 1; + } + lastSlash = i; + dots = 0; + } else if (code === CHAR_DOT && dots !== -1) { + ++dots; + } else { + dots = -1; + } + } + return res; +} + +function formatExt(ext: string): string { + return ext ? `${ext[0] === '.' ? '' : '.'}${ext}` : ''; +} + +function _format(sep: string, pathObject: ParsedPath) { + validateObject(pathObject, 'pathObject'); + const dir = pathObject.dir || pathObject.root; + const base = pathObject.base || + `${pathObject.name || ''}${formatExt(pathObject.ext)}`; + if (!dir) { + return base; + } + return dir === pathObject.root ? `${dir}${base}` : `${dir}${sep}${base}`; +} + +export interface ParsedPath { + root: string; + dir: string; + base: string; + ext: string; + name: string; +} + +export interface IPath { + normalize(path: string): string; + isAbsolute(path: string): boolean; + join(...paths: string[]): string; + resolve(...pathSegments: string[]): string; + relative(from: string, to: string): string; + dirname(path: string): string; + basename(path: string, suffix?: string): string; + extname(path: string): string; + format(pathObject: ParsedPath): string; + parse(path: string): ParsedPath; + toNamespacedPath(path: string): string; + sep: '\\' | '/'; + delimiter: string; + win32: IPath | null; + posix: IPath | null; +} + +export const win32: IPath = { + // path.resolve([from ...], to) + resolve(...pathSegments: string[]): string { + let resolvedDevice = ''; + let resolvedTail = ''; + let resolvedAbsolute = false; + + for (let i = pathSegments.length - 1; i >= -1; i--) { + let path; + if (i >= 0) { + path = pathSegments[i]; + validateString(path, `paths[${i}]`); + + // Skip empty entries + if (path.length === 0) { + continue; + } + } else if (resolvedDevice.length === 0) { + path = process.cwd(); + } else { + // Windows has the concept of drive-specific current working + // directories. If we've resolved a drive letter but not yet an + // absolute path, get cwd for that drive, or the process cwd if + // the drive cwd is not available. We're sure the device is not + // a UNC path at this points, because UNC paths are always absolute. + path = process.env[`=${resolvedDevice}`] || process.cwd(); + + // Verify that a cwd was found and that it actually points + // to our drive. If not, default to the drive's root. + if (path === undefined || + (path.slice(0, 2).toLowerCase() !== resolvedDevice.toLowerCase() && + path.charCodeAt(2) === CHAR_BACKWARD_SLASH)) { + path = `${resolvedDevice}\\`; + } + } + + const len = path.length; + let rootEnd = 0; + let device = ''; + let isAbsolute = false; + const code = path.charCodeAt(0); + + // Try to match a root + if (len === 1) { + if (isPathSeparator(code)) { + // `path` contains just a path separator + rootEnd = 1; + isAbsolute = true; + } + } else if (isPathSeparator(code)) { + // Possible UNC root + + // If we started with a separator, we know we at least have an + // absolute path of some kind (UNC or otherwise) + isAbsolute = true; + + if (isPathSeparator(path.charCodeAt(1))) { + // Matched double path separator at beginning + let j = 2; + let last = j; + // Match 1 or more non-path separators + while (j < len && !isPathSeparator(path.charCodeAt(j))) { + j++; + } + if (j < len && j !== last) { + const firstPart = path.slice(last, j); + // Matched! + last = j; + // Match 1 or more path separators + while (j < len && isPathSeparator(path.charCodeAt(j))) { + j++; + } + if (j < len && j !== last) { + // Matched! + last = j; + // Match 1 or more non-path separators + while (j < len && !isPathSeparator(path.charCodeAt(j))) { + j++; + } + if (j === len || j !== last) { + // We matched a UNC root + device = `\\\\${firstPart}\\${path.slice(last, j)}`; + rootEnd = j; + } + } + } + } else { + rootEnd = 1; + } + } else if (isWindowsDeviceRoot(code) && + path.charCodeAt(1) === CHAR_COLON) { + // Possible device root + device = path.slice(0, 2); + rootEnd = 2; + if (len > 2 && isPathSeparator(path.charCodeAt(2))) { + // Treat separator following drive name as an absolute path + // indicator + isAbsolute = true; + rootEnd = 3; + } + } + + if (device.length > 0) { + if (resolvedDevice.length > 0) { + if (device.toLowerCase() !== resolvedDevice.toLowerCase()) { + // This path points to another device so it is not applicable + continue; + } + } else { + resolvedDevice = device; + } + } + + if (resolvedAbsolute) { + if (resolvedDevice.length > 0) { + break; + } + } else { + resolvedTail = `${path.slice(rootEnd)}\\${resolvedTail}`; + resolvedAbsolute = isAbsolute; + if (isAbsolute && resolvedDevice.length > 0) { + break; + } + } + } + + // At this point the path should be resolved to a full absolute path, + // but handle relative paths to be safe (might happen when process.cwd() + // fails) + + // Normalize the tail path + resolvedTail = normalizeString(resolvedTail, !resolvedAbsolute, '\\', + isPathSeparator); + + return resolvedAbsolute ? + `${resolvedDevice}\\${resolvedTail}` : + `${resolvedDevice}${resolvedTail}` || '.'; + }, + + normalize(path: string): string { + validateString(path, 'path'); + const len = path.length; + if (len === 0) { + return '.'; + } + let rootEnd = 0; + let device; + let isAbsolute = false; + const code = path.charCodeAt(0); + + // Try to match a root + if (len === 1) { + // `path` contains just a single char, exit early to avoid + // unnecessary work + return isPosixPathSeparator(code) ? '\\' : path; + } + if (isPathSeparator(code)) { + // Possible UNC root + + // If we started with a separator, we know we at least have an absolute + // path of some kind (UNC or otherwise) + isAbsolute = true; + + if (isPathSeparator(path.charCodeAt(1))) { + // Matched double path separator at beginning + let j = 2; + let last = j; + // Match 1 or more non-path separators + while (j < len && !isPathSeparator(path.charCodeAt(j))) { + j++; + } + if (j < len && j !== last) { + const firstPart = path.slice(last, j); + // Matched! + last = j; + // Match 1 or more path separators + while (j < len && isPathSeparator(path.charCodeAt(j))) { + j++; + } + if (j < len && j !== last) { + // Matched! + last = j; + // Match 1 or more non-path separators + while (j < len && !isPathSeparator(path.charCodeAt(j))) { + j++; + } + if (j === len) { + // We matched a UNC root only + // Return the normalized version of the UNC root since there + // is nothing left to process + return `\\\\${firstPart}\\${path.slice(last)}\\`; + } + if (j !== last) { + // We matched a UNC root with leftovers + device = `\\\\${firstPart}\\${path.slice(last, j)}`; + rootEnd = j; + } + } + } + } else { + rootEnd = 1; + } + } else if (isWindowsDeviceRoot(code) && path.charCodeAt(1) === CHAR_COLON) { + // Possible device root + device = path.slice(0, 2); + rootEnd = 2; + if (len > 2 && isPathSeparator(path.charCodeAt(2))) { + // Treat separator following drive name as an absolute path + // indicator + isAbsolute = true; + rootEnd = 3; + } + } + + let tail = rootEnd < len ? + normalizeString(path.slice(rootEnd), !isAbsolute, '\\', isPathSeparator) : + ''; + if (tail.length === 0 && !isAbsolute) { + tail = '.'; + } + if (tail.length > 0 && isPathSeparator(path.charCodeAt(len - 1))) { + tail += '\\'; + } + if (device === undefined) { + return isAbsolute ? `\\${tail}` : tail; + } + return isAbsolute ? `${device}\\${tail}` : `${device}${tail}`; + }, + + isAbsolute(path: string): boolean { + validateString(path, 'path'); + const len = path.length; + if (len === 0) { + return false; + } + + const code = path.charCodeAt(0); + return isPathSeparator(code) || + // Possible device root + (len > 2 && + isWindowsDeviceRoot(code) && + path.charCodeAt(1) === CHAR_COLON && + isPathSeparator(path.charCodeAt(2))); + }, + + join(...paths: string[]): string { + if (paths.length === 0) { + return '.'; + } + + let joined; + let firstPart: string | undefined; + for (let i = 0; i < paths.length; ++i) { + const arg = paths[i]; + validateString(arg, 'path'); + if (arg.length > 0) { + if (joined === undefined) { + joined = firstPart = arg; + } + else { + joined += `\\${arg}`; + } + } + } + + if (joined === undefined) { + return '.'; + } + + // Make sure that the joined path doesn't start with two slashes, because + // normalize() will mistake it for a UNC path then. + // + // This step is skipped when it is very clear that the user actually + // intended to point at a UNC path. This is assumed when the first + // non-empty string arguments starts with exactly two slashes followed by + // at least one more non-slash character. + // + // Note that for normalize() to treat a path as a UNC path it needs to + // have at least 2 components, so we don't filter for that here. + // This means that the user can use join to construct UNC paths from + // a server name and a share name; for example: + // path.join('//server', 'share') -> '\\\\server\\share\\') + let needsReplace = true; + let slashCount = 0; + if (typeof firstPart === 'string' && isPathSeparator(firstPart.charCodeAt(0))) { + ++slashCount; + const firstLen = firstPart.length; + if (firstLen > 1 && isPathSeparator(firstPart.charCodeAt(1))) { + ++slashCount; + if (firstLen > 2) { + if (isPathSeparator(firstPart.charCodeAt(2))) { + ++slashCount; + } else { + // We matched a UNC path in the first part + needsReplace = false; + } + } + } + } + if (needsReplace) { + // Find any more consecutive slashes we need to replace + while (slashCount < joined.length && + isPathSeparator(joined.charCodeAt(slashCount))) { + slashCount++; + } + + // Replace the slashes if needed + if (slashCount >= 2) { + joined = `\\${joined.slice(slashCount)}`; + } + } + + return win32.normalize(joined); + }, + + + // It will solve the relative path from `from` to `to`, for instance: + // from = 'C:\\orandea\\test\\aaa' + // to = 'C:\\orandea\\impl\\bbb' + // The output of the function should be: '..\\..\\impl\\bbb' + relative(from: string, to: string): string { + validateString(from, 'from'); + validateString(to, 'to'); + + if (from === to) { + return ''; + } + + const fromOrig = win32.resolve(from); + const toOrig = win32.resolve(to); + + if (fromOrig === toOrig) { + return ''; + } + + from = fromOrig.toLowerCase(); + to = toOrig.toLowerCase(); + + if (from === to) { + return ''; + } + + // Trim any leading backslashes + let fromStart = 0; + while (fromStart < from.length && + from.charCodeAt(fromStart) === CHAR_BACKWARD_SLASH) { + fromStart++; + } + // Trim trailing backslashes (applicable to UNC paths only) + let fromEnd = from.length; + while (fromEnd - 1 > fromStart && + from.charCodeAt(fromEnd - 1) === CHAR_BACKWARD_SLASH) { + fromEnd--; + } + const fromLen = fromEnd - fromStart; + + // Trim any leading backslashes + let toStart = 0; + while (toStart < to.length && + to.charCodeAt(toStart) === CHAR_BACKWARD_SLASH) { + toStart++; + } + // Trim trailing backslashes (applicable to UNC paths only) + let toEnd = to.length; + while (toEnd - 1 > toStart && + to.charCodeAt(toEnd - 1) === CHAR_BACKWARD_SLASH) { + toEnd--; + } + const toLen = toEnd - toStart; + + // Compare paths to find the longest common path from root + const length = fromLen < toLen ? fromLen : toLen; + let lastCommonSep = -1; + let i = 0; + for (; i < length; i++) { + const fromCode = from.charCodeAt(fromStart + i); + if (fromCode !== to.charCodeAt(toStart + i)) { + break; + } else if (fromCode === CHAR_BACKWARD_SLASH) { + lastCommonSep = i; + } + } + + // We found a mismatch before the first common path separator was seen, so + // return the original `to`. + if (i !== length) { + if (lastCommonSep === -1) { + return toOrig; + } + } else { + if (toLen > length) { + if (to.charCodeAt(toStart + i) === CHAR_BACKWARD_SLASH) { + // We get here if `from` is the exact base path for `to`. + // For example: from='C:\\foo\\bar'; to='C:\\foo\\bar\\baz' + return toOrig.slice(toStart + i + 1); + } + if (i === 2) { + // We get here if `from` is the device root. + // For example: from='C:\\'; to='C:\\foo' + return toOrig.slice(toStart + i); + } + } + if (fromLen > length) { + if (from.charCodeAt(fromStart + i) === CHAR_BACKWARD_SLASH) { + // We get here if `to` is the exact base path for `from`. + // For example: from='C:\\foo\\bar'; to='C:\\foo' + lastCommonSep = i; + } else if (i === 2) { + // We get here if `to` is the device root. + // For example: from='C:\\foo\\bar'; to='C:\\' + lastCommonSep = 3; + } + } + if (lastCommonSep === -1) { + lastCommonSep = 0; + } + } + + let out = ''; + // Generate the relative path based on the path difference between `to` and + // `from` + for (i = fromStart + lastCommonSep + 1; i <= fromEnd; ++i) { + if (i === fromEnd || from.charCodeAt(i) === CHAR_BACKWARD_SLASH) { + out += out.length === 0 ? '..' : '\\..'; + } + } + + toStart += lastCommonSep; + + // Lastly, append the rest of the destination (`to`) path that comes after + // the common path parts + if (out.length > 0) { + return `${out}${toOrig.slice(toStart, toEnd)}`; + } + + if (toOrig.charCodeAt(toStart) === CHAR_BACKWARD_SLASH) { + ++toStart; + } + + return toOrig.slice(toStart, toEnd); + }, + + toNamespacedPath(path: string): string { + // Note: this will *probably* throw somewhere. + if (typeof path !== 'string' || path.length === 0) { + return path; + } + + const resolvedPath = win32.resolve(path); + + if (resolvedPath.length <= 2) { + return path; + } + + if (resolvedPath.charCodeAt(0) === CHAR_BACKWARD_SLASH) { + // Possible UNC root + if (resolvedPath.charCodeAt(1) === CHAR_BACKWARD_SLASH) { + const code = resolvedPath.charCodeAt(2); + if (code !== CHAR_QUESTION_MARK && code !== CHAR_DOT) { + // Matched non-long UNC root, convert the path to a long UNC path + return `\\\\?\\UNC\\${resolvedPath.slice(2)}`; + } + } + } else if (isWindowsDeviceRoot(resolvedPath.charCodeAt(0)) && + resolvedPath.charCodeAt(1) === CHAR_COLON && + resolvedPath.charCodeAt(2) === CHAR_BACKWARD_SLASH) { + // Matched device root, convert the path to a long UNC path + return `\\\\?\\${resolvedPath}`; + } + + return path; + }, + + dirname(path: string): string { + validateString(path, 'path'); + const len = path.length; + if (len === 0) { + return '.'; + } + let rootEnd = -1; + let offset = 0; + const code = path.charCodeAt(0); + + if (len === 1) { + // `path` contains just a path separator, exit early to avoid + // unnecessary work or a dot. + return isPathSeparator(code) ? path : '.'; + } + + // Try to match a root + if (isPathSeparator(code)) { + // Possible UNC root + + rootEnd = offset = 1; + + if (isPathSeparator(path.charCodeAt(1))) { + // Matched double path separator at beginning + let j = 2; + let last = j; + // Match 1 or more non-path separators + while (j < len && !isPathSeparator(path.charCodeAt(j))) { + j++; + } + if (j < len && j !== last) { + // Matched! + last = j; + // Match 1 or more path separators + while (j < len && isPathSeparator(path.charCodeAt(j))) { + j++; + } + if (j < len && j !== last) { + // Matched! + last = j; + // Match 1 or more non-path separators + while (j < len && !isPathSeparator(path.charCodeAt(j))) { + j++; + } + if (j === len) { + // We matched a UNC root only + return path; + } + if (j !== last) { + // We matched a UNC root with leftovers + + // Offset by 1 to include the separator after the UNC root to + // treat it as a "normal root" on top of a (UNC) root + rootEnd = offset = j + 1; + } + } + } + } + // Possible device root + } else if (isWindowsDeviceRoot(code) && path.charCodeAt(1) === CHAR_COLON) { + rootEnd = len > 2 && isPathSeparator(path.charCodeAt(2)) ? 3 : 2; + offset = rootEnd; + } + + let end = -1; + let matchedSlash = true; + for (let i = len - 1; i >= offset; --i) { + if (isPathSeparator(path.charCodeAt(i))) { + if (!matchedSlash) { + end = i; + break; + } + } else { + // We saw the first non-path separator + matchedSlash = false; + } + } + + if (end === -1) { + if (rootEnd === -1) { + return '.'; + } + + end = rootEnd; + } + return path.slice(0, end); + }, + + basename(path: string, suffix?: string): string { + if (suffix !== undefined) { + validateString(suffix, 'suffix'); + } + validateString(path, 'path'); + let start = 0; + let end = -1; + let matchedSlash = true; + let i; + + // Check for a drive letter prefix so as not to mistake the following + // path separator as an extra separator at the end of the path that can be + // disregarded + if (path.length >= 2 && + isWindowsDeviceRoot(path.charCodeAt(0)) && + path.charCodeAt(1) === CHAR_COLON) { + start = 2; + } + + if (suffix !== undefined && suffix.length > 0 && suffix.length <= path.length) { + if (suffix === path) { + return ''; + } + let extIdx = suffix.length - 1; + let firstNonSlashEnd = -1; + for (i = path.length - 1; i >= start; --i) { + const code = path.charCodeAt(i); + if (isPathSeparator(code)) { + // If we reached a path separator that was not part of a set of path + // separators at the end of the string, stop now + if (!matchedSlash) { + start = i + 1; + break; + } + } else { + if (firstNonSlashEnd === -1) { + // We saw the first non-path separator, remember this index in case + // we need it if the extension ends up not matching + matchedSlash = false; + firstNonSlashEnd = i + 1; + } + if (extIdx >= 0) { + // Try to match the explicit extension + if (code === suffix.charCodeAt(extIdx)) { + if (--extIdx === -1) { + // We matched the extension, so mark this as the end of our path + // component + end = i; + } + } else { + // Extension does not match, so our result is the entire path + // component + extIdx = -1; + end = firstNonSlashEnd; + } + } + } + } + + if (start === end) { + end = firstNonSlashEnd; + } else if (end === -1) { + end = path.length; + } + return path.slice(start, end); + } + for (i = path.length - 1; i >= start; --i) { + if (isPathSeparator(path.charCodeAt(i))) { + // If we reached a path separator that was not part of a set of path + // separators at the end of the string, stop now + if (!matchedSlash) { + start = i + 1; + break; + } + } else if (end === -1) { + // We saw the first non-path separator, mark this as the end of our + // path component + matchedSlash = false; + end = i + 1; + } + } + + if (end === -1) { + return ''; + } + return path.slice(start, end); + }, + + extname(path: string): string { + validateString(path, 'path'); + let start = 0; + let startDot = -1; + let startPart = 0; + let end = -1; + let matchedSlash = true; + // Track the state of characters (if any) we see before our first dot and + // after any path separator we find + let preDotState = 0; + + // Check for a drive letter prefix so as not to mistake the following + // path separator as an extra separator at the end of the path that can be + // disregarded + + if (path.length >= 2 && + path.charCodeAt(1) === CHAR_COLON && + isWindowsDeviceRoot(path.charCodeAt(0))) { + start = startPart = 2; + } + + for (let i = path.length - 1; i >= start; --i) { + const code = path.charCodeAt(i); + if (isPathSeparator(code)) { + // If we reached a path separator that was not part of a set of path + // separators at the end of the string, stop now + if (!matchedSlash) { + startPart = i + 1; + break; + } + continue; + } + if (end === -1) { + // We saw the first non-path separator, mark this as the end of our + // extension + matchedSlash = false; + end = i + 1; + } + if (code === CHAR_DOT) { + // If this is our first dot, mark it as the start of our extension + if (startDot === -1) { + startDot = i; + } + else if (preDotState !== 1) { + preDotState = 1; + } + } else if (startDot !== -1) { + // We saw a non-dot and non-path separator before our dot, so we should + // have a good chance at having a non-empty extension + preDotState = -1; + } + } + + if (startDot === -1 || + end === -1 || + // We saw a non-dot character immediately before the dot + preDotState === 0 || + // The (right-most) trimmed path component is exactly '..' + (preDotState === 1 && + startDot === end - 1 && + startDot === startPart + 1)) { + return ''; + } + return path.slice(startDot, end); + }, + + format: _format.bind(null, '\\'), + + parse(path) { + validateString(path, 'path'); + + const ret = { root: '', dir: '', base: '', ext: '', name: '' }; + if (path.length === 0) { + return ret; + } + + const len = path.length; + let rootEnd = 0; + let code = path.charCodeAt(0); + + if (len === 1) { + if (isPathSeparator(code)) { + // `path` contains just a path separator, exit early to avoid + // unnecessary work + ret.root = ret.dir = path; + return ret; + } + ret.base = ret.name = path; + return ret; + } + // Try to match a root + if (isPathSeparator(code)) { + // Possible UNC root + + rootEnd = 1; + if (isPathSeparator(path.charCodeAt(1))) { + // Matched double path separator at beginning + let j = 2; + let last = j; + // Match 1 or more non-path separators + while (j < len && !isPathSeparator(path.charCodeAt(j))) { + j++; + } + if (j < len && j !== last) { + // Matched! + last = j; + // Match 1 or more path separators + while (j < len && isPathSeparator(path.charCodeAt(j))) { + j++; + } + if (j < len && j !== last) { + // Matched! + last = j; + // Match 1 or more non-path separators + while (j < len && !isPathSeparator(path.charCodeAt(j))) { + j++; + } + if (j === len) { + // We matched a UNC root only + rootEnd = j; + } else if (j !== last) { + // We matched a UNC root with leftovers + rootEnd = j + 1; + } + } + } + } + } else if (isWindowsDeviceRoot(code) && path.charCodeAt(1) === CHAR_COLON) { + // Possible device root + if (len <= 2) { + // `path` contains just a drive root, exit early to avoid + // unnecessary work + ret.root = ret.dir = path; + return ret; + } + rootEnd = 2; + if (isPathSeparator(path.charCodeAt(2))) { + if (len === 3) { + // `path` contains just a drive root, exit early to avoid + // unnecessary work + ret.root = ret.dir = path; + return ret; + } + rootEnd = 3; + } + } + if (rootEnd > 0) { + ret.root = path.slice(0, rootEnd); + } + + let startDot = -1; + let startPart = rootEnd; + let end = -1; + let matchedSlash = true; + let i = path.length - 1; + + // Track the state of characters (if any) we see before our first dot and + // after any path separator we find + let preDotState = 0; + + // Get non-dir info + for (; i >= rootEnd; --i) { + code = path.charCodeAt(i); + if (isPathSeparator(code)) { + // If we reached a path separator that was not part of a set of path + // separators at the end of the string, stop now + if (!matchedSlash) { + startPart = i + 1; + break; + } + continue; + } + if (end === -1) { + // We saw the first non-path separator, mark this as the end of our + // extension + matchedSlash = false; + end = i + 1; + } + if (code === CHAR_DOT) { + // If this is our first dot, mark it as the start of our extension + if (startDot === -1) { + startDot = i; + } else if (preDotState !== 1) { + preDotState = 1; + } + } else if (startDot !== -1) { + // We saw a non-dot and non-path separator before our dot, so we should + // have a good chance at having a non-empty extension + preDotState = -1; + } + } + + if (end !== -1) { + if (startDot === -1 || + // We saw a non-dot character immediately before the dot + preDotState === 0 || + // The (right-most) trimmed path component is exactly '..' + (preDotState === 1 && + startDot === end - 1 && + startDot === startPart + 1)) { + ret.base = ret.name = path.slice(startPart, end); + } else { + ret.name = path.slice(startPart, startDot); + ret.base = path.slice(startPart, end); + ret.ext = path.slice(startDot, end); + } + } + + // If the directory is the root, use the entire root as the `dir` including + // the trailing slash if any (`C:\abc` -> `C:\`). Otherwise, strip out the + // trailing slash (`C:\abc\def` -> `C:\abc`). + if (startPart > 0 && startPart !== rootEnd) { + ret.dir = path.slice(0, startPart - 1); + } else { + ret.dir = ret.root; + } + + return ret; + }, + + sep: '\\', + delimiter: ';', + win32: null, + posix: null +}; + +const posixCwd = (() => { + if (platformIsWin32) { + // Converts Windows' backslash path separators to POSIX forward slashes + // and truncates any drive indicator + const regexp = /\\/g; + return () => { + const cwd = process.cwd().replace(regexp, '/'); + return cwd.slice(cwd.indexOf('/')); + }; + } + + // We're already on POSIX, no need for any transformations + return () => process.cwd(); +})(); + +export const posix: IPath = { + // path.resolve([from ...], to) + resolve(...pathSegments: string[]): string { + let resolvedPath = ''; + let resolvedAbsolute = false; + + for (let i = pathSegments.length - 1; i >= -1 && !resolvedAbsolute; i--) { + const path = i >= 0 ? pathSegments[i] : posixCwd(); + + validateString(path, `paths[${i}]`); + + // Skip empty entries + if (path.length === 0) { + continue; + } + + resolvedPath = `${path}/${resolvedPath}`; + resolvedAbsolute = path.charCodeAt(0) === CHAR_FORWARD_SLASH; + } + + // At this point the path should be resolved to a full absolute path, but + // handle relative paths to be safe (might happen when process.cwd() fails) + + // Normalize the path + resolvedPath = normalizeString(resolvedPath, !resolvedAbsolute, '/', + isPosixPathSeparator); + + if (resolvedAbsolute) { + return `/${resolvedPath}`; + } + return resolvedPath.length > 0 ? resolvedPath : '.'; + }, + + normalize(path: string): string { + validateString(path, 'path'); + + if (path.length === 0) { + return '.'; + } + + const isAbsolute = path.charCodeAt(0) === CHAR_FORWARD_SLASH; + const trailingSeparator = + path.charCodeAt(path.length - 1) === CHAR_FORWARD_SLASH; + + // Normalize the path + path = normalizeString(path, !isAbsolute, '/', isPosixPathSeparator); + + if (path.length === 0) { + if (isAbsolute) { + return '/'; + } + return trailingSeparator ? './' : '.'; + } + if (trailingSeparator) { + path += '/'; + } + + return isAbsolute ? `/${path}` : path; + }, + + isAbsolute(path: string): boolean { + validateString(path, 'path'); + return path.length > 0 && path.charCodeAt(0) === CHAR_FORWARD_SLASH; + }, + + join(...paths: string[]): string { + if (paths.length === 0) { + return '.'; + } + let joined; + for (let i = 0; i < paths.length; ++i) { + const arg = paths[i]; + validateString(arg, 'path'); + if (arg.length > 0) { + if (joined === undefined) { + joined = arg; + } else { + joined += `/${arg}`; + } + } + } + if (joined === undefined) { + return '.'; + } + return posix.normalize(joined); + }, + + relative(from: string, to: string): string { + validateString(from, 'from'); + validateString(to, 'to'); + + if (from === to) { + return ''; + } + + // Trim leading forward slashes. + from = posix.resolve(from); + to = posix.resolve(to); + + if (from === to) { + return ''; + } + + const fromStart = 1; + const fromEnd = from.length; + const fromLen = fromEnd - fromStart; + const toStart = 1; + const toLen = to.length - toStart; + + // Compare paths to find the longest common path from root + const length = (fromLen < toLen ? fromLen : toLen); + let lastCommonSep = -1; + let i = 0; + for (; i < length; i++) { + const fromCode = from.charCodeAt(fromStart + i); + if (fromCode !== to.charCodeAt(toStart + i)) { + break; + } else if (fromCode === CHAR_FORWARD_SLASH) { + lastCommonSep = i; + } + } + if (i === length) { + if (toLen > length) { + if (to.charCodeAt(toStart + i) === CHAR_FORWARD_SLASH) { + // We get here if `from` is the exact base path for `to`. + // For example: from='/foo/bar'; to='/foo/bar/baz' + return to.slice(toStart + i + 1); + } + if (i === 0) { + // We get here if `from` is the root + // For example: from='/'; to='/foo' + return to.slice(toStart + i); + } + } else if (fromLen > length) { + if (from.charCodeAt(fromStart + i) === CHAR_FORWARD_SLASH) { + // We get here if `to` is the exact base path for `from`. + // For example: from='/foo/bar/baz'; to='/foo/bar' + lastCommonSep = i; + } else if (i === 0) { + // We get here if `to` is the root. + // For example: from='/foo/bar'; to='/' + lastCommonSep = 0; + } + } + } + + let out = ''; + // Generate the relative path based on the path difference between `to` + // and `from`. + for (i = fromStart + lastCommonSep + 1; i <= fromEnd; ++i) { + if (i === fromEnd || from.charCodeAt(i) === CHAR_FORWARD_SLASH) { + out += out.length === 0 ? '..' : '/..'; + } + } + + // Lastly, append the rest of the destination (`to`) path that comes after + // the common path parts. + return `${out}${to.slice(toStart + lastCommonSep)}`; + }, + + toNamespacedPath(path: string): string { + // Non-op on posix systems + return path; + }, + + dirname(path: string): string { + validateString(path, 'path'); + if (path.length === 0) { + return '.'; + } + const hasRoot = path.charCodeAt(0) === CHAR_FORWARD_SLASH; + let end = -1; + let matchedSlash = true; + for (let i = path.length - 1; i >= 1; --i) { + if (path.charCodeAt(i) === CHAR_FORWARD_SLASH) { + if (!matchedSlash) { + end = i; + break; + } + } else { + // We saw the first non-path separator + matchedSlash = false; + } + } + + if (end === -1) { + return hasRoot ? '/' : '.'; + } + if (hasRoot && end === 1) { + return '//'; + } + return path.slice(0, end); + }, + + basename(path: string, suffix?: string): string { + if (suffix !== undefined) { + validateString(suffix, 'ext'); + } + validateString(path, 'path'); + + let start = 0; + let end = -1; + let matchedSlash = true; + let i; + + if (suffix !== undefined && suffix.length > 0 && suffix.length <= path.length) { + if (suffix === path) { + return ''; + } + let extIdx = suffix.length - 1; + let firstNonSlashEnd = -1; + for (i = path.length - 1; i >= 0; --i) { + const code = path.charCodeAt(i); + if (code === CHAR_FORWARD_SLASH) { + // If we reached a path separator that was not part of a set of path + // separators at the end of the string, stop now + if (!matchedSlash) { + start = i + 1; + break; + } + } else { + if (firstNonSlashEnd === -1) { + // We saw the first non-path separator, remember this index in case + // we need it if the extension ends up not matching + matchedSlash = false; + firstNonSlashEnd = i + 1; + } + if (extIdx >= 0) { + // Try to match the explicit extension + if (code === suffix.charCodeAt(extIdx)) { + if (--extIdx === -1) { + // We matched the extension, so mark this as the end of our path + // component + end = i; + } + } else { + // Extension does not match, so our result is the entire path + // component + extIdx = -1; + end = firstNonSlashEnd; + } + } + } + } + + if (start === end) { + end = firstNonSlashEnd; + } else if (end === -1) { + end = path.length; + } + return path.slice(start, end); + } + for (i = path.length - 1; i >= 0; --i) { + if (path.charCodeAt(i) === CHAR_FORWARD_SLASH) { + // If we reached a path separator that was not part of a set of path + // separators at the end of the string, stop now + if (!matchedSlash) { + start = i + 1; + break; + } + } else if (end === -1) { + // We saw the first non-path separator, mark this as the end of our + // path component + matchedSlash = false; + end = i + 1; + } + } + + if (end === -1) { + return ''; + } + return path.slice(start, end); + }, + + extname(path: string): string { + validateString(path, 'path'); + let startDot = -1; + let startPart = 0; + let end = -1; + let matchedSlash = true; + // Track the state of characters (if any) we see before our first dot and + // after any path separator we find + let preDotState = 0; + for (let i = path.length - 1; i >= 0; --i) { + const code = path.charCodeAt(i); + if (code === CHAR_FORWARD_SLASH) { + // If we reached a path separator that was not part of a set of path + // separators at the end of the string, stop now + if (!matchedSlash) { + startPart = i + 1; + break; + } + continue; + } + if (end === -1) { + // We saw the first non-path separator, mark this as the end of our + // extension + matchedSlash = false; + end = i + 1; + } + if (code === CHAR_DOT) { + // If this is our first dot, mark it as the start of our extension + if (startDot === -1) { + startDot = i; + } + else if (preDotState !== 1) { + preDotState = 1; + } + } else if (startDot !== -1) { + // We saw a non-dot and non-path separator before our dot, so we should + // have a good chance at having a non-empty extension + preDotState = -1; + } + } + + if (startDot === -1 || + end === -1 || + // We saw a non-dot character immediately before the dot + preDotState === 0 || + // The (right-most) trimmed path component is exactly '..' + (preDotState === 1 && + startDot === end - 1 && + startDot === startPart + 1)) { + return ''; + } + return path.slice(startDot, end); + }, + + format: _format.bind(null, '/'), + + parse(path: string): ParsedPath { + validateString(path, 'path'); + + const ret = { root: '', dir: '', base: '', ext: '', name: '' }; + if (path.length === 0) { + return ret; + } + const isAbsolute = path.charCodeAt(0) === CHAR_FORWARD_SLASH; + let start; + if (isAbsolute) { + ret.root = '/'; + start = 1; + } else { + start = 0; + } + let startDot = -1; + let startPart = 0; + let end = -1; + let matchedSlash = true; + let i = path.length - 1; + + // Track the state of characters (if any) we see before our first dot and + // after any path separator we find + let preDotState = 0; + + // Get non-dir info + for (; i >= start; --i) { + const code = path.charCodeAt(i); + if (code === CHAR_FORWARD_SLASH) { + // If we reached a path separator that was not part of a set of path + // separators at the end of the string, stop now + if (!matchedSlash) { + startPart = i + 1; + break; + } + continue; + } + if (end === -1) { + // We saw the first non-path separator, mark this as the end of our + // extension + matchedSlash = false; + end = i + 1; + } + if (code === CHAR_DOT) { + // If this is our first dot, mark it as the start of our extension + if (startDot === -1) { + startDot = i; + } else if (preDotState !== 1) { + preDotState = 1; + } + } else if (startDot !== -1) { + // We saw a non-dot and non-path separator before our dot, so we should + // have a good chance at having a non-empty extension + preDotState = -1; + } + } + + if (end !== -1) { + const start = startPart === 0 && isAbsolute ? 1 : startPart; + if (startDot === -1 || + // We saw a non-dot character immediately before the dot + preDotState === 0 || + // The (right-most) trimmed path component is exactly '..' + (preDotState === 1 && + startDot === end - 1 && + startDot === startPart + 1)) { + ret.base = ret.name = path.slice(start, end); + } else { + ret.name = path.slice(start, startDot); + ret.base = path.slice(start, end); + ret.ext = path.slice(startDot, end); + } + } + + if (startPart > 0) { + ret.dir = path.slice(0, startPart - 1); + } else if (isAbsolute) { + ret.dir = '/'; + } + + return ret; + }, + + sep: '/', + delimiter: ':', + win32: null, + posix: null +}; + +posix.win32 = win32.win32 = win32; +posix.posix = win32.posix = posix; + +export const normalize = (platformIsWin32 ? win32.normalize : posix.normalize); +export const isAbsolute = (platformIsWin32 ? win32.isAbsolute : posix.isAbsolute); +export const join = (platformIsWin32 ? win32.join : posix.join); +export const resolve = (platformIsWin32 ? win32.resolve : posix.resolve); +export const relative = (platformIsWin32 ? win32.relative : posix.relative); +export const dirname = (platformIsWin32 ? win32.dirname : posix.dirname); +export const basename = (platformIsWin32 ? win32.basename : posix.basename); +export const extname = (platformIsWin32 ? win32.extname : posix.extname); +export const format = (platformIsWin32 ? win32.format : posix.format); +export const parse = (platformIsWin32 ? win32.parse : posix.parse); +export const toNamespacedPath = (platformIsWin32 ? win32.toNamespacedPath : posix.toNamespacedPath); +export const sep = (platformIsWin32 ? win32.sep : posix.sep); +export const delimiter = (platformIsWin32 ? win32.delimiter : posix.delimiter); diff --git a/src/vs/base/common/performance.d.ts b/src/vs/base/common/performance.d.ts new file mode 100644 index 0000000000..fc233e6f83 --- /dev/null +++ b/src/vs/base/common/performance.d.ts @@ -0,0 +1,16 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export interface PerformanceMark { + readonly name: string; + readonly startTime: number; +} + +export function mark(name: string): void; + +/** + * Returns all marks, sorted by `startTime`. + */ +export function getMarks(): PerformanceMark[]; diff --git a/src/vs/base/common/performance.js b/src/vs/base/common/performance.js new file mode 100644 index 0000000000..cdea491777 --- /dev/null +++ b/src/vs/base/common/performance.js @@ -0,0 +1,135 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +//@ts-check +'use strict'; + +// ESM-uncomment-begin +// const module = { exports: {} }; +// ESM-uncomment-end + +(function () { + + /** + * @returns {{mark(name:string):void, getMarks():{name:string, startTime:number}[]}} + */ + function _definePolyfillMarks(timeOrigin) { + + const _data = []; + if (typeof timeOrigin === 'number') { + _data.push('code/timeOrigin', timeOrigin); + } + + function mark(name) { + _data.push(name, Date.now()); + } + function getMarks() { + const result = []; + for (let i = 0; i < _data.length; i += 2) { + result.push({ + name: _data[i], + startTime: _data[i + 1], + }); + } + return result; + } + return { mark, getMarks }; + } + + /** + * @returns {{mark(name:string):void, getMarks():{name:string, startTime:number}[]}} + */ + function _define() { + + // Identify browser environment when following property is not present + // https://nodejs.org/dist/latest-v16.x/docs/api/perf_hooks.html#performancenodetiming + // @ts-ignore + if (typeof performance === 'object' && typeof performance.mark === 'function' && !performance.nodeTiming) { + // in a browser context, reuse performance-util + + if (typeof performance.timeOrigin !== 'number' && !performance.timing) { + // safari & webworker: because there is no timeOrigin and no workaround + // we use the `Date.now`-based polyfill. + return _definePolyfillMarks(); + + } else { + // use "native" performance for mark and getMarks + return { + mark(name) { + performance.mark(name); + }, + getMarks() { + let timeOrigin = performance.timeOrigin; + if (typeof timeOrigin !== 'number') { + // safari: there is no timerOrigin but in renderers there is the timing-property + // see https://bugs.webkit.org/show_bug.cgi?id=174862 + timeOrigin = performance.timing.navigationStart || performance.timing.redirectStart || performance.timing.fetchStart; + } + const result = [{ name: 'code/timeOrigin', startTime: Math.round(timeOrigin) }]; + for (const entry of performance.getEntriesByType('mark')) { + result.push({ + name: entry.name, + startTime: Math.round(timeOrigin + entry.startTime) + }); + } + return result; + } + }; + } + + } else if (typeof process === 'object') { + // node.js: use the normal polyfill but add the timeOrigin + // from the node perf_hooks API as very first mark + const timeOrigin = performance?.timeOrigin ?? Math.round((require.__$__nodeRequire || require)('perf_hooks').performance.timeOrigin); + return _definePolyfillMarks(timeOrigin); + + } else { + // unknown environment + console.trace('perf-util loaded in UNKNOWN environment'); + return _definePolyfillMarks(); + } + } + + function _factory(sharedObj) { + if (!sharedObj.MonacoPerformanceMarks) { + sharedObj.MonacoPerformanceMarks = _define(); + } + return sharedObj.MonacoPerformanceMarks; + } + + // This module can be loaded in an amd and commonjs-context. + // Because we want both instances to use the same perf-data + // we store them globally + + // eslint-disable-next-line no-var + var sharedObj; + if (typeof global === 'object') { + // nodejs + sharedObj = global; + } else if (typeof self === 'object') { + // browser + sharedObj = self; + } else { + sharedObj = {}; + } + + if (typeof define === 'function') { + // amd + define([], function () { return _factory(sharedObj); }); + } else if (typeof module === 'object' && typeof module.exports === 'object') { + // commonjs + module.exports = _factory(sharedObj); + } else { + console.trace('perf-util defined in UNKNOWN context (neither requirejs or commonjs)'); + // @ts-ignore + sharedObj.perf = _factory(sharedObj); + } + +})(); + +// ESM-uncomment-begin +// export const mark = module.exports.mark; +// export const getMarks = module.exports.getMarks; +// ESM-uncomment-end diff --git a/src/vs/base/common/platform.ts b/src/vs/base/common/platform.ts new file mode 100644 index 0000000000..d931e64dfa --- /dev/null +++ b/src/vs/base/common/platform.ts @@ -0,0 +1,281 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as nls from 'vs/nls'; + +export const LANGUAGE_DEFAULT = 'en'; + +let _isWindows = false; +let _isMacintosh = false; +let _isLinux = false; +let _isLinuxSnap = false; +let _isNative = false; +let _isWeb = false; +let _isElectron = false; +let _isIOS = false; +let _isCI = false; +let _isMobile = false; +let _locale: string | undefined = undefined; +let _language: string = LANGUAGE_DEFAULT; +let _platformLocale: string = LANGUAGE_DEFAULT; +let _translationsConfigFile: string | undefined = undefined; +let _userAgent: string | undefined = undefined; + +export interface IProcessEnvironment { + [key: string]: string | undefined; +} + +/** + * This interface is intentionally not identical to node.js + * process because it also works in sandboxed environments + * where the process object is implemented differently. We + * define the properties here that we need for `platform` + * to work and nothing else. + */ +export interface INodeProcess { + platform: string; + arch: string; + env: IProcessEnvironment; + versions?: { + node?: string; + electron?: string; + chrome?: string; + }; + type?: string; + cwd: () => string; +} + +declare const process: INodeProcess; + +const $globalThis: any = globalThis; + +let nodeProcess: INodeProcess | undefined = undefined; +if (typeof $globalThis.vscode !== 'undefined' && typeof $globalThis.vscode.process !== 'undefined') { + // Native environment (sandboxed) + nodeProcess = $globalThis.vscode.process; +} else if (typeof process !== 'undefined' && typeof process?.versions?.node === 'string') { + // Native environment (non-sandboxed) + nodeProcess = process; +} + +const isElectronProcess = typeof nodeProcess?.versions?.electron === 'string'; +const isElectronRenderer = isElectronProcess && nodeProcess?.type === 'renderer'; + +interface INavigator { + userAgent: string; + maxTouchPoints?: number; + language: string; +} +declare const navigator: INavigator; + +// Native environment +if (typeof nodeProcess === 'object') { + _isWindows = (nodeProcess.platform === 'win32'); + _isMacintosh = (nodeProcess.platform === 'darwin'); + _isLinux = (nodeProcess.platform === 'linux'); + _isLinuxSnap = _isLinux && !!nodeProcess.env['SNAP'] && !!nodeProcess.env['SNAP_REVISION']; + _isElectron = isElectronProcess; + _isCI = !!nodeProcess.env['CI'] || !!nodeProcess.env['BUILD_ARTIFACTSTAGINGDIRECTORY']; + _locale = LANGUAGE_DEFAULT; + _language = LANGUAGE_DEFAULT; + const rawNlsConfig = nodeProcess.env['VSCODE_NLS_CONFIG']; + if (rawNlsConfig) { + try { + const nlsConfig: nls.INLSConfiguration = JSON.parse(rawNlsConfig); + _locale = nlsConfig.userLocale; + _platformLocale = nlsConfig.osLocale; + _language = nlsConfig.resolvedLanguage || LANGUAGE_DEFAULT; + _translationsConfigFile = nlsConfig.languagePack?.translationsConfigFile; + } catch (e) { + } + } + _isNative = true; +} + +// Web environment +else if (typeof navigator === 'object' && !isElectronRenderer) { + _userAgent = navigator.userAgent; + _isWindows = _userAgent.indexOf('Windows') >= 0; + _isMacintosh = _userAgent.indexOf('Macintosh') >= 0; + _isIOS = (_userAgent.indexOf('Macintosh') >= 0 || _userAgent.indexOf('iPad') >= 0 || _userAgent.indexOf('iPhone') >= 0) && !!navigator.maxTouchPoints && navigator.maxTouchPoints > 0; + _isLinux = _userAgent.indexOf('Linux') >= 0; + _isMobile = _userAgent?.indexOf('Mobi') >= 0; + _isWeb = true; + // VSCODE_GLOBALS: NLS + _language = globalThis._VSCODE_NLS_LANGUAGE || LANGUAGE_DEFAULT; + _locale = navigator.language.toLowerCase(); + _platformLocale = _locale; +} + +// Unknown environment +else { + console.error('Unable to resolve platform.'); +} + +export const enum Platform { + Web, + Mac, + Linux, + Windows +} +export type PlatformName = 'Web' | 'Windows' | 'Mac' | 'Linux'; + +export function PlatformToString(platform: Platform): PlatformName { + switch (platform) { + case Platform.Web: return 'Web'; + case Platform.Mac: return 'Mac'; + case Platform.Linux: return 'Linux'; + case Platform.Windows: return 'Windows'; + } +} + +let _platform: Platform = Platform.Web; +if (_isMacintosh) { + _platform = Platform.Mac; +} else if (_isWindows) { + _platform = Platform.Windows; +} else if (_isLinux) { + _platform = Platform.Linux; +} + +export const isWindows = _isWindows; +export const isMacintosh = _isMacintosh; +export const isLinux = _isLinux; +export const isLinuxSnap = _isLinuxSnap; +export const isNative = _isNative; +export const isElectron = _isElectron; +export const isWeb = _isWeb; +export const isWebWorker = (_isWeb && typeof $globalThis.importScripts === 'function'); +export const webWorkerOrigin = isWebWorker ? $globalThis.origin : undefined; +export const isIOS = _isIOS; +export const isMobile = _isMobile; +/** + * Whether we run inside a CI environment, such as + * GH actions or Azure Pipelines. + */ +export const isCI = _isCI; +export const platform = _platform; +export const userAgent = _userAgent; + +/** + * The language used for the user interface. The format of + * the string is all lower case (e.g. zh-tw for Traditional + * Chinese or de for German) + */ +export const language = _language; + +export namespace Language { + + export function value(): string { + return language; + } + + export function isDefaultVariant(): boolean { + if (language.length === 2) { + return language === 'en'; + } else if (language.length >= 3) { + return language[0] === 'e' && language[1] === 'n' && language[2] === '-'; + } else { + return false; + } + } + + export function isDefault(): boolean { + return language === 'en'; + } +} + +/** + * Desktop: The OS locale or the locale specified by --locale or `argv.json`. + * Web: matches `platformLocale`. + * + * The UI is not necessarily shown in the provided locale. + */ +export const locale = _locale; + +/** + * This will always be set to the OS/browser's locale regardless of + * what was specified otherwise. The format of the string is all + * lower case (e.g. zh-tw for Traditional Chinese). The UI is not + * necessarily shown in the provided locale. + */ +export const platformLocale = _platformLocale; + +/** + * The translations that are available through language packs. + */ +export const translationsConfigFile = _translationsConfigFile; + +export const setTimeout0IsFaster = (typeof $globalThis.postMessage === 'function' && !$globalThis.importScripts); + +/** + * See https://html.spec.whatwg.org/multipage/timers-and-user-prompts.html#:~:text=than%204%2C%20then-,set%20timeout%20to%204,-. + * + * Works similarly to `setTimeout(0)` but doesn't suffer from the 4ms artificial delay + * that browsers set when the nesting level is > 5. + */ +export const setTimeout0 = (() => { + if (setTimeout0IsFaster) { + interface IQueueElement { + id: number; + callback: () => void; + } + const pending: IQueueElement[] = []; + + $globalThis.addEventListener('message', (e: any) => { + if (e.data && e.data.vscodeScheduleAsyncWork) { + for (let i = 0, len = pending.length; i < len; i++) { + const candidate = pending[i]; + if (candidate.id === e.data.vscodeScheduleAsyncWork) { + pending.splice(i, 1); + candidate.callback(); + return; + } + } + } + }); + let lastId = 0; + return (callback: () => void) => { + const myId = ++lastId; + pending.push({ + id: myId, + callback: callback + }); + $globalThis.postMessage({ vscodeScheduleAsyncWork: myId }, '*'); + }; + } + return (callback: () => void) => setTimeout(callback); +})(); + +export const enum OperatingSystem { + Windows = 1, + Macintosh = 2, + Linux = 3 +} +export const OS = (_isMacintosh || _isIOS ? OperatingSystem.Macintosh : (_isWindows ? OperatingSystem.Windows : OperatingSystem.Linux)); + +let _isLittleEndian = true; +let _isLittleEndianComputed = false; +export function isLittleEndian(): boolean { + if (!_isLittleEndianComputed) { + _isLittleEndianComputed = true; + const test = new Uint8Array(2); + test[0] = 1; + test[1] = 2; + const view = new Uint16Array(test.buffer); + _isLittleEndian = (view[0] === (2 << 8) + 1); + } + return _isLittleEndian; +} + +export const isChrome = !!(userAgent && userAgent.indexOf('Chrome') >= 0); +export const isFirefox = !!(userAgent && userAgent.indexOf('Firefox') >= 0); +export const isSafari = !!(!isChrome && (userAgent && userAgent.indexOf('Safari') >= 0)); +export const isEdge = !!(userAgent && userAgent.indexOf('Edg/') >= 0); +export const isAndroid = !!(userAgent && userAgent.indexOf('Android') >= 0); + +export function isBigSurOrNewer(osVersion: string): boolean { + return parseFloat(osVersion) >= 20; +} diff --git a/src/vs/base/common/ports.ts b/src/vs/base/common/ports.ts new file mode 100644 index 0000000000..5ec75530a8 --- /dev/null +++ b/src/vs/base/common/ports.ts @@ -0,0 +1,13 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * @returns Returns a random port between 1025 and 65535. + */ +export function randomPort(): number { + const min = 1025; + const max = 65535; + return min + Math.floor((max - min) * Math.random()); +} diff --git a/src/vs/base/common/prefixTree.ts b/src/vs/base/common/prefixTree.ts new file mode 100644 index 0000000000..53f02964d3 --- /dev/null +++ b/src/vs/base/common/prefixTree.ts @@ -0,0 +1,252 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Iterable } from 'vs/base/common/iterator'; + +const unset = Symbol('unset'); + +export interface IPrefixTreeNode { + /** Possible children of the node. */ + children?: ReadonlyMap>; + + /** The value if data exists for this node in the tree. Mutable. */ + value: T | undefined; +} + +/** + * A simple prefix tree implementation where a value is stored based on + * well-defined prefix segments. + */ +export class WellDefinedPrefixTree { + private readonly root = new Node(); + private _size = 0; + + public get size() { + return this._size; + } + + /** Gets the top-level nodes of the tree */ + public get nodes(): Iterable> { + return this.root.children?.values() || Iterable.empty(); + } + + /** Gets the top-level nodes of the tree */ + public get entries(): Iterable<[string, IPrefixTreeNode]> { + return this.root.children?.entries() || Iterable.empty(); + } + + /** + * Inserts a new value in the prefix tree. + * @param onNode - called for each node as we descend to the insertion point, + * including the insertion point itself. + */ + insert(key: Iterable, value: V, onNode?: (n: IPrefixTreeNode) => void): void { + this.opNode(key, n => n._value = value, onNode); + } + + /** Mutates a value in the prefix tree. */ + mutate(key: Iterable, mutate: (value?: V) => V): void { + this.opNode(key, n => n._value = mutate(n._value === unset ? undefined : n._value)); + } + + /** Mutates nodes along the path in the prefix tree. */ + mutatePath(key: Iterable, mutate: (node: IPrefixTreeNode) => void): void { + this.opNode(key, () => { }, n => mutate(n)); + } + + /** Deletes a node from the prefix tree, returning the value it contained. */ + delete(key: Iterable): V | undefined { + const path = this.getPathToKey(key); + if (!path) { + return; + } + + let i = path.length - 1; + const value = path[i].node._value; + if (value === unset) { + return; // not actually a real node + } + + this._size--; + path[i].node._value = unset; + + for (; i > 0; i--) { + const { node, part } = path[i]; + if (node.children?.size || node._value !== unset) { + break; + } + + path[i - 1].node.children!.delete(part); + } + + return value; + } + + /** Deletes a subtree from the prefix tree, returning the values they contained. */ + *deleteRecursive(key: Iterable): Iterable { + const path = this.getPathToKey(key); + if (!path) { + return; + } + + const subtree = path[path.length - 1].node; + + // important: run the deletion before we start to yield results, so that + // it still runs even if the caller doesn't consumer the iterator + for (let i = path.length - 1; i > 0; i--) { + const parent = path[i - 1]; + parent.node.children!.delete(path[i].part); + if (parent.node.children!.size > 0 || parent.node._value !== unset) { + break; + } + } + + for (const node of bfsIterate(subtree)) { + if (node._value !== unset) { + this._size--; + yield node._value; + } + } + } + + /** Gets a value from the tree. */ + find(key: Iterable): V | undefined { + let node = this.root; + for (const segment of key) { + const next = node.children?.get(segment); + if (!next) { + return undefined; + } + + node = next; + } + + return node._value === unset ? undefined : node._value; + } + + /** Gets whether the tree has the key, or a parent of the key, already inserted. */ + hasKeyOrParent(key: Iterable): boolean { + let node = this.root; + for (const segment of key) { + const next = node.children?.get(segment); + if (!next) { + return false; + } + if (next._value !== unset) { + return true; + } + + node = next; + } + + return false; + } + + /** Gets whether the tree has the given key or any children. */ + hasKeyOrChildren(key: Iterable): boolean { + let node = this.root; + for (const segment of key) { + const next = node.children?.get(segment); + if (!next) { + return false; + } + + node = next; + } + + return true; + } + + /** Gets whether the tree has the given key. */ + hasKey(key: Iterable): boolean { + let node = this.root; + for (const segment of key) { + const next = node.children?.get(segment); + if (!next) { + return false; + } + + node = next; + } + + return node._value !== unset; + } + + private getPathToKey(key: Iterable) { + const path = [{ part: '', node: this.root }]; + let i = 0; + for (const part of key) { + const node = path[i].node.children?.get(part); + if (!node) { + return; // node not in tree + } + + path.push({ part, node }); + i++; + } + + return path; + } + + private opNode(key: Iterable, fn: (node: Node) => void, onDescend?: (node: Node) => void): void { + let node = this.root; + for (const part of key) { + if (!node.children) { + const next = new Node(); + node.children = new Map([[part, next]]); + node = next; + } else if (!node.children.has(part)) { + const next = new Node(); + node.children.set(part, next); + node = next; + } else { + node = node.children.get(part)!; + } + onDescend?.(node); + } + + const sizeBefore = node._value === unset ? 0 : 1; + fn(node); + const sizeAfter = node._value === unset ? 0 : 1; + this._size += sizeAfter - sizeBefore; + } + + /** Returns an iterable of the tree values in no defined order. */ + *values() { + for (const { _value } of bfsIterate(this.root)) { + if (_value !== unset) { + yield _value; + } + } + } +} + +function* bfsIterate(root: Node): Iterable> { + const stack = [root]; + while (stack.length > 0) { + const node = stack.pop()!; + yield node; + + if (node.children) { + for (const child of node.children.values()) { + stack.push(child); + } + } + } +} + +class Node implements IPrefixTreeNode { + public children?: Map>; + + public get value() { + return this._value === unset ? undefined : this._value; + } + + public set value(value: T | undefined) { + this._value = value === undefined ? unset : value; + } + + public _value: T | typeof unset = unset; +} diff --git a/src/vs/base/common/process.ts b/src/vs/base/common/process.ts new file mode 100644 index 0000000000..48fcd8acb4 --- /dev/null +++ b/src/vs/base/common/process.ts @@ -0,0 +1,76 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { INodeProcess, isMacintosh, isWindows } from 'vs/base/common/platform'; + +let safeProcess: Omit & { arch: string | undefined }; +declare const process: INodeProcess; + +// Native sandbox environment +const vscodeGlobal = (globalThis as any).vscode; +if (typeof vscodeGlobal !== 'undefined' && typeof vscodeGlobal.process !== 'undefined') { + const sandboxProcess: INodeProcess = vscodeGlobal.process; + safeProcess = { + get platform() { return sandboxProcess.platform; }, + get arch() { return sandboxProcess.arch; }, + get env() { return sandboxProcess.env; }, + cwd() { return sandboxProcess.cwd(); } + }; +} + +// Native node.js environment +else if (typeof process !== 'undefined') { + safeProcess = { + get platform() { return process.platform; }, + get arch() { return process.arch; }, + get env() { return process.env; }, + cwd() { return process.env['VSCODE_CWD'] || process.cwd(); } + }; +} + +// Web environment +else { + safeProcess = { + + // Supported + get platform() { return isWindows ? 'win32' : isMacintosh ? 'darwin' : 'linux'; }, + get arch() { return undefined; /* arch is undefined in web */ }, + + // Unsupported + get env() { return {}; }, + cwd() { return '/'; } + }; +} + +/** + * Provides safe access to the `cwd` property in node.js, sandboxed or web + * environments. + * + * Note: in web, this property is hardcoded to be `/`. + * + * @skipMangle + */ +export const cwd = safeProcess.cwd; + +/** + * Provides safe access to the `env` property in node.js, sandboxed or web + * environments. + * + * Note: in web, this property is hardcoded to be `{}`. + */ +export const env = safeProcess.env; + +/** + * Provides safe access to the `platform` property in node.js, sandboxed or web + * environments. + */ +export const platform = safeProcess.platform; + +/** + * Provides safe access to the `arch` method in node.js, sandboxed or web + * environments. + * Note: `arch` is `undefined` in web + */ +export const arch = safeProcess.arch; diff --git a/src/vs/base/common/processes.ts b/src/vs/base/common/processes.ts new file mode 100644 index 0000000000..ef29387bc0 --- /dev/null +++ b/src/vs/base/common/processes.ts @@ -0,0 +1,148 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IProcessEnvironment, isLinux } from 'vs/base/common/platform'; + +/** + * Options to be passed to the external program or shell. + */ +export interface CommandOptions { + /** + * The current working directory of the executed program or shell. + * If omitted VSCode's current workspace root is used. + */ + cwd?: string; + + /** + * The environment of the executed program or shell. If omitted + * the parent process' environment is used. + */ + env?: { [key: string]: string }; +} + +export interface Executable { + /** + * The command to be executed. Can be an external program or a shell + * command. + */ + command: string; + + /** + * Specifies whether the command is a shell command and therefore must + * be executed in a shell interpreter (e.g. cmd.exe, bash, ...). + */ + isShellCommand: boolean; + + /** + * The arguments passed to the command. + */ + args: string[]; + + /** + * The command options used when the command is executed. Can be omitted. + */ + options?: CommandOptions; +} + +export interface ForkOptions extends CommandOptions { + execArgv?: string[]; +} + +export const enum Source { + stdout, + stderr +} + +/** + * The data send via a success callback + */ +export interface SuccessData { + error?: Error; + cmdCode?: number; + terminated?: boolean; +} + +/** + * The data send via a error callback + */ +export interface ErrorData { + error?: Error; + terminated?: boolean; + stdout?: string; + stderr?: string; +} + +export interface TerminateResponse { + success: boolean; + code?: TerminateResponseCode; + error?: any; +} + +export const enum TerminateResponseCode { + Success = 0, + Unknown = 1, + AccessDenied = 2, + ProcessNotFound = 3, +} + +export interface ProcessItem { + name: string; + cmd: string; + pid: number; + ppid: number; + load: number; + mem: number; + + children?: ProcessItem[]; +} + +/** + * Sanitizes a VS Code process environment by removing all Electron/VS Code-related values. + */ +export function sanitizeProcessEnvironment(env: IProcessEnvironment, ...preserve: string[]): void { + const set = preserve.reduce>((set, key) => { + set[key] = true; + return set; + }, {}); + const keysToRemove = [ + /^ELECTRON_.+$/, + /^VSCODE_(?!(PORTABLE|SHELL_LOGIN|ENV_REPLACE|ENV_APPEND|ENV_PREPEND)).+$/, + /^SNAP(|_.*)$/, + /^GDK_PIXBUF_.+$/, + ]; + const envKeys = Object.keys(env); + envKeys + .filter(key => !set[key]) + .forEach(envKey => { + for (let i = 0; i < keysToRemove.length; i++) { + if (envKey.search(keysToRemove[i]) !== -1) { + delete env[envKey]; + break; + } + } + }); +} + +/** + * Remove dangerous environment variables that have caused crashes + * in forked processes (i.e. in ELECTRON_RUN_AS_NODE processes) + * + * @param env The env object to change + */ +export function removeDangerousEnvVariables(env: IProcessEnvironment | undefined): void { + if (!env) { + return; + } + + // Unset `DEBUG`, as an invalid value might lead to process crashes + // See https://github.com/microsoft/vscode/issues/130072 + delete env['DEBUG']; + + if (isLinux) { + // Unset `LD_PRELOAD`, as it might lead to process crashes + // See https://github.com/microsoft/vscode/issues/134177 + delete env['LD_PRELOAD']; + } +} diff --git a/src/vs/base/common/product.ts b/src/vs/base/common/product.ts new file mode 100644 index 0000000000..754eba4958 --- /dev/null +++ b/src/vs/base/common/product.ts @@ -0,0 +1,314 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IStringDictionary } from 'vs/base/common/collections'; +import { PlatformName } from 'vs/base/common/platform'; + +export interface IBuiltInExtension { + readonly name: string; + readonly version: string; + readonly repo: string; + readonly metadata: any; +} + +export interface IProductWalkthrough { + id: string; + steps: IProductWalkthroughStep[]; +} + +export interface IProductWalkthroughStep { + id: string; + title: string; + when: string; + description: string; + media: + | { type: 'image'; path: string | { hc: string; hcLight?: string; light: string; dark: string }; altText: string } + | { type: 'svg'; path: string; altText: string } + | { type: 'markdown'; path: string }; +} + +export interface IFeaturedExtension { + readonly id: string; + readonly title: string; + readonly description: string; + readonly imagePath: string; +} + +export type ConfigurationSyncStore = { + url: string; + insidersUrl: string; + stableUrl: string; + canSwitch?: boolean; + authenticationProviders: IStringDictionary<{ scopes: string[] }>; +}; + +export type ExtensionUntrustedWorkspaceSupport = { + readonly default?: boolean | 'limited'; + readonly override?: boolean | 'limited'; +}; + +export type ExtensionVirtualWorkspaceSupport = { + readonly default?: boolean; + readonly override?: boolean; +}; + +export interface IProductConfiguration { + readonly version: string; + readonly date?: string; + readonly quality?: string; + readonly commit?: string; + + readonly nameShort: string; + readonly nameLong: string; + + readonly win32AppUserModelId?: string; + readonly win32MutexName?: string; + readonly win32RegValueName?: string; + readonly applicationName: string; + readonly embedderIdentifier?: string; + + readonly urlProtocol: string; + readonly dataFolderName: string; // location for extensions (e.g. ~/.vscode-insiders) + + readonly builtInExtensions?: IBuiltInExtension[]; + readonly walkthroughMetadata?: IProductWalkthrough[]; + readonly featuredExtensions?: IFeaturedExtension[]; + + readonly downloadUrl?: string; + readonly updateUrl?: string; + readonly webUrl?: string; + readonly webEndpointUrlTemplate?: string; + readonly webviewContentExternalBaseUrlTemplate?: string; + readonly target?: string; + readonly nlsCoreBaseUrl?: string; + + readonly settingsSearchBuildId?: number; + readonly settingsSearchUrl?: string; + + readonly tasConfig?: { + endpoint: string; + telemetryEventName: string; + assignmentContextTelemetryPropertyName: string; + }; + + readonly extensionsGallery?: { + readonly serviceUrl: string; + readonly servicePPEUrl?: string; + readonly searchUrl?: string; + readonly itemUrl: string; + readonly publisherUrl: string; + readonly resourceUrlTemplate: string; + readonly controlUrl: string; + readonly nlsBaseUrl: string; + }; + + readonly extensionRecommendations?: IStringDictionary; + readonly configBasedExtensionTips?: IStringDictionary; + readonly exeBasedExtensionTips?: IStringDictionary; + readonly remoteExtensionTips?: IStringDictionary; + readonly virtualWorkspaceExtensionTips?: IStringDictionary; + readonly extensionKeywords?: IStringDictionary; + readonly keymapExtensionTips?: readonly string[]; + readonly webExtensionTips?: readonly string[]; + readonly languageExtensionTips?: readonly string[]; + readonly trustedExtensionUrlPublicKeys?: IStringDictionary; + readonly trustedExtensionAuthAccess?: string[] | IStringDictionary; + readonly trustedExtensionProtocolHandlers?: readonly string[]; + + readonly commandPaletteSuggestedCommandIds?: string[]; + + readonly crashReporter?: { + readonly companyName: string; + readonly productName: string; + }; + + readonly removeTelemetryMachineId?: boolean; + readonly enabledTelemetryLevels?: { error: boolean; usage: boolean }; + readonly enableTelemetry?: boolean; + readonly openToWelcomeMainPage?: boolean; + readonly aiConfig?: { + readonly ariaKey: string; + }; + + readonly documentationUrl?: string; + readonly serverDocumentationUrl?: string; + readonly releaseNotesUrl?: string; + readonly keyboardShortcutsUrlMac?: string; + readonly keyboardShortcutsUrlLinux?: string; + readonly keyboardShortcutsUrlWin?: string; + readonly introductoryVideosUrl?: string; + readonly tipsAndTricksUrl?: string; + readonly newsletterSignupUrl?: string; + readonly youTubeUrl?: string; + readonly requestFeatureUrl?: string; + readonly reportIssueUrl?: string; + readonly reportMarketplaceIssueUrl?: string; + readonly licenseUrl?: string; + readonly serverLicenseUrl?: string; + readonly privacyStatementUrl?: string; + readonly showTelemetryOptOut?: boolean; + + readonly serverGreeting?: string[]; + readonly serverLicense?: string[]; + readonly serverLicensePrompt?: string; + readonly serverApplicationName: string; + readonly serverDataFolderName?: string; + + readonly tunnelApplicationName?: string; + readonly tunnelApplicationConfig?: ITunnelApplicationConfig; + + readonly npsSurveyUrl?: string; + readonly cesSurveyUrl?: string; + readonly surveys?: readonly ISurveyData[]; + + readonly checksums?: { [path: string]: string }; + readonly checksumFailMoreInfoUrl?: string; + + readonly appCenter?: IAppCenterConfiguration; + + readonly portable?: string; + + readonly extensionKind?: { readonly [extensionId: string]: ('ui' | 'workspace' | 'web')[] }; + readonly extensionPointExtensionKind?: { readonly [extensionPointId: string]: ('ui' | 'workspace' | 'web')[] }; + readonly extensionSyncedKeys?: { readonly [extensionId: string]: string[] }; + + readonly extensionsEnabledWithApiProposalVersion?: string[]; + readonly extensionEnabledApiProposals?: { readonly [extensionId: string]: string[] }; + readonly extensionUntrustedWorkspaceSupport?: { readonly [extensionId: string]: ExtensionUntrustedWorkspaceSupport }; + readonly extensionVirtualWorkspacesSupport?: { readonly [extensionId: string]: ExtensionVirtualWorkspaceSupport }; + + readonly msftInternalDomains?: string[]; + readonly linkProtectionTrustedDomains?: readonly string[]; + + readonly 'configurationSync.store'?: ConfigurationSyncStore; + + readonly 'editSessions.store'?: Omit; + readonly darwinUniversalAssetId?: string; + readonly profileTemplatesUrl?: string; + + readonly commonlyUsedSettings?: string[]; + readonly aiGeneratedWorkspaceTrust?: IAiGeneratedWorkspaceTrust; + readonly gitHubEntitlement?: IGitHubEntitlement; + readonly chatWelcomeView?: IChatWelcomeView; + readonly chatParticipantRegistry?: string; +} + +export interface ITunnelApplicationConfig { + authenticationProviders: IStringDictionary<{ scopes: string[] }>; + editorWebUrl: string; + extension: IRemoteExtensionTip; +} + +export interface IExtensionRecommendations { + readonly onFileOpen: IFileOpenCondition[]; + readonly onSettingsEditorOpen?: ISettingsEditorOpenCondition; +} + +export interface ISettingsEditorOpenCondition { + readonly prerelease?: boolean | string; +} + +export interface IExtensionRecommendationCondition { + readonly important?: boolean; + readonly whenInstalled?: string[]; + readonly whenNotInstalled?: string[]; +} + +export type IFileOpenCondition = IFileLanguageCondition | IFilePathCondition | IFileContentCondition; + +export interface IFileLanguageCondition extends IExtensionRecommendationCondition { + readonly languages: string[]; +} + +export interface IFilePathCondition extends IExtensionRecommendationCondition { + readonly pathGlob: string; +} + +export type IFileContentCondition = (IFileLanguageCondition | IFilePathCondition) & { readonly contentPattern: string }; + +export interface IAppCenterConfiguration { + readonly 'win32-x64': string; + readonly 'win32-arm64': string; + readonly 'linux-x64': string; + readonly 'darwin': string; + readonly 'darwin-universal': string; + readonly 'darwin-arm64': string; +} + +export interface IConfigBasedExtensionTip { + configPath: string; + configName: string; + configScheme?: string; + recommendations: IStringDictionary<{ + name: string; + contentPattern?: string; + important?: boolean; + isExtensionPack?: boolean; + whenNotInstalled?: string[]; + }>; +} + +export interface IExeBasedExtensionTip { + friendlyName: string; + windowsPath?: string; + important?: boolean; + recommendations: IStringDictionary<{ name: string; important?: boolean; isExtensionPack?: boolean; whenNotInstalled?: string[] }>; +} + +export interface IRemoteExtensionTip { + friendlyName: string; + extensionId: string; + supportedPlatforms?: PlatformName[]; + startEntry?: { + helpLink: string; + startConnectLabel: string; + startCommand: string; + priority: number; + }; +} + +export interface IVirtualWorkspaceExtensionTip { + friendlyName: string; + extensionId: string; + supportedPlatforms?: PlatformName[]; + startEntry: { + helpLink: string; + startConnectLabel: string; + startCommand: string; + priority: number; + }; +} + +export interface ISurveyData { + surveyId: string; + surveyUrl: string; + languageId: string; + editCount: number; + userProbability: number; +} + +export interface IAiGeneratedWorkspaceTrust { + readonly title: string; + readonly checkboxText: string; + readonly trustOption: string; + readonly dontTrustOption: string; + readonly startupTrustRequestLearnMore: string; +} + +export interface IGitHubEntitlement { + providerId: string; + command: { title: string; titleWithoutPlaceHolder: string; action: string; when: string }; + entitlementUrl: string; + extensionId: string; + enablementKey: string; + confirmationMessage: string; + confirmationAction: string; +} + +export interface IChatWelcomeView { + welcomeViewId: string; + welcomeViewTitle: string; + welcomeViewContent: string; +} diff --git a/src/vs/base/common/range.ts b/src/vs/base/common/range.ts new file mode 100644 index 0000000000..93a1a26914 --- /dev/null +++ b/src/vs/base/common/range.ts @@ -0,0 +1,60 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export interface IRange { + start: number; + end: number; +} + +export interface IRangedGroup { + range: IRange; + size: number; +} + +export namespace Range { + + /** + * Returns the intersection between two ranges as a range itself. + * Returns `{ start: 0, end: 0 }` if the intersection is empty. + */ + export function intersect(one: IRange, other: IRange): IRange { + if (one.start >= other.end || other.start >= one.end) { + return { start: 0, end: 0 }; + } + + const start = Math.max(one.start, other.start); + const end = Math.min(one.end, other.end); + + if (end - start <= 0) { + return { start: 0, end: 0 }; + } + + return { start, end }; + } + + export function isEmpty(range: IRange): boolean { + return range.end - range.start <= 0; + } + + export function intersects(one: IRange, other: IRange): boolean { + return !isEmpty(intersect(one, other)); + } + + export function relativeComplement(one: IRange, other: IRange): IRange[] { + const result: IRange[] = []; + const first = { start: one.start, end: Math.min(other.start, one.end) }; + const second = { start: Math.max(other.end, one.start), end: one.end }; + + if (!isEmpty(first)) { + result.push(first); + } + + if (!isEmpty(second)) { + result.push(second); + } + + return result; + } +} diff --git a/src/vs/base/common/scrollable.ts b/src/vs/base/common/scrollable.ts new file mode 100644 index 0000000000..4d1360c586 --- /dev/null +++ b/src/vs/base/common/scrollable.ts @@ -0,0 +1,522 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Emitter, Event } from 'vs/base/common/event'; +import { Disposable, IDisposable } from 'vs/base/common/lifecycle'; + +export const enum ScrollbarVisibility { + Auto = 1, + Hidden = 2, + Visible = 3 +} + +export interface ScrollEvent { + inSmoothScrolling: boolean; + + oldWidth: number; + oldScrollWidth: number; + oldScrollLeft: number; + + width: number; + scrollWidth: number; + scrollLeft: number; + + oldHeight: number; + oldScrollHeight: number; + oldScrollTop: number; + + height: number; + scrollHeight: number; + scrollTop: number; + + widthChanged: boolean; + scrollWidthChanged: boolean; + scrollLeftChanged: boolean; + + heightChanged: boolean; + scrollHeightChanged: boolean; + scrollTopChanged: boolean; +} + +export class ScrollState implements IScrollDimensions, IScrollPosition { + _scrollStateBrand: void = undefined; + + public readonly rawScrollLeft: number; + public readonly rawScrollTop: number; + + public readonly width: number; + public readonly scrollWidth: number; + public readonly scrollLeft: number; + public readonly height: number; + public readonly scrollHeight: number; + public readonly scrollTop: number; + + constructor( + private readonly _forceIntegerValues: boolean, + width: number, + scrollWidth: number, + scrollLeft: number, + height: number, + scrollHeight: number, + scrollTop: number + ) { + if (this._forceIntegerValues) { + width = width | 0; + scrollWidth = scrollWidth | 0; + scrollLeft = scrollLeft | 0; + height = height | 0; + scrollHeight = scrollHeight | 0; + scrollTop = scrollTop | 0; + } + + this.rawScrollLeft = scrollLeft; // before validation + this.rawScrollTop = scrollTop; // before validation + + if (width < 0) { + width = 0; + } + if (scrollLeft + width > scrollWidth) { + scrollLeft = scrollWidth - width; + } + if (scrollLeft < 0) { + scrollLeft = 0; + } + + if (height < 0) { + height = 0; + } + if (scrollTop + height > scrollHeight) { + scrollTop = scrollHeight - height; + } + if (scrollTop < 0) { + scrollTop = 0; + } + + this.width = width; + this.scrollWidth = scrollWidth; + this.scrollLeft = scrollLeft; + this.height = height; + this.scrollHeight = scrollHeight; + this.scrollTop = scrollTop; + } + + public equals(other: ScrollState): boolean { + return ( + this.rawScrollLeft === other.rawScrollLeft + && this.rawScrollTop === other.rawScrollTop + && this.width === other.width + && this.scrollWidth === other.scrollWidth + && this.scrollLeft === other.scrollLeft + && this.height === other.height + && this.scrollHeight === other.scrollHeight + && this.scrollTop === other.scrollTop + ); + } + + public withScrollDimensions(update: INewScrollDimensions, useRawScrollPositions: boolean): ScrollState { + return new ScrollState( + this._forceIntegerValues, + (typeof update.width !== 'undefined' ? update.width : this.width), + (typeof update.scrollWidth !== 'undefined' ? update.scrollWidth : this.scrollWidth), + useRawScrollPositions ? this.rawScrollLeft : this.scrollLeft, + (typeof update.height !== 'undefined' ? update.height : this.height), + (typeof update.scrollHeight !== 'undefined' ? update.scrollHeight : this.scrollHeight), + useRawScrollPositions ? this.rawScrollTop : this.scrollTop + ); + } + + public withScrollPosition(update: INewScrollPosition): ScrollState { + return new ScrollState( + this._forceIntegerValues, + this.width, + this.scrollWidth, + (typeof update.scrollLeft !== 'undefined' ? update.scrollLeft : this.rawScrollLeft), + this.height, + this.scrollHeight, + (typeof update.scrollTop !== 'undefined' ? update.scrollTop : this.rawScrollTop) + ); + } + + public createScrollEvent(previous: ScrollState, inSmoothScrolling: boolean): ScrollEvent { + const widthChanged = (this.width !== previous.width); + const scrollWidthChanged = (this.scrollWidth !== previous.scrollWidth); + const scrollLeftChanged = (this.scrollLeft !== previous.scrollLeft); + + const heightChanged = (this.height !== previous.height); + const scrollHeightChanged = (this.scrollHeight !== previous.scrollHeight); + const scrollTopChanged = (this.scrollTop !== previous.scrollTop); + + return { + inSmoothScrolling: inSmoothScrolling, + oldWidth: previous.width, + oldScrollWidth: previous.scrollWidth, + oldScrollLeft: previous.scrollLeft, + + width: this.width, + scrollWidth: this.scrollWidth, + scrollLeft: this.scrollLeft, + + oldHeight: previous.height, + oldScrollHeight: previous.scrollHeight, + oldScrollTop: previous.scrollTop, + + height: this.height, + scrollHeight: this.scrollHeight, + scrollTop: this.scrollTop, + + widthChanged: widthChanged, + scrollWidthChanged: scrollWidthChanged, + scrollLeftChanged: scrollLeftChanged, + + heightChanged: heightChanged, + scrollHeightChanged: scrollHeightChanged, + scrollTopChanged: scrollTopChanged, + }; + } + +} + +export interface IScrollDimensions { + readonly width: number; + readonly scrollWidth: number; + readonly height: number; + readonly scrollHeight: number; +} +export interface INewScrollDimensions { + width?: number; + scrollWidth?: number; + height?: number; + scrollHeight?: number; +} + +export interface IScrollPosition { + readonly scrollLeft: number; + readonly scrollTop: number; +} +export interface ISmoothScrollPosition { + readonly scrollLeft: number; + readonly scrollTop: number; + + readonly width: number; + readonly height: number; +} +export interface INewScrollPosition { + scrollLeft?: number; + scrollTop?: number; +} + +export interface IScrollableOptions { + /** + * Define if the scroll values should always be integers. + */ + forceIntegerValues: boolean; + /** + * Set the duration (ms) used for smooth scroll animations. + */ + smoothScrollDuration: number; + /** + * A function to schedule an update at the next frame (used for smooth scroll animations). + */ + scheduleAtNextAnimationFrame: (callback: () => void) => IDisposable; +} + +export class Scrollable extends Disposable { + + _scrollableBrand: void = undefined; + + private _smoothScrollDuration: number; + private readonly _scheduleAtNextAnimationFrame: (callback: () => void) => IDisposable; + private _state: ScrollState; + private _smoothScrolling: SmoothScrollingOperation | null; + + private _onScroll = this._register(new Emitter()); + public readonly onScroll: Event = this._onScroll.event; + + constructor(options: IScrollableOptions) { + super(); + + this._smoothScrollDuration = options.smoothScrollDuration; + this._scheduleAtNextAnimationFrame = options.scheduleAtNextAnimationFrame; + this._state = new ScrollState(options.forceIntegerValues, 0, 0, 0, 0, 0, 0); + this._smoothScrolling = null; + } + + public override dispose(): void { + if (this._smoothScrolling) { + this._smoothScrolling.dispose(); + this._smoothScrolling = null; + } + super.dispose(); + } + + public setSmoothScrollDuration(smoothScrollDuration: number): void { + this._smoothScrollDuration = smoothScrollDuration; + } + + public validateScrollPosition(scrollPosition: INewScrollPosition): IScrollPosition { + return this._state.withScrollPosition(scrollPosition); + } + + public getScrollDimensions(): IScrollDimensions { + return this._state; + } + + public setScrollDimensions(dimensions: INewScrollDimensions, useRawScrollPositions: boolean): void { + const newState = this._state.withScrollDimensions(dimensions, useRawScrollPositions); + this._setState(newState, Boolean(this._smoothScrolling)); + + // Validate outstanding animated scroll position target + this._smoothScrolling?.acceptScrollDimensions(this._state); + } + + /** + * Returns the final scroll position that the instance will have once the smooth scroll animation concludes. + * If no scroll animation is occurring, it will return the current scroll position instead. + */ + public getFutureScrollPosition(): IScrollPosition { + if (this._smoothScrolling) { + return this._smoothScrolling.to; + } + return this._state; + } + + /** + * Returns the current scroll position. + * Note: This result might be an intermediate scroll position, as there might be an ongoing smooth scroll animation. + */ + public getCurrentScrollPosition(): IScrollPosition { + return this._state; + } + + public setScrollPositionNow(update: INewScrollPosition): void { + // no smooth scrolling requested + const newState = this._state.withScrollPosition(update); + + // Terminate any outstanding smooth scrolling + if (this._smoothScrolling) { + this._smoothScrolling.dispose(); + this._smoothScrolling = null; + } + + this._setState(newState, false); + } + + public setScrollPositionSmooth(update: INewScrollPosition, reuseAnimation?: boolean): void { + if (this._smoothScrollDuration === 0) { + // Smooth scrolling not supported. + return this.setScrollPositionNow(update); + } + + if (this._smoothScrolling) { + // Combine our pending scrollLeft/scrollTop with incoming scrollLeft/scrollTop + update = { + scrollLeft: (typeof update.scrollLeft === 'undefined' ? this._smoothScrolling.to.scrollLeft : update.scrollLeft), + scrollTop: (typeof update.scrollTop === 'undefined' ? this._smoothScrolling.to.scrollTop : update.scrollTop) + }; + + // Validate `update` + const validTarget = this._state.withScrollPosition(update); + + if (this._smoothScrolling.to.scrollLeft === validTarget.scrollLeft && this._smoothScrolling.to.scrollTop === validTarget.scrollTop) { + // No need to interrupt or extend the current animation since we're going to the same place + return; + } + let newSmoothScrolling: SmoothScrollingOperation; + if (reuseAnimation) { + newSmoothScrolling = new SmoothScrollingOperation(this._smoothScrolling.from, validTarget, this._smoothScrolling.startTime, this._smoothScrolling.duration); + } else { + newSmoothScrolling = this._smoothScrolling.combine(this._state, validTarget, this._smoothScrollDuration); + } + this._smoothScrolling.dispose(); + this._smoothScrolling = newSmoothScrolling; + } else { + // Validate `update` + const validTarget = this._state.withScrollPosition(update); + + this._smoothScrolling = SmoothScrollingOperation.start(this._state, validTarget, this._smoothScrollDuration); + } + + // Begin smooth scrolling animation + this._smoothScrolling.animationFrameDisposable = this._scheduleAtNextAnimationFrame(() => { + if (!this._smoothScrolling) { + return; + } + this._smoothScrolling.animationFrameDisposable = null; + this._performSmoothScrolling(); + }); + } + + public hasPendingScrollAnimation(): boolean { + return Boolean(this._smoothScrolling); + } + + private _performSmoothScrolling(): void { + if (!this._smoothScrolling) { + return; + } + const update = this._smoothScrolling.tick(); + const newState = this._state.withScrollPosition(update); + + this._setState(newState, true); + + if (!this._smoothScrolling) { + // Looks like someone canceled the smooth scrolling + // from the scroll event handler + return; + } + + if (update.isDone) { + this._smoothScrolling.dispose(); + this._smoothScrolling = null; + return; + } + + // Continue smooth scrolling animation + this._smoothScrolling.animationFrameDisposable = this._scheduleAtNextAnimationFrame(() => { + if (!this._smoothScrolling) { + return; + } + this._smoothScrolling.animationFrameDisposable = null; + this._performSmoothScrolling(); + }); + } + + private _setState(newState: ScrollState, inSmoothScrolling: boolean): void { + const oldState = this._state; + if (oldState.equals(newState)) { + // no change + return; + } + this._state = newState; + this._onScroll.fire(this._state.createScrollEvent(oldState, inSmoothScrolling)); + } +} + +export class SmoothScrollingUpdate { + + public readonly scrollLeft: number; + public readonly scrollTop: number; + public readonly isDone: boolean; + + constructor(scrollLeft: number, scrollTop: number, isDone: boolean) { + this.scrollLeft = scrollLeft; + this.scrollTop = scrollTop; + this.isDone = isDone; + } + +} + +interface IAnimation { + (completion: number): number; +} + +function createEaseOutCubic(from: number, to: number): IAnimation { + const delta = to - from; + return function (completion: number): number { + return from + delta * easeOutCubic(completion); + }; +} + +function createComposed(a: IAnimation, b: IAnimation, cut: number): IAnimation { + return function (completion: number): number { + if (completion < cut) { + return a(completion / cut); + } + return b((completion - cut) / (1 - cut)); + }; +} + +export class SmoothScrollingOperation { + + public readonly from: ISmoothScrollPosition; + public to: ISmoothScrollPosition; + public readonly duration: number; + public readonly startTime: number; + public animationFrameDisposable: IDisposable | null; + + private scrollLeft!: IAnimation; + private scrollTop!: IAnimation; + + constructor(from: ISmoothScrollPosition, to: ISmoothScrollPosition, startTime: number, duration: number) { + this.from = from; + this.to = to; + this.duration = duration; + this.startTime = startTime; + + this.animationFrameDisposable = null; + + this._initAnimations(); + } + + private _initAnimations(): void { + this.scrollLeft = this._initAnimation(this.from.scrollLeft, this.to.scrollLeft, this.to.width); + this.scrollTop = this._initAnimation(this.from.scrollTop, this.to.scrollTop, this.to.height); + } + + private _initAnimation(from: number, to: number, viewportSize: number): IAnimation { + const delta = Math.abs(from - to); + if (delta > 2.5 * viewportSize) { + let stop1: number, stop2: number; + if (from < to) { + // scroll to 75% of the viewportSize + stop1 = from + 0.75 * viewportSize; + stop2 = to - 0.75 * viewportSize; + } else { + stop1 = from - 0.75 * viewportSize; + stop2 = to + 0.75 * viewportSize; + } + return createComposed(createEaseOutCubic(from, stop1), createEaseOutCubic(stop2, to), 0.33); + } + return createEaseOutCubic(from, to); + } + + public dispose(): void { + if (this.animationFrameDisposable !== null) { + this.animationFrameDisposable.dispose(); + this.animationFrameDisposable = null; + } + } + + public acceptScrollDimensions(state: ScrollState): void { + this.to = state.withScrollPosition(this.to); + this._initAnimations(); + } + + public tick(): SmoothScrollingUpdate { + return this._tick(Date.now()); + } + + protected _tick(now: number): SmoothScrollingUpdate { + const completion = (now - this.startTime) / this.duration; + + if (completion < 1) { + const newScrollLeft = this.scrollLeft(completion); + const newScrollTop = this.scrollTop(completion); + return new SmoothScrollingUpdate(newScrollLeft, newScrollTop, false); + } + + return new SmoothScrollingUpdate(this.to.scrollLeft, this.to.scrollTop, true); + } + + public combine(from: ISmoothScrollPosition, to: ISmoothScrollPosition, duration: number): SmoothScrollingOperation { + return SmoothScrollingOperation.start(from, to, duration); + } + + public static start(from: ISmoothScrollPosition, to: ISmoothScrollPosition, duration: number): SmoothScrollingOperation { + // +10 / -10 : pretend the animation already started for a quicker response to a scroll request + duration = duration + 10; + const startTime = Date.now() - 10; + + return new SmoothScrollingOperation(from, to, startTime, duration); + } +} + +function easeInCubic(t: number) { + return Math.pow(t, 3); +} + +function easeOutCubic(t: number) { + return 1 - easeInCubic(1 - t); +} diff --git a/src/vs/base/common/search.ts b/src/vs/base/common/search.ts new file mode 100644 index 0000000000..24c5bf7929 --- /dev/null +++ b/src/vs/base/common/search.ts @@ -0,0 +1,48 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as strings from './strings'; + +export function buildReplaceStringWithCasePreserved(matches: string[] | null, pattern: string): string { + if (matches && (matches[0] !== '')) { + const containsHyphens = validateSpecificSpecialCharacter(matches, pattern, '-'); + const containsUnderscores = validateSpecificSpecialCharacter(matches, pattern, '_'); + if (containsHyphens && !containsUnderscores) { + return buildReplaceStringForSpecificSpecialCharacter(matches, pattern, '-'); + } else if (!containsHyphens && containsUnderscores) { + return buildReplaceStringForSpecificSpecialCharacter(matches, pattern, '_'); + } + if (matches[0].toUpperCase() === matches[0]) { + return pattern.toUpperCase(); + } else if (matches[0].toLowerCase() === matches[0]) { + return pattern.toLowerCase(); + } else if (strings.containsUppercaseCharacter(matches[0][0]) && pattern.length > 0) { + return pattern[0].toUpperCase() + pattern.substr(1); + } else if (matches[0][0].toUpperCase() !== matches[0][0] && pattern.length > 0) { + return pattern[0].toLowerCase() + pattern.substr(1); + } else { + // we don't understand its pattern yet. + return pattern; + } + } else { + return pattern; + } +} + +function validateSpecificSpecialCharacter(matches: string[], pattern: string, specialCharacter: string): boolean { + const doesContainSpecialCharacter = matches[0].indexOf(specialCharacter) !== -1 && pattern.indexOf(specialCharacter) !== -1; + return doesContainSpecialCharacter && matches[0].split(specialCharacter).length === pattern.split(specialCharacter).length; +} + +function buildReplaceStringForSpecificSpecialCharacter(matches: string[], pattern: string, specialCharacter: string): string { + const splitPatternAtSpecialCharacter = pattern.split(specialCharacter); + const splitMatchAtSpecialCharacter = matches[0].split(specialCharacter); + let replaceString: string = ''; + splitPatternAtSpecialCharacter.forEach((splitValue, index) => { + replaceString += buildReplaceStringWithCasePreserved([splitMatchAtSpecialCharacter[index]], splitValue) + specialCharacter; + }); + + return replaceString.slice(0, -1); +} diff --git a/src/vs/base/common/sequence.ts b/src/vs/base/common/sequence.ts new file mode 100644 index 0000000000..b68bc7cdc8 --- /dev/null +++ b/src/vs/base/common/sequence.ts @@ -0,0 +1,34 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Emitter, Event } from 'vs/base/common/event'; + +export interface ISplice { + readonly start: number; + readonly deleteCount: number; + readonly toInsert: readonly T[]; +} + +export interface ISpliceable { + splice(start: number, deleteCount: number, toInsert: readonly T[]): void; +} + +export interface ISequence { + readonly elements: T[]; + readonly onDidSplice: Event>; +} + +export class Sequence implements ISequence, ISpliceable { + + readonly elements: T[] = []; + + private readonly _onDidSplice = new Emitter>(); + readonly onDidSplice: Event> = this._onDidSplice.event; + + splice(start: number, deleteCount: number, toInsert: readonly T[] = []): void { + this.elements.splice(start, deleteCount, ...toInsert); + this._onDidSplice.fire({ start, deleteCount, toInsert }); + } +} diff --git a/src/vs/base/common/severity.ts b/src/vs/base/common/severity.ts new file mode 100644 index 0000000000..83e753d8cf --- /dev/null +++ b/src/vs/base/common/severity.ts @@ -0,0 +1,56 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as strings from 'vs/base/common/strings'; + +enum Severity { + Ignore = 0, + Info = 1, + Warning = 2, + Error = 3 +} + +namespace Severity { + + const _error = 'error'; + const _warning = 'warning'; + const _warn = 'warn'; + const _info = 'info'; + const _ignore = 'ignore'; + + /** + * Parses 'error', 'warning', 'warn', 'info' in call casings + * and falls back to ignore. + */ + export function fromValue(value: string): Severity { + if (!value) { + return Severity.Ignore; + } + + if (strings.equalsIgnoreCase(_error, value)) { + return Severity.Error; + } + + if (strings.equalsIgnoreCase(_warning, value) || strings.equalsIgnoreCase(_warn, value)) { + return Severity.Warning; + } + + if (strings.equalsIgnoreCase(_info, value)) { + return Severity.Info; + } + return Severity.Ignore; + } + + export function toString(severity: Severity): string { + switch (severity) { + case Severity.Error: return _error; + case Severity.Warning: return _warning; + case Severity.Info: return _info; + default: return _ignore; + } + } +} + +export default Severity; diff --git a/src/vs/base/common/skipList.ts b/src/vs/base/common/skipList.ts new file mode 100644 index 0000000000..ed3fd7e5a0 --- /dev/null +++ b/src/vs/base/common/skipList.ts @@ -0,0 +1,204 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +class Node { + readonly forward: Node[]; + constructor(readonly level: number, readonly key: K, public value: V) { + this.forward = []; + } +} + +const NIL: undefined = undefined; + +interface Comparator { + (a: K, b: K): number; +} + +export class SkipList implements Map { + + readonly [Symbol.toStringTag] = 'SkipList'; + + private _maxLevel: number; + private _level: number = 0; + private _header: Node; + private _size: number = 0; + + /** + * + * @param capacity Capacity at which the list performs best + */ + constructor( + readonly comparator: (a: K, b: K) => number, + capacity: number = 2 ** 16 + ) { + this._maxLevel = Math.max(1, Math.log2(capacity) | 0); + this._header = new Node(this._maxLevel, NIL, NIL); + } + + get size(): number { + return this._size; + } + + clear(): void { + this._header = new Node(this._maxLevel, NIL, NIL); + this._size = 0; + } + + has(key: K): boolean { + return Boolean(SkipList._search(this, key, this.comparator)); + } + + get(key: K): V | undefined { + return SkipList._search(this, key, this.comparator)?.value; + } + + set(key: K, value: V): this { + if (SkipList._insert(this, key, value, this.comparator)) { + this._size += 1; + } + return this; + } + + delete(key: K): boolean { + const didDelete = SkipList._delete(this, key, this.comparator); + if (didDelete) { + this._size -= 1; + } + return didDelete; + } + + // --- iteration + + forEach(callbackfn: (value: V, key: K, map: Map) => void, thisArg?: any): void { + let node = this._header.forward[0]; + while (node) { + callbackfn.call(thisArg, node.value, node.key, this); + node = node.forward[0]; + } + } + + [Symbol.iterator](): IterableIterator<[K, V]> { + return this.entries(); + } + + *entries(): IterableIterator<[K, V]> { + let node = this._header.forward[0]; + while (node) { + yield [node.key, node.value]; + node = node.forward[0]; + } + } + + *keys(): IterableIterator { + let node = this._header.forward[0]; + while (node) { + yield node.key; + node = node.forward[0]; + } + } + + *values(): IterableIterator { + let node = this._header.forward[0]; + while (node) { + yield node.value; + node = node.forward[0]; + } + } + + toString(): string { + // debug string... + let result = '[SkipList]:'; + let node = this._header.forward[0]; + while (node) { + result += `node(${node.key}, ${node.value}, lvl:${node.level})`; + node = node.forward[0]; + } + return result; + } + + // from https://www.epaperpress.com/sortsearch/download/skiplist.pdf + + private static _search(list: SkipList, searchKey: K, comparator: Comparator) { + let x = list._header; + for (let i = list._level - 1; i >= 0; i--) { + while (x.forward[i] && comparator(x.forward[i].key, searchKey) < 0) { + x = x.forward[i]; + } + } + x = x.forward[0]; + if (x && comparator(x.key, searchKey) === 0) { + return x; + } + return undefined; + } + + private static _insert(list: SkipList, searchKey: K, value: V, comparator: Comparator) { + const update: Node[] = []; + let x = list._header; + for (let i = list._level - 1; i >= 0; i--) { + while (x.forward[i] && comparator(x.forward[i].key, searchKey) < 0) { + x = x.forward[i]; + } + update[i] = x; + } + x = x.forward[0]; + if (x && comparator(x.key, searchKey) === 0) { + // update + x.value = value; + return false; + } else { + // insert + const lvl = SkipList._randomLevel(list); + if (lvl > list._level) { + for (let i = list._level; i < lvl; i++) { + update[i] = list._header; + } + list._level = lvl; + } + x = new Node(lvl, searchKey, value); + for (let i = 0; i < lvl; i++) { + x.forward[i] = update[i].forward[i]; + update[i].forward[i] = x; + } + return true; + } + } + + private static _randomLevel(list: SkipList, p: number = 0.5): number { + let lvl = 1; + while (Math.random() < p && lvl < list._maxLevel) { + lvl += 1; + } + return lvl; + } + + private static _delete(list: SkipList, searchKey: K, comparator: Comparator) { + const update: Node[] = []; + let x = list._header; + for (let i = list._level - 1; i >= 0; i--) { + while (x.forward[i] && comparator(x.forward[i].key, searchKey) < 0) { + x = x.forward[i]; + } + update[i] = x; + } + x = x.forward[0]; + if (!x || comparator(x.key, searchKey) !== 0) { + // not found + return false; + } + for (let i = 0; i < list._level; i++) { + if (update[i].forward[i] !== x) { + break; + } + update[i].forward[i] = x.forward[i]; + } + while (list._level > 0 && list._header.forward[list._level - 1] === NIL) { + list._level -= 1; + } + return true; + } + +} diff --git a/src/vs/base/common/stopwatch.ts b/src/vs/base/common/stopwatch.ts new file mode 100644 index 0000000000..e32c0dd9d9 --- /dev/null +++ b/src/vs/base/common/stopwatch.ts @@ -0,0 +1,43 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// fake definition so that the valid layers check won't trip on this +declare const globalThis: { performance?: { now(): number } }; + +const hasPerformanceNow = (globalThis.performance && typeof globalThis.performance.now === 'function'); + +export class StopWatch { + + private _startTime: number; + private _stopTime: number; + + private readonly _now: () => number; + + public static create(highResolution?: boolean): StopWatch { + return new StopWatch(highResolution); + } + + constructor(highResolution?: boolean) { + this._now = hasPerformanceNow && highResolution === false ? Date.now : globalThis.performance!.now.bind(globalThis.performance); + this._startTime = this._now(); + this._stopTime = -1; + } + + public stop(): void { + this._stopTime = this._now(); + } + + public reset(): void { + this._startTime = this._now(); + this._stopTime = -1; + } + + public elapsed(): number { + if (this._stopTime !== -1) { + return this._stopTime - this._startTime; + } + return this._now() - this._startTime; + } +} diff --git a/src/vs/base/common/stream.ts b/src/vs/base/common/stream.ts new file mode 100644 index 0000000000..d6cd674b1d --- /dev/null +++ b/src/vs/base/common/stream.ts @@ -0,0 +1,772 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CancellationToken } from 'vs/base/common/cancellation'; +import { onUnexpectedError } from 'vs/base/common/errors'; +import { DisposableStore, toDisposable } from 'vs/base/common/lifecycle'; + +/** + * The payload that flows in readable stream events. + */ +export type ReadableStreamEventPayload = T | Error | 'end'; + +export interface ReadableStreamEvents { + + /** + * The 'data' event is emitted whenever the stream is + * relinquishing ownership of a chunk of data to a consumer. + * + * NOTE: PLEASE UNDERSTAND THAT ADDING A DATA LISTENER CAN + * TURN THE STREAM INTO FLOWING MODE. IT IS THEREFOR THE + * LAST LISTENER THAT SHOULD BE ADDED AND NOT THE FIRST + * + * Use `listenStream` as a helper method to listen to + * stream events in the right order. + */ + on(event: 'data', callback: (data: T) => void): void; + + /** + * Emitted when any error occurs. + */ + on(event: 'error', callback: (err: Error) => void): void; + + /** + * The 'end' event is emitted when there is no more data + * to be consumed from the stream. The 'end' event will + * not be emitted unless the data is completely consumed. + */ + on(event: 'end', callback: () => void): void; +} + +/** + * A interface that emulates the API shape of a node.js readable + * stream for use in native and web environments. + */ +export interface ReadableStream extends ReadableStreamEvents { + + /** + * Stops emitting any events until resume() is called. + */ + pause(): void; + + /** + * Starts emitting events again after pause() was called. + */ + resume(): void; + + /** + * Destroys the stream and stops emitting any event. + */ + destroy(): void; + + /** + * Allows to remove a listener that was previously added. + */ + removeListener(event: string, callback: Function): void; +} + +/** + * A interface that emulates the API shape of a node.js readable + * for use in native and web environments. + */ +export interface Readable { + + /** + * Read data from the underlying source. Will return + * null to indicate that no more data can be read. + */ + read(): T | null; +} + +export function isReadable(obj: unknown): obj is Readable { + const candidate = obj as Readable | undefined; + if (!candidate) { + return false; + } + + return typeof candidate.read === 'function'; +} + +/** + * A interface that emulates the API shape of a node.js writeable + * stream for use in native and web environments. + */ +export interface WriteableStream extends ReadableStream { + + /** + * Writing data to the stream will trigger the on('data') + * event listener if the stream is flowing and buffer the + * data otherwise until the stream is flowing. + * + * If a `highWaterMark` is configured and writing to the + * stream reaches this mark, a promise will be returned + * that should be awaited on before writing more data. + * Otherwise there is a risk of buffering a large number + * of data chunks without consumer. + */ + write(data: T): void | Promise; + + /** + * Signals an error to the consumer of the stream via the + * on('error') handler if the stream is flowing. + * + * NOTE: call `end` to signal that the stream has ended, + * this DOES NOT happen automatically from `error`. + */ + error(error: Error): void; + + /** + * Signals the end of the stream to the consumer. If the + * result is provided, will trigger the on('data') event + * listener if the stream is flowing and buffer the data + * otherwise until the stream is flowing. + */ + end(result?: T): void; +} + +/** + * A stream that has a buffer already read. Returns the original stream + * that was read as well as the chunks that got read. + * + * The `ended` flag indicates if the stream has been fully consumed. + */ +export interface ReadableBufferedStream { + + /** + * The original stream that is being read. + */ + stream: ReadableStream; + + /** + * An array of chunks already read from this stream. + */ + buffer: T[]; + + /** + * Signals if the stream has ended or not. If not, consumers + * should continue to read from the stream until consumed. + */ + ended: boolean; +} + +export function isReadableStream(obj: unknown): obj is ReadableStream { + const candidate = obj as ReadableStream | undefined; + if (!candidate) { + return false; + } + + return [candidate.on, candidate.pause, candidate.resume, candidate.destroy].every(fn => typeof fn === 'function'); +} + +export function isReadableBufferedStream(obj: unknown): obj is ReadableBufferedStream { + const candidate = obj as ReadableBufferedStream | undefined; + if (!candidate) { + return false; + } + + return isReadableStream(candidate.stream) && Array.isArray(candidate.buffer) && typeof candidate.ended === 'boolean'; +} + +export interface IReducer { + (data: T[]): R; +} + +export interface IDataTransformer { + (data: Original): Transformed; +} + +export interface IErrorTransformer { + (error: Error): Error; +} + +export interface ITransformer { + data: IDataTransformer; + error?: IErrorTransformer; +} + +export function newWriteableStream(reducer: IReducer, options?: WriteableStreamOptions): WriteableStream { + return new WriteableStreamImpl(reducer, options); +} + +export interface WriteableStreamOptions { + + /** + * The number of objects to buffer before WriteableStream#write() + * signals back that the buffer is full. Can be used to reduce + * the memory pressure when the stream is not flowing. + */ + highWaterMark?: number; +} + +class WriteableStreamImpl implements WriteableStream { + + private readonly state = { + flowing: false, + ended: false, + destroyed: false + }; + + private readonly buffer = { + data: [] as T[], + error: [] as Error[] + }; + + private readonly listeners = { + data: [] as { (data: T): void }[], + error: [] as { (error: Error): void }[], + end: [] as { (): void }[] + }; + + private readonly pendingWritePromises: Function[] = []; + + constructor(private reducer: IReducer, private options?: WriteableStreamOptions) { } + + pause(): void { + if (this.state.destroyed) { + return; + } + + this.state.flowing = false; + } + + resume(): void { + if (this.state.destroyed) { + return; + } + + if (!this.state.flowing) { + this.state.flowing = true; + + // emit buffered events + this.flowData(); + this.flowErrors(); + this.flowEnd(); + } + } + + write(data: T): void | Promise { + if (this.state.destroyed) { + return; + } + + // flowing: directly send the data to listeners + if (this.state.flowing) { + this.emitData(data); + } + + // not yet flowing: buffer data until flowing + else { + this.buffer.data.push(data); + + // highWaterMark: if configured, signal back when buffer reached limits + if (typeof this.options?.highWaterMark === 'number' && this.buffer.data.length > this.options.highWaterMark) { + return new Promise(resolve => this.pendingWritePromises.push(resolve)); + } + } + } + + error(error: Error): void { + if (this.state.destroyed) { + return; + } + + // flowing: directly send the error to listeners + if (this.state.flowing) { + this.emitError(error); + } + + // not yet flowing: buffer errors until flowing + else { + this.buffer.error.push(error); + } + } + + end(result?: T): void { + if (this.state.destroyed) { + return; + } + + // end with data if provided + if (typeof result !== 'undefined') { + this.write(result); + } + + // flowing: send end event to listeners + if (this.state.flowing) { + this.emitEnd(); + + this.destroy(); + } + + // not yet flowing: remember state + else { + this.state.ended = true; + } + } + + private emitData(data: T): void { + this.listeners.data.slice(0).forEach(listener => listener(data)); // slice to avoid listener mutation from delivering event + } + + private emitError(error: Error): void { + if (this.listeners.error.length === 0) { + onUnexpectedError(error); // nobody listened to this error so we log it as unexpected + } else { + this.listeners.error.slice(0).forEach(listener => listener(error)); // slice to avoid listener mutation from delivering event + } + } + + private emitEnd(): void { + this.listeners.end.slice(0).forEach(listener => listener()); // slice to avoid listener mutation from delivering event + } + + on(event: 'data', callback: (data: T) => void): void; + on(event: 'error', callback: (err: Error) => void): void; + on(event: 'end', callback: () => void): void; + on(event: 'data' | 'error' | 'end', callback: (arg0?: any) => void): void { + if (this.state.destroyed) { + return; + } + + switch (event) { + case 'data': + this.listeners.data.push(callback); + + // switch into flowing mode as soon as the first 'data' + // listener is added and we are not yet in flowing mode + this.resume(); + + break; + + case 'end': + this.listeners.end.push(callback); + + // emit 'end' event directly if we are flowing + // and the end has already been reached + // + // finish() when it went through + if (this.state.flowing && this.flowEnd()) { + this.destroy(); + } + + break; + + case 'error': + this.listeners.error.push(callback); + + // emit buffered 'error' events unless done already + // now that we know that we have at least one listener + if (this.state.flowing) { + this.flowErrors(); + } + + break; + } + } + + removeListener(event: string, callback: Function): void { + if (this.state.destroyed) { + return; + } + + let listeners: unknown[] | undefined = undefined; + + switch (event) { + case 'data': + listeners = this.listeners.data; + break; + + case 'end': + listeners = this.listeners.end; + break; + + case 'error': + listeners = this.listeners.error; + break; + } + + if (listeners) { + const index = listeners.indexOf(callback); + if (index >= 0) { + listeners.splice(index, 1); + } + } + } + + private flowData(): void { + if (this.buffer.data.length > 0) { + const fullDataBuffer = this.reducer(this.buffer.data); + + this.emitData(fullDataBuffer); + + this.buffer.data.length = 0; + + // When the buffer is empty, resolve all pending writers + const pendingWritePromises = [...this.pendingWritePromises]; + this.pendingWritePromises.length = 0; + pendingWritePromises.forEach(pendingWritePromise => pendingWritePromise()); + } + } + + private flowErrors(): void { + if (this.listeners.error.length > 0) { + for (const error of this.buffer.error) { + this.emitError(error); + } + + this.buffer.error.length = 0; + } + } + + private flowEnd(): boolean { + if (this.state.ended) { + this.emitEnd(); + + return this.listeners.end.length > 0; + } + + return false; + } + + destroy(): void { + if (!this.state.destroyed) { + this.state.destroyed = true; + this.state.ended = true; + + this.buffer.data.length = 0; + this.buffer.error.length = 0; + + this.listeners.data.length = 0; + this.listeners.error.length = 0; + this.listeners.end.length = 0; + + this.pendingWritePromises.length = 0; + } + } +} + +/** + * Helper to fully read a T readable into a T. + */ +export function consumeReadable(readable: Readable, reducer: IReducer): T { + const chunks: T[] = []; + + let chunk: T | null; + while ((chunk = readable.read()) !== null) { + chunks.push(chunk); + } + + return reducer(chunks); +} + +/** + * Helper to read a T readable up to a maximum of chunks. If the limit is + * reached, will return a readable instead to ensure all data can still + * be read. + */ +export function peekReadable(readable: Readable, reducer: IReducer, maxChunks: number): T | Readable { + const chunks: T[] = []; + + let chunk: T | null | undefined = undefined; + while ((chunk = readable.read()) !== null && chunks.length < maxChunks) { + chunks.push(chunk); + } + + // If the last chunk is null, it means we reached the end of + // the readable and return all the data at once + if (chunk === null && chunks.length > 0) { + return reducer(chunks); + } + + // Otherwise, we still have a chunk, it means we reached the maxChunks + // value and as such we return a new Readable that first returns + // the existing read chunks and then continues with reading from + // the underlying readable. + return { + read: () => { + + // First consume chunks from our array + if (chunks.length > 0) { + return chunks.shift()!; + } + + // Then ensure to return our last read chunk + if (typeof chunk !== 'undefined') { + const lastReadChunk = chunk; + + // explicitly use undefined here to indicate that we consumed + // the chunk, which could have either been null or valued. + chunk = undefined; + + return lastReadChunk; + } + + // Finally delegate back to the Readable + return readable.read(); + } + }; +} + +/** + * Helper to fully read a T stream into a T or consuming + * a stream fully, awaiting all the events without caring + * about the data. + */ +export function consumeStream(stream: ReadableStreamEvents, reducer: IReducer): Promise; +export function consumeStream(stream: ReadableStreamEvents): Promise; +export function consumeStream(stream: ReadableStreamEvents, reducer?: IReducer): Promise { + return new Promise((resolve, reject) => { + const chunks: T[] = []; + + listenStream(stream, { + onData: chunk => { + if (reducer) { + chunks.push(chunk); + } + }, + onError: error => { + if (reducer) { + reject(error); + } else { + resolve(undefined); + } + }, + onEnd: () => { + if (reducer) { + resolve(reducer(chunks)); + } else { + resolve(undefined); + } + } + }); + }); +} + +export interface IStreamListener { + + /** + * The 'data' event is emitted whenever the stream is + * relinquishing ownership of a chunk of data to a consumer. + */ + onData(data: T): void; + + /** + * Emitted when any error occurs. + */ + onError(err: Error): void; + + /** + * The 'end' event is emitted when there is no more data + * to be consumed from the stream. The 'end' event will + * not be emitted unless the data is completely consumed. + */ + onEnd(): void; +} + +/** + * Helper to listen to all events of a T stream in proper order. + */ +export function listenStream(stream: ReadableStreamEvents, listener: IStreamListener, token?: CancellationToken): void { + + stream.on('error', error => { + if (!token?.isCancellationRequested) { + listener.onError(error); + } + }); + + stream.on('end', () => { + if (!token?.isCancellationRequested) { + listener.onEnd(); + } + }); + + // Adding the `data` listener will turn the stream + // into flowing mode. As such it is important to + // add this listener last (DO NOT CHANGE!) + stream.on('data', data => { + if (!token?.isCancellationRequested) { + listener.onData(data); + } + }); +} + +/** + * Helper to peek up to `maxChunks` into a stream. The return type signals if + * the stream has ended or not. If not, caller needs to add a `data` listener + * to continue reading. + */ +export function peekStream(stream: ReadableStream, maxChunks: number): Promise> { + return new Promise((resolve, reject) => { + const streamListeners = new DisposableStore(); + const buffer: T[] = []; + + // Data Listener + const dataListener = (chunk: T) => { + + // Add to buffer + buffer.push(chunk); + + // We reached maxChunks and thus need to return + if (buffer.length > maxChunks) { + + // Dispose any listeners and ensure to pause the + // stream so that it can be consumed again by caller + streamListeners.dispose(); + stream.pause(); + + return resolve({ stream, buffer, ended: false }); + } + }; + + // Error Listener + const errorListener = (error: Error) => { + streamListeners.dispose(); + + return reject(error); + }; + + // End Listener + const endListener = () => { + streamListeners.dispose(); + + return resolve({ stream, buffer, ended: true }); + }; + + streamListeners.add(toDisposable(() => stream.removeListener('error', errorListener))); + stream.on('error', errorListener); + + streamListeners.add(toDisposable(() => stream.removeListener('end', endListener))); + stream.on('end', endListener); + + // Important: leave the `data` listener last because + // this can turn the stream into flowing mode and we + // want `error` events to be received as well. + streamListeners.add(toDisposable(() => stream.removeListener('data', dataListener))); + stream.on('data', dataListener); + }); +} + +/** + * Helper to create a readable stream from an existing T. + */ +export function toStream(t: T, reducer: IReducer): ReadableStream { + const stream = newWriteableStream(reducer); + + stream.end(t); + + return stream; +} + +/** + * Helper to create an empty stream + */ +export function emptyStream(): ReadableStream { + const stream = newWriteableStream(() => { throw new Error('not supported'); }); + stream.end(); + + return stream; +} + +/** + * Helper to convert a T into a Readable. + */ +export function toReadable(t: T): Readable { + let consumed = false; + + return { + read: () => { + if (consumed) { + return null; + } + + consumed = true; + + return t; + } + }; +} + +/** + * Helper to transform a readable stream into another stream. + */ +export function transform(stream: ReadableStreamEvents, transformer: ITransformer, reducer: IReducer): ReadableStream { + const target = newWriteableStream(reducer); + + listenStream(stream, { + onData: data => target.write(transformer.data(data)), + onError: error => target.error(transformer.error ? transformer.error(error) : error), + onEnd: () => target.end() + }); + + return target; +} + +/** + * Helper to take an existing readable that will + * have a prefix injected to the beginning. + */ +export function prefixedReadable(prefix: T, readable: Readable, reducer: IReducer): Readable { + let prefixHandled = false; + + return { + read: () => { + const chunk = readable.read(); + + // Handle prefix only once + if (!prefixHandled) { + prefixHandled = true; + + // If we have also a read-result, make + // sure to reduce it to a single result + if (chunk !== null) { + return reducer([prefix, chunk]); + } + + // Otherwise, just return prefix directly + return prefix; + } + + return chunk; + } + }; +} + +/** + * Helper to take an existing stream that will + * have a prefix injected to the beginning. + */ +export function prefixedStream(prefix: T, stream: ReadableStream, reducer: IReducer): ReadableStream { + let prefixHandled = false; + + const target = newWriteableStream(reducer); + + listenStream(stream, { + onData: data => { + + // Handle prefix only once + if (!prefixHandled) { + prefixHandled = true; + + return target.write(reducer([prefix, data])); + } + + return target.write(data); + }, + onError: error => target.error(error), + onEnd: () => { + + // Handle prefix only once + if (!prefixHandled) { + prefixHandled = true; + + target.write(prefix); + } + + target.end(); + } + }); + + return target; +} diff --git a/src/vs/base/common/strings.ts b/src/vs/base/common/strings.ts new file mode 100644 index 0000000000..74b4f00cd9 --- /dev/null +++ b/src/vs/base/common/strings.ts @@ -0,0 +1,557 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CharCode } from 'vs/base/common/charCode'; +import { Constants } from 'vs/base/common/uint'; + +export function isFalsyOrWhitespace(str: string | undefined): boolean { + if (!str || typeof str !== 'string') { + return true; + } + return str.trim().length === 0; +} + +const _formatRegexp = /{(\d+)}/g; + +/** + * Helper to produce a string with a variable number of arguments. Insert variable segments + * into the string using the {n} notation where N is the index of the argument following the string. + * @param value string to which formatting is applied + * @param args replacements for {n}-entries + */ +export function format(value: string, ...args: any[]): string { + if (args.length === 0) { + return value; + } + return value.replace(_formatRegexp, function (match, group) { + const idx = parseInt(group, 10); + return isNaN(idx) || idx < 0 || idx >= args.length ? + match : + args[idx]; + }); +} + +const _format2Regexp = /{([^}]+)}/g; + +/** + * Helper to create a string from a template and a string record. + * Similar to `format` but with objects instead of positional arguments. + */ +export function format2(template: string, values: Record): string { + if (Object.keys(values).length === 0) { + return template; + } + return template.replace(_format2Regexp, (match, group) => (values[group] ?? match) as string); +} + +/** + * Encodes the given value so that it can be used as literal value in html attributes. + * + * In other words, computes `$val`, such that `attr` in `
` has the runtime value `value`. + * This prevents XSS injection. + */ +export function htmlAttributeEncodeValue(value: string): string { + return value.replace(/[<>"'&]/g, ch => { + switch (ch) { + case '<': return '<'; + case '>': return '>'; + case '"': return '"'; + case '\'': return '''; + case '&': return '&'; + } + return ch; + }); +} + +/** + * Converts HTML characters inside the string to use entities instead. Makes the string safe from + * being used e.g. in HTMLElement.innerHTML. + */ +export function escape(html: string): string { + return html.replace(/[<>&]/g, function (match) { + switch (match) { + case '<': return '<'; + case '>': return '>'; + case '&': return '&'; + default: return match; + } + }); +} + +/** + * Escapes regular expression characters in a given string + */ +export function escapeRegExpCharacters(value: string): string { + return value.replace(/[\\\{\}\*\+\?\|\^\$\.\[\]\(\)]/g, '\\$&'); +} + +/** + * Counts how often `substr` occurs inside `value`. + */ +export function count(value: string, substr: string): number { + let result = 0; + let index = value.indexOf(substr); + while (index !== -1) { + result++; + index = value.indexOf(substr, index + substr.length); + } + return result; +} + +export function truncate(value: string, maxLength: number, suffix = '…'): string { + if (value.length <= maxLength) { + return value; + } + + return `${value.substr(0, maxLength)}${suffix}`; +} + +export function truncateMiddle(value: string, maxLength: number, suffix = '…'): string { + if (value.length <= maxLength) { + return value; + } + + const prefixLength = Math.ceil(maxLength / 2) - suffix.length / 2; + const suffixLength = Math.floor(maxLength / 2) - suffix.length / 2; + + return `${value.substr(0, prefixLength)}${suffix}${value.substr(value.length - suffixLength)}`; +} + +/** + * Removes all occurrences of needle from the beginning and end of haystack. + * @param haystack string to trim + * @param needle the thing to trim (default is a blank) + */ +export function trim(haystack: string, needle: string = ' '): string { + const trimmed = ltrim(haystack, needle); + return rtrim(trimmed, needle); +} + +/** + * Removes all occurrences of needle from the beginning of haystack. + * @param haystack string to trim + * @param needle the thing to trim + */ +export function ltrim(haystack: string, needle: string): string { + if (!haystack || !needle) { + return haystack; + } + + const needleLen = needle.length; + if (needleLen === 0 || haystack.length === 0) { + return haystack; + } + + let offset = 0; + + while (haystack.indexOf(needle, offset) === offset) { + offset = offset + needleLen; + } + return haystack.substring(offset); +} + +/** + * Removes all occurrences of needle from the end of haystack. + * @param haystack string to trim + * @param needle the thing to trim + */ +export function rtrim(haystack: string, needle: string): string { + if (!haystack || !needle) { + return haystack; + } + + const needleLen = needle.length, + haystackLen = haystack.length; + + if (needleLen === 0 || haystackLen === 0) { + return haystack; + } + + let offset = haystackLen, + idx = -1; + + while (true) { + idx = haystack.lastIndexOf(needle, offset - 1); + if (idx === -1 || idx + needleLen !== offset) { + break; + } + if (idx === 0) { + return ''; + } + offset = idx; + } + + return haystack.substring(0, offset); +} + +export function convertSimple2RegExpPattern(pattern: string): string { + return pattern.replace(/[\-\\\{\}\+\?\|\^\$\.\,\[\]\(\)\#\s]/g, '\\$&').replace(/[\*]/g, '.*'); +} + +export function stripWildcards(pattern: string): string { + return pattern.replace(/\*/g, ''); +} + +export interface RegExpOptions { + matchCase?: boolean; + wholeWord?: boolean; + multiline?: boolean; + global?: boolean; + unicode?: boolean; +} + +export function createRegExp(searchString: string, isRegex: boolean, options: RegExpOptions = {}): RegExp { + if (!searchString) { + throw new Error('Cannot create regex from empty string'); + } + if (!isRegex) { + searchString = escapeRegExpCharacters(searchString); + } + if (options.wholeWord) { + if (!/\B/.test(searchString.charAt(0))) { + searchString = '\\b' + searchString; + } + if (!/\B/.test(searchString.charAt(searchString.length - 1))) { + searchString = searchString + '\\b'; + } + } + let modifiers = ''; + if (options.global) { + modifiers += 'g'; + } + if (!options.matchCase) { + modifiers += 'i'; + } + if (options.multiline) { + modifiers += 'm'; + } + if (options.unicode) { + modifiers += 'u'; + } + + return new RegExp(searchString, modifiers); +} + +export function regExpLeadsToEndlessLoop(regexp: RegExp): boolean { + // Exit early if it's one of these special cases which are meant to match + // against an empty string + if (regexp.source === '^' || regexp.source === '^$' || regexp.source === '$' || regexp.source === '^\\s*$') { + return false; + } + + // We check against an empty string. If the regular expression doesn't advance + // (e.g. ends in an endless loop) it will match an empty string. + const match = regexp.exec(''); + return !!(match && regexp.lastIndex === 0); +} + +export function splitLines(str: string): string[] { + return str.split(/\r\n|\r|\n/); +} + +export function splitLinesIncludeSeparators(str: string): string[] { + const linesWithSeparators: string[] = []; + const splitLinesAndSeparators = str.split(/(\r\n|\r|\n)/); + for (let i = 0; i < Math.ceil(splitLinesAndSeparators.length / 2); i++) { + linesWithSeparators.push(splitLinesAndSeparators[2 * i] + (splitLinesAndSeparators[2 * i + 1] ?? '')); + } + return linesWithSeparators; +} + +/** + * Returns first index of the string that is not whitespace. + * If string is empty or contains only whitespaces, returns -1 + */ +export function firstNonWhitespaceIndex(str: string): number { + for (let i = 0, len = str.length; i < len; i++) { + const chCode = str.charCodeAt(i); + if (chCode !== CharCode.Space && chCode !== CharCode.Tab) { + return i; + } + } + return -1; +} + +/** + * Returns the leading whitespace of the string. + * If the string contains only whitespaces, returns entire string + */ +export function getLeadingWhitespace(str: string, start: number = 0, end: number = str.length): string { + for (let i = start; i < end; i++) { + const chCode = str.charCodeAt(i); + if (chCode !== CharCode.Space && chCode !== CharCode.Tab) { + return str.substring(start, i); + } + } + return str.substring(start, end); +} + +/** + * Returns last index of the string that is not whitespace. + * If string is empty or contains only whitespaces, returns -1 + */ +export function lastNonWhitespaceIndex(str: string, startIndex: number = str.length - 1): number { + for (let i = startIndex; i >= 0; i--) { + const chCode = str.charCodeAt(i); + if (chCode !== CharCode.Space && chCode !== CharCode.Tab) { + return i; + } + } + return -1; +} + +/** + * Function that works identically to String.prototype.replace, except, the + * replace function is allowed to be async and return a Promise. + */ +export function replaceAsync(str: string, search: RegExp, replacer: (match: string, ...args: any[]) => Promise): Promise { + const parts: (string | Promise)[] = []; + + let last = 0; + for (const match of str.matchAll(search)) { + parts.push(str.slice(last, match.index)); + if (match.index === undefined) { + throw new Error('match.index should be defined'); + } + + last = match.index + match[0].length; + parts.push(replacer(match[0], ...match.slice(1), match.index, str, match.groups)); + } + + parts.push(str.slice(last)); + + return Promise.all(parts).then(p => p.join('')); +} + +export function compare(a: string, b: string): number { + if (a < b) { + return -1; + } else if (a > b) { + return 1; + } else { + return 0; + } +} + +export function compareSubstring(a: string, b: string, aStart: number = 0, aEnd: number = a.length, bStart: number = 0, bEnd: number = b.length): number { + for (; aStart < aEnd && bStart < bEnd; aStart++, bStart++) { + const codeA = a.charCodeAt(aStart); + const codeB = b.charCodeAt(bStart); + if (codeA < codeB) { + return -1; + } else if (codeA > codeB) { + return 1; + } + } + const aLen = aEnd - aStart; + const bLen = bEnd - bStart; + if (aLen < bLen) { + return -1; + } else if (aLen > bLen) { + return 1; + } + return 0; +} + +export function compareIgnoreCase(a: string, b: string): number { + return compareSubstringIgnoreCase(a, b, 0, a.length, 0, b.length); +} + +export function compareSubstringIgnoreCase(a: string, b: string, aStart: number = 0, aEnd: number = a.length, bStart: number = 0, bEnd: number = b.length): number { + + for (; aStart < aEnd && bStart < bEnd; aStart++, bStart++) { + + let codeA = a.charCodeAt(aStart); + let codeB = b.charCodeAt(bStart); + + if (codeA === codeB) { + // equal + continue; + } + + if (codeA >= 128 || codeB >= 128) { + // not ASCII letters -> fallback to lower-casing strings + return compareSubstring(a.toLowerCase(), b.toLowerCase(), aStart, aEnd, bStart, bEnd); + } + + // mapper lower-case ascii letter onto upper-case varinats + // [97-122] (lower ascii) --> [65-90] (upper ascii) + if (isLowerAsciiLetter(codeA)) { + codeA -= 32; + } + if (isLowerAsciiLetter(codeB)) { + codeB -= 32; + } + + // compare both code points + const diff = codeA - codeB; + if (diff === 0) { + continue; + } + + return diff; + } + + const aLen = aEnd - aStart; + const bLen = bEnd - bStart; + + if (aLen < bLen) { + return -1; + } else if (aLen > bLen) { + return 1; + } + + return 0; +} + +export function isAsciiDigit(code: number): boolean { + return code >= CharCode.Digit0 && code <= CharCode.Digit9; +} + +export function isLowerAsciiLetter(code: number): boolean { + return code >= CharCode.a && code <= CharCode.z; +} + +export function isUpperAsciiLetter(code: number): boolean { + return code >= CharCode.A && code <= CharCode.Z; +} + +export function equalsIgnoreCase(a: string, b: string): boolean { + return a.length === b.length && compareSubstringIgnoreCase(a, b) === 0; +} + +export function startsWithIgnoreCase(str: string, candidate: string): boolean { + const candidateLength = candidate.length; + if (candidate.length > str.length) { + return false; + } + + return compareSubstringIgnoreCase(str, candidate, 0, candidateLength) === 0; +} + +/** + * @returns the length of the common prefix of the two strings. + */ +export function commonPrefixLength(a: string, b: string): number { + + const len = Math.min(a.length, b.length); + let i: number; + + for (i = 0; i < len; i++) { + if (a.charCodeAt(i) !== b.charCodeAt(i)) { + return i; + } + } + + return len; +} + +/** + * @returns the length of the common suffix of the two strings. + */ +export function commonSuffixLength(a: string, b: string): number { + + const len = Math.min(a.length, b.length); + let i: number; + + const aLastIndex = a.length - 1; + const bLastIndex = b.length - 1; + + for (i = 0; i < len; i++) { + if (a.charCodeAt(aLastIndex - i) !== b.charCodeAt(bLastIndex - i)) { + return i; + } + } + + return len; +} + +/** + * See http://en.wikipedia.org/wiki/Surrogate_pair + */ +export function isHighSurrogate(charCode: number): boolean { + return (0xD800 <= charCode && charCode <= 0xDBFF); +} + +/** + * See http://en.wikipedia.org/wiki/Surrogate_pair + */ +export function isLowSurrogate(charCode: number): boolean { + return (0xDC00 <= charCode && charCode <= 0xDFFF); +} + +/** + * See http://en.wikipedia.org/wiki/Surrogate_pair + */ +export function computeCodePoint(highSurrogate: number, lowSurrogate: number): number { + return ((highSurrogate - 0xD800) << 10) + (lowSurrogate - 0xDC00) + 0x10000; +} + +/** + * get the code point that begins at offset `offset` + */ +export function getNextCodePoint(str: string, len: number, offset: number): number { + const charCode = str.charCodeAt(offset); + if (isHighSurrogate(charCode) && offset + 1 < len) { + const nextCharCode = str.charCodeAt(offset + 1); + if (isLowSurrogate(nextCharCode)) { + return computeCodePoint(charCode, nextCharCode); + } + } + return charCode; +} + +/** + * get the code point that ends right before offset `offset` + */ +function getPrevCodePoint(str: string, offset: number): number { + const charCode = str.charCodeAt(offset - 1); + if (isLowSurrogate(charCode) && offset > 1) { + const prevCharCode = str.charCodeAt(offset - 2); + if (isHighSurrogate(prevCharCode)) { + return computeCodePoint(prevCharCode, charCode); + } + } + return charCode; +} + +export class CodePointIterator { + + private readonly _str: string; + private readonly _len: number; + private _offset: number; + + public get offset(): number { + return this._offset; + } + + constructor(str: string, offset: number = 0) { + this._str = str; + this._len = str.length; + this._offset = offset; + } + + public setOffset(offset: number): void { + this._offset = offset; + } + + public prevCodePoint(): number { + const codePoint = getPrevCodePoint(this._str, this._offset); + this._offset -= (codePoint >= Constants.UNICODE_SUPPLEMENTARY_PLANE_BEGIN ? 2 : 1); + return codePoint; + } + + public nextCodePoint(): number { + const codePoint = getNextCodePoint(this._str, this._len, this._offset); + this._offset += (codePoint >= Constants.UNICODE_SUPPLEMENTARY_PLANE_BEGIN ? 2 : 1); + return codePoint; + } + + public eol(): boolean { + return (this._offset >= this._len); + } +} + +export const noBreakWhitespace = '\xa0'; diff --git a/src/vs/base/common/symbols.ts b/src/vs/base/common/symbols.ts new file mode 100644 index 0000000000..9aa8e5bb04 --- /dev/null +++ b/src/vs/base/common/symbols.ts @@ -0,0 +1,9 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Can be passed into the Delayed to defer using a microtask + * */ +export const MicrotaskDelay = Symbol('MicrotaskDelay'); diff --git a/src/vs/base/common/tfIdf.ts b/src/vs/base/common/tfIdf.ts new file mode 100644 index 0000000000..4504275961 --- /dev/null +++ b/src/vs/base/common/tfIdf.ts @@ -0,0 +1,240 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CancellationToken } from 'vs/base/common/cancellation'; + +type SparseEmbedding = Record; +type TermFrequencies = Map; +type DocumentOccurrences = Map; + +function countMapFrom(values: Iterable): Map { + const map = new Map(); + for (const value of values) { + map.set(value, (map.get(value) ?? 0) + 1); + } + return map; +} + +interface DocumentChunkEntry { + readonly text: string; + readonly tf: TermFrequencies; +} + +export interface TfIdfDocument { + readonly key: string; + readonly textChunks: readonly string[]; +} + +export interface TfIdfScore { + readonly key: string; + /** + * An unbounded number. + */ + readonly score: number; +} + +export interface NormalizedTfIdfScore { + readonly key: string; + /** + * A number between 0 and 1. + */ + readonly score: number; +} + +/** + * Implementation of tf-idf (term frequency-inverse document frequency) for a set of + * documents where each document contains one or more chunks of text. + * Each document is identified by a key, and the score for each document is computed + * by taking the max score over all the chunks in the document. + */ +export class TfIdfCalculator { + calculateScores(query: string, token: CancellationToken): TfIdfScore[] { + const embedding = this.computeEmbedding(query); + const idfCache = new Map(); + const scores: TfIdfScore[] = []; + // For each document, generate one score + for (const [key, doc] of this.documents) { + if (token.isCancellationRequested) { + return []; + } + + for (const chunk of doc.chunks) { + const score = this.computeSimilarityScore(chunk, embedding, idfCache); + if (score > 0) { + scores.push({ key, score }); + } + } + } + + return scores; + } + + /** + * Count how many times each term (word) appears in a string. + */ + private static termFrequencies(input: string): TermFrequencies { + return countMapFrom(TfIdfCalculator.splitTerms(input)); + } + + /** + * Break a string into terms (words). + */ + private static *splitTerms(input: string): Iterable { + const normalize = (word: string) => word.toLowerCase(); + + // Only match on words that are at least 3 characters long and start with a letter + for (const [word] of input.matchAll(/\b\p{Letter}[\p{Letter}\d]{2,}\b/gu)) { + yield normalize(word); + + const camelParts = word.replace(/([a-z])([A-Z])/g, '$1 $2').split(/\s+/g); + if (camelParts.length > 1) { + for (const part of camelParts) { + // Require at least 3 letters in the parts of a camel case word + if (part.length > 2 && /\p{Letter}{3,}/gu.test(part)) { + yield normalize(part); + } + } + } + } + } + + /** + * Total number of chunks + */ + private chunkCount = 0; + + private readonly chunkOccurrences: DocumentOccurrences = new Map(); + + private readonly documents = new Map; + }>(); + + updateDocuments(documents: ReadonlyArray): this { + for (const { key } of documents) { + this.deleteDocument(key); + } + + for (const doc of documents) { + const chunks: Array<{ text: string; tf: TermFrequencies }> = []; + for (const text of doc.textChunks) { + // TODO: See if we can compute the tf lazily + // The challenge is that we need to also update the `chunkOccurrences` + // and all of those updates need to get flushed before the real TF-IDF of + // anything is computed. + const tf = TfIdfCalculator.termFrequencies(text); + + // Update occurrences list + for (const term of tf.keys()) { + this.chunkOccurrences.set(term, (this.chunkOccurrences.get(term) ?? 0) + 1); + } + + chunks.push({ text, tf }); + } + + this.chunkCount += chunks.length; + this.documents.set(doc.key, { chunks }); + } + return this; + } + + deleteDocument(key: string) { + const doc = this.documents.get(key); + if (!doc) { + return; + } + + this.documents.delete(key); + this.chunkCount -= doc.chunks.length; + + // Update term occurrences for the document + for (const chunk of doc.chunks) { + for (const term of chunk.tf.keys()) { + const currentOccurrences = this.chunkOccurrences.get(term); + if (typeof currentOccurrences === 'number') { + const newOccurrences = currentOccurrences - 1; + if (newOccurrences <= 0) { + this.chunkOccurrences.delete(term); + } else { + this.chunkOccurrences.set(term, newOccurrences); + } + } + } + } + } + + private computeSimilarityScore(chunk: DocumentChunkEntry, queryEmbedding: SparseEmbedding, idfCache: Map): number { + // Compute the dot product between the chunk's embedding and the query embedding + + // Note that the chunk embedding is computed lazily on a per-term basis. + // This lets us skip a large number of calculations because the majority + // of chunks do not share any terms with the query. + + let sum = 0; + for (const [term, termTfidf] of Object.entries(queryEmbedding)) { + const chunkTf = chunk.tf.get(term); + if (!chunkTf) { + // Term does not appear in chunk so it has no contribution + continue; + } + + let chunkIdf = idfCache.get(term); + if (typeof chunkIdf !== 'number') { + chunkIdf = this.computeIdf(term); + idfCache.set(term, chunkIdf); + } + + const chunkTfidf = chunkTf * chunkIdf; + sum += chunkTfidf * termTfidf; + } + return sum; + } + + private computeEmbedding(input: string): SparseEmbedding { + const tf = TfIdfCalculator.termFrequencies(input); + return this.computeTfidf(tf); + } + + private computeIdf(term: string): number { + const chunkOccurrences = this.chunkOccurrences.get(term) ?? 0; + return chunkOccurrences > 0 + ? Math.log((this.chunkCount + 1) / chunkOccurrences) + : 0; + } + + private computeTfidf(termFrequencies: TermFrequencies): SparseEmbedding { + const embedding = Object.create(null); + for (const [word, occurrences] of termFrequencies) { + const idf = this.computeIdf(word); + if (idf > 0) { + embedding[word] = occurrences * idf; + } + } + return embedding; + } +} + +/** + * Normalize the scores to be between 0 and 1 and sort them decending. + * @param scores array of scores from {@link TfIdfCalculator.calculateScores} + * @returns normalized scores + */ +export function normalizeTfIdfScores(scores: TfIdfScore[]): NormalizedTfIdfScore[] { + + // copy of scores + const result = scores.slice(0) as { score: number }[]; + + // sort descending + result.sort((a, b) => b.score - a.score); + + // normalize + const max = result[0]?.score ?? 0; + if (max > 0) { + for (const score of result) { + score.score /= max; + } + } + + return result as TfIdfScore[]; +} diff --git a/src/vs/base/common/types.ts b/src/vs/base/common/types.ts new file mode 100644 index 0000000000..1acab57b75 --- /dev/null +++ b/src/vs/base/common/types.ts @@ -0,0 +1,250 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * @returns whether the provided parameter is a JavaScript String or not. + */ +export function isString(str: unknown): str is string { + return (typeof str === 'string'); +} + +/** + * @returns whether the provided parameter is a JavaScript Array and each element in the array is a string. + */ +export function isStringArray(value: unknown): value is string[] { + return Array.isArray(value) && (value).every(elem => isString(elem)); +} + +/** + * @returns whether the provided parameter is of type `object` but **not** + * `null`, an `array`, a `regexp`, nor a `date`. + */ +export function isObject(obj: unknown): obj is Object { + // The method can't do a type cast since there are type (like strings) which + // are subclasses of any put not positvely matched by the function. Hence type + // narrowing results in wrong results. + return typeof obj === 'object' + && obj !== null + && !Array.isArray(obj) + && !(obj instanceof RegExp) + && !(obj instanceof Date); +} + +/** + * @returns whether the provided parameter is of type `Buffer` or Uint8Array dervived type + */ +export function isTypedArray(obj: unknown): obj is Object { + const TypedArray = Object.getPrototypeOf(Uint8Array); + return typeof obj === 'object' + && obj instanceof TypedArray; +} + +/** + * In **contrast** to just checking `typeof` this will return `false` for `NaN`. + * @returns whether the provided parameter is a JavaScript Number or not. + */ +export function isNumber(obj: unknown): obj is number { + return (typeof obj === 'number' && !isNaN(obj)); +} + +/** + * @returns whether the provided parameter is an Iterable, casting to the given generic + */ +export function isIterable(obj: unknown): obj is Iterable { + return !!obj && typeof (obj as any)[Symbol.iterator] === 'function'; +} + +/** + * @returns whether the provided parameter is a JavaScript Boolean or not. + */ +export function isBoolean(obj: unknown): obj is boolean { + return (obj === true || obj === false); +} + +/** + * @returns whether the provided parameter is undefined. + */ +export function isUndefined(obj: unknown): obj is undefined { + return (typeof obj === 'undefined'); +} + +/** + * @returns whether the provided parameter is defined. + */ +export function isDefined(arg: T | null | undefined): arg is T { + return !isUndefinedOrNull(arg); +} + +/** + * @returns whether the provided parameter is undefined or null. + */ +export function isUndefinedOrNull(obj: unknown): obj is undefined | null { + return (isUndefined(obj) || obj === null); +} + + +export function assertType(condition: unknown, type?: string): asserts condition { + if (!condition) { + throw new Error(type ? `Unexpected type, expected '${type}'` : 'Unexpected type'); + } +} + +/** + * Asserts that the argument passed in is neither undefined nor null. + */ +export function assertIsDefined(arg: T | null | undefined): T { + if (isUndefinedOrNull(arg)) { + throw new Error('Assertion Failed: argument is undefined or null'); + } + + return arg; +} + +/** + * Asserts that each argument passed in is neither undefined nor null. + */ +export function assertAllDefined(t1: T1 | null | undefined, t2: T2 | null | undefined): [T1, T2]; +export function assertAllDefined(t1: T1 | null | undefined, t2: T2 | null | undefined, t3: T3 | null | undefined): [T1, T2, T3]; +export function assertAllDefined(t1: T1 | null | undefined, t2: T2 | null | undefined, t3: T3 | null | undefined, t4: T4 | null | undefined): [T1, T2, T3, T4]; +export function assertAllDefined(...args: (unknown | null | undefined)[]): unknown[] { + const result = []; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + + if (isUndefinedOrNull(arg)) { + throw new Error(`Assertion Failed: argument at index ${i} is undefined or null`); + } + + result.push(arg); + } + + return result; +} + +const hasOwnProperty = Object.prototype.hasOwnProperty; + +/** + * @returns whether the provided parameter is an empty JavaScript Object or not. + */ +export function isEmptyObject(obj: unknown): obj is object { + if (!isObject(obj)) { + return false; + } + + for (const key in obj) { + if (hasOwnProperty.call(obj, key)) { + return false; + } + } + + return true; +} + +/** + * @returns whether the provided parameter is a JavaScript Function or not. + */ +export function isFunction(obj: unknown): obj is Function { + return (typeof obj === 'function'); +} + +/** + * @returns whether the provided parameters is are JavaScript Function or not. + */ +export function areFunctions(...objects: unknown[]): boolean { + return objects.length > 0 && objects.every(isFunction); +} + +export type TypeConstraint = string | Function; + +export function validateConstraints(args: unknown[], constraints: Array): void { + const len = Math.min(args.length, constraints.length); + for (let i = 0; i < len; i++) { + validateConstraint(args[i], constraints[i]); + } +} + +export function validateConstraint(arg: unknown, constraint: TypeConstraint | undefined): void { + + if (isString(constraint)) { + if (typeof arg !== constraint) { + throw new Error(`argument does not match constraint: typeof ${constraint}`); + } + } else if (isFunction(constraint)) { + try { + if (arg instanceof constraint) { + return; + } + } catch { + // ignore + } + if (!isUndefinedOrNull(arg) && (arg as any).constructor === constraint) { + return; + } + if (constraint.length === 1 && constraint.call(undefined, arg) === true) { + return; + } + throw new Error(`argument does not match one of these constraints: arg instanceof constraint, arg.constructor === constraint, nor constraint(arg) === true`); + } +} + +type AddFirstParameterToFunction = T extends (...args: any[]) => TargetFunctionsReturnType ? + // Function: add param to function + (firstArg: FirstParameter, ...args: Parameters) => ReturnType : + + // Else: just leave as is + T; + +/** + * Allows to add a first parameter to functions of a type. + */ +export type AddFirstParameterToFunctions = { + // For every property + [K in keyof Target]: AddFirstParameterToFunction; +}; + +/** + * Given an object with all optional properties, requires at least one to be defined. + * i.e. AtLeastOne; + */ +export type AtLeastOne }> = Partial & U[keyof U]; + +/** + * Only picks the non-optional properties of a type. + */ +export type OmitOptional = { [K in keyof T as T[K] extends Required[K] ? K : never]: T[K] }; + +/** + * A type that removed readonly-less from all properties of `T` + */ +export type Mutable = { + -readonly [P in keyof T]: T[P] +}; + +/** + * A single object or an array of the objects. + */ +export type SingleOrMany = T | T[]; + + +/** + * A type that recursively makes all properties of `T` required + */ +export type DeepRequiredNonNullable = { + [P in keyof T]-?: T[P] extends object ? DeepRequiredNonNullable : Required>; +}; + + +/** + * Represents a type that is a partial version of a given type `T`, where all properties are optional and can be deeply nested. + */ +export type DeepPartial = { + [P in keyof T]?: T[P] extends object ? DeepPartial : Partial; +}; + +/** + * Represents a type that is a partial version of a given type `T`, except a subset. + */ +export type PartialExcept = Partial> & Pick; diff --git a/src/vs/base/common/uint.ts b/src/vs/base/common/uint.ts new file mode 100644 index 0000000000..347af57eec --- /dev/null +++ b/src/vs/base/common/uint.ts @@ -0,0 +1,59 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export const enum Constants { + /** + * MAX SMI (SMall Integer) as defined in v8. + * one bit is lost for boxing/unboxing flag. + * one bit is lost for sign flag. + * See https://thibaultlaurens.github.io/javascript/2013/04/29/how-the-v8-engine-works/#tagged-values + */ + MAX_SAFE_SMALL_INTEGER = 1 << 30, + + /** + * MIN SMI (SMall Integer) as defined in v8. + * one bit is lost for boxing/unboxing flag. + * one bit is lost for sign flag. + * See https://thibaultlaurens.github.io/javascript/2013/04/29/how-the-v8-engine-works/#tagged-values + */ + MIN_SAFE_SMALL_INTEGER = -(1 << 30), + + /** + * Max unsigned integer that fits on 8 bits. + */ + MAX_UINT_8 = 255, // 2^8 - 1 + + /** + * Max unsigned integer that fits on 16 bits. + */ + MAX_UINT_16 = 65535, // 2^16 - 1 + + /** + * Max unsigned integer that fits on 32 bits. + */ + MAX_UINT_32 = 4294967295, // 2^32 - 1 + + UNICODE_SUPPLEMENTARY_PLANE_BEGIN = 0x010000 +} + +export function toUint8(v: number): number { + if (v < 0) { + return 0; + } + if (v > Constants.MAX_UINT_8) { + return Constants.MAX_UINT_8; + } + return v | 0; +} + +export function toUint32(v: number): number { + if (v < 0) { + return 0; + } + if (v > Constants.MAX_UINT_32) { + return Constants.MAX_UINT_32; + } + return v | 0; +} diff --git a/src/vs/base/common/uuid.ts b/src/vs/base/common/uuid.ts new file mode 100644 index 0000000000..0bd0c937ca --- /dev/null +++ b/src/vs/base/common/uuid.ts @@ -0,0 +1,81 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +const _UUIDPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +export function isUUID(value: string): boolean { + return _UUIDPattern.test(value); +} + +declare const crypto: undefined | { + //https://developer.mozilla.org/en-US/docs/Web/API/Crypto/getRandomValues#browser_compatibility + getRandomValues?(data: Uint8Array): Uint8Array; + //https://developer.mozilla.org/en-US/docs/Web/API/Crypto/randomUUID#browser_compatibility + randomUUID?(): string; +}; + +export const generateUuid = (function (): () => string { + + // use `randomUUID` if possible + if (typeof crypto === 'object' && typeof crypto.randomUUID === 'function') { + return crypto.randomUUID.bind(crypto); + } + + // use `randomValues` if possible + let getRandomValues: (bucket: Uint8Array) => Uint8Array; + if (typeof crypto === 'object' && typeof crypto.getRandomValues === 'function') { + getRandomValues = crypto.getRandomValues.bind(crypto); + + } else { + getRandomValues = function (bucket: Uint8Array): Uint8Array { + for (let i = 0; i < bucket.length; i++) { + bucket[i] = Math.floor(Math.random() * 256); + } + return bucket; + }; + } + + // prep-work + const _data = new Uint8Array(16); + const _hex: string[] = []; + for (let i = 0; i < 256; i++) { + _hex.push(i.toString(16).padStart(2, '0')); + } + + return function generateUuid(): string { + // get data + getRandomValues(_data); + + // set version bits + _data[6] = (_data[6] & 0x0f) | 0x40; + _data[8] = (_data[8] & 0x3f) | 0x80; + + // print as string + let i = 0; + let result = ''; + result += _hex[_data[i++]]; + result += _hex[_data[i++]]; + result += _hex[_data[i++]]; + result += _hex[_data[i++]]; + result += '-'; + result += _hex[_data[i++]]; + result += _hex[_data[i++]]; + result += '-'; + result += _hex[_data[i++]]; + result += _hex[_data[i++]]; + result += '-'; + result += _hex[_data[i++]]; + result += _hex[_data[i++]]; + result += '-'; + result += _hex[_data[i++]]; + result += _hex[_data[i++]]; + result += _hex[_data[i++]]; + result += _hex[_data[i++]]; + result += _hex[_data[i++]]; + result += _hex[_data[i++]]; + return result; + }; +})(); diff --git a/src/vs/base/common/verifier.ts b/src/vs/base/common/verifier.ts new file mode 100644 index 0000000000..cf77d1800c --- /dev/null +++ b/src/vs/base/common/verifier.ts @@ -0,0 +1,87 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { isObject } from 'vs/base/common/types'; + +interface IVerifier { + verify(value: unknown): T; +} + +abstract class Verifier implements IVerifier { + + constructor(protected readonly defaultValue: T) { } + + verify(value: unknown): T { + if (!this.isType(value)) { + return this.defaultValue; + } + + return value; + } + + protected abstract isType(value: unknown): value is T; +} + +export class BooleanVerifier extends Verifier { + protected isType(value: unknown): value is boolean { + return typeof value === 'boolean'; + } +} + +export class NumberVerifier extends Verifier { + protected isType(value: unknown): value is number { + return typeof value === 'number'; + } +} + +export class SetVerifier extends Verifier> { + protected isType(value: unknown): value is Set { + return value instanceof Set; + } +} + +export class EnumVerifier extends Verifier { + private readonly allowedValues: ReadonlyArray; + + constructor(defaultValue: T, allowedValues: ReadonlyArray) { + super(defaultValue); + this.allowedValues = allowedValues; + } + + protected isType(value: unknown): value is T { + return this.allowedValues.includes(value as T); + } +} + +export class ObjectVerifier extends Verifier { + + constructor(defaultValue: T, private readonly verifier: { [K in keyof T]: IVerifier }) { + super(defaultValue); + } + + override verify(value: unknown): T { + if (!this.isType(value)) { + return this.defaultValue; + } + return verifyObject(this.verifier, value); + } + + protected isType(value: unknown): value is T { + return isObject(value); + } +} + +export function verifyObject(verifiers: { [K in keyof T]: IVerifier }, value: Object): T { + const result = Object.create(null); + + for (const key in verifiers) { + if (Object.hasOwnProperty.call(verifiers, key)) { + const verifier = verifiers[key]; + result[key] = verifier.verify((value as any)[key]); + } + } + + return result; +} diff --git a/src/vs/tsconfig.json b/src/vs/tsconfig.json index 30f10c2399..c45bc40ccf 100644 --- a/src/vs/tsconfig.json +++ b/src/vs/tsconfig.json @@ -34,7 +34,6 @@ "resolveJsonModule": true, "isolatedModules": true, "types": [ - "semver", "trusted-types", ], "composite": true, @@ -54,6 +53,7 @@ "**/electron-main", "**/electron-sandbox", "**/node", + "**/parts", "**/test", "**/sandbox", "**/worker", @@ -64,7 +64,6 @@ "base/common/jsonc.js", "base/common/performance.js", "base/common/marked/marked.js", - "base/common/network.ts", - "base/browser/markdownRenderer.ts" + "base/common/network.ts" ] } From 86c875f7e976fee3df6e13e32be8130ae2f8b9ec Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Tue, 9 Jul 2024 13:08:57 -0700 Subject: [PATCH 05/40] Helper script for finding unused files --- bin/vs_base_find_used_files.js | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 bin/vs_base_find_used_files.js diff --git a/bin/vs_base_find_used_files.js b/bin/vs_base_find_used_files.js new file mode 100644 index 0000000000..50ffaf4f5e --- /dev/null +++ b/bin/vs_base_find_used_files.js @@ -0,0 +1,24 @@ +// @ts-check + +const { dirname } = require("path"); +const ts = require("typescript"); + +function findUnusedSymbols( + /** @type string */ tsconfigPath +) { + // Initialize a program using the project's tsconfig.json + const configFile = ts.readConfigFile(tsconfigPath, ts.sys.readFile); + const parsedConfig = ts.parseJsonConfigFileContent(configFile.config, ts.sys, dirname(tsconfigPath)); + + // Initialize a program with the parsed configuration + const program = ts.createProgram(parsedConfig.fileNames, { + ...parsedConfig.options, + noUnusedLocals: true + }); + const sourceFiles = program.getSourceFiles(); + const usedBaseSourceFiles = sourceFiles.filter(e => e.fileName.includes('src/vs/base/')); + console.log('Source files used in src/vs/base/:', usedBaseSourceFiles.map(e => e.fileName.replace(/^.+\/src\//, 'src/')).sort((a, b) => a.localeCompare(b))); +} + +// Example usage +findUnusedSymbols("./src/browser/tsconfig.json"); From c33ed461612a28c1af70ad811323750dbe37b6cd Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Tue, 9 Jul 2024 13:29:20 -0700 Subject: [PATCH 06/40] Delete unused files --- src/vs/base/browser/broadcast.ts | 70 - src/vs/base/browser/defaultWorkerFactory.ts | 174 -- src/vs/base/browser/deviceAccess.ts | 108 - src/vs/base/browser/dnd.ts | 110 - src/vs/base/browser/domObservable.ts | 17 - src/vs/base/browser/event.ts | 50 - src/vs/base/browser/fonts.ts | 16 - src/vs/base/browser/formattedTextRenderer.ts | 226 -- src/vs/base/browser/hash.ts | 32 - src/vs/base/browser/history.ts | 20 - src/vs/base/browser/indexedDB.ts | 177 -- src/vs/base/browser/ui/aria/aria.css | 9 - src/vs/base/browser/ui/aria/aria.ts | 163 -- .../ui/breadcrumbs/breadcrumbsWidget.css | 36 - .../ui/breadcrumbs/breadcrumbsWidget.ts | 356 ---- .../browser/ui/centered/centeredViewLayout.ts | 225 -- .../ui/codicons/codicon/codicon-modifiers.css | 33 - .../browser/ui/codicons/codicon/codicon.css | 25 - .../base/browser/ui/codicons/codiconStyles.ts | 7 - .../base/browser/ui/countBadge/countBadge.css | 24 - .../base/browser/ui/countBadge/countBadge.ts | 69 - src/vs/base/browser/ui/grid/grid.ts | 947 --------- src/vs/base/browser/ui/grid/gridview.css | 16 - src/vs/base/browser/ui/grid/gridview.ts | 1836 ----------------- .../browser/ui/mouseCursor/mouseCursor.css | 8 - .../browser/ui/mouseCursor/mouseCursor.ts | 8 - .../progressAccessibilitySignal.ts | 23 - .../browser/ui/progressbar/progressbar.css | 61 - .../browser/ui/progressbar/progressbar.ts | 224 -- src/vs/base/browser/ui/resizable/resizable.ts | 190 -- src/vs/base/browser/ui/sash/sash.css | 149 -- src/vs/base/browser/ui/sash/sash.ts | 688 ------ src/vs/base/browser/ui/splitview/paneview.css | 152 -- src/vs/base/browser/ui/splitview/paneview.ts | 681 ------ .../base/browser/ui/splitview/splitview.css | 70 - src/vs/base/browser/ui/splitview/splitview.ts | 1504 -------------- src/vs/base/common/actions.ts | 271 --- src/vs/base/common/amd.ts | 163 -- src/vs/base/common/buffer.ts | 441 ---- src/vs/base/common/cache.ts | 120 -- src/vs/base/common/codiconsUtil.ts | 28 - src/vs/base/common/color.ts | 633 ------ src/vs/base/common/comparers.ts | 355 ---- src/vs/base/common/controlFlow.ts | 69 - src/vs/base/common/date.ts | 242 --- src/vs/base/common/desktopEnvironmentInfo.ts | 101 - src/vs/base/common/errorMessage.ts | 113 - src/vs/base/common/extpath.ts | 423 ---- src/vs/base/common/glob.ts | 737 ------- src/vs/base/common/hierarchicalKind.ts | 31 - src/vs/base/common/history.ts | 277 --- src/vs/base/common/hotReload.ts | 112 - src/vs/base/common/hotReloadHelpers.ts | 30 - src/vs/base/common/idGenerator.ts | 21 - src/vs/base/common/ime.ts | 36 - src/vs/base/common/json.ts | 1326 ------------ src/vs/base/common/jsonEdit.ts | 176 -- src/vs/base/common/jsonErrorMessages.ts | 26 - src/vs/base/common/jsonFormatter.ts | 261 --- src/vs/base/common/jsonSchema.ts | 267 --- src/vs/base/common/jsonc.d.ts | 24 - src/vs/base/common/jsonc.js | 89 - src/vs/base/common/keybindingLabels.ts | 184 -- src/vs/base/common/keybindingParser.ts | 102 - src/vs/base/common/linkedText.ts | 55 - src/vs/base/common/mime.ts | 126 -- src/vs/base/common/navigator.ts | 50 - src/vs/base/common/network.ts | 128 -- src/vs/base/common/objects.ts | 274 --- src/vs/base/common/paging.ts | 189 -- src/vs/base/common/parsers.ts | 79 - src/vs/base/common/path.ts | 1529 -------------- src/vs/base/common/performance.d.ts | 16 - src/vs/base/common/performance.js | 135 -- src/vs/base/common/ports.ts | 13 - src/vs/base/common/prefixTree.ts | 252 --- src/vs/base/common/process.ts | 76 - src/vs/base/common/processes.ts | 148 -- src/vs/base/common/product.ts | 314 --- src/vs/base/common/range.ts | 60 - src/vs/base/common/search.ts | 48 - src/vs/base/common/severity.ts | 56 - src/vs/base/common/skipList.ts | 204 -- src/vs/base/common/stream.ts | 772 ------- src/vs/base/common/tfIdf.ts | 240 --- src/vs/base/common/types.ts | 250 --- src/vs/base/common/uuid.ts | 81 - src/vs/base/common/verifier.ts | 87 - 88 files changed, 20344 deletions(-) delete mode 100644 src/vs/base/browser/broadcast.ts delete mode 100644 src/vs/base/browser/defaultWorkerFactory.ts delete mode 100644 src/vs/base/browser/deviceAccess.ts delete mode 100644 src/vs/base/browser/dnd.ts delete mode 100644 src/vs/base/browser/domObservable.ts delete mode 100644 src/vs/base/browser/event.ts delete mode 100644 src/vs/base/browser/fonts.ts delete mode 100644 src/vs/base/browser/formattedTextRenderer.ts delete mode 100644 src/vs/base/browser/hash.ts delete mode 100644 src/vs/base/browser/history.ts delete mode 100644 src/vs/base/browser/indexedDB.ts delete mode 100644 src/vs/base/browser/ui/aria/aria.css delete mode 100644 src/vs/base/browser/ui/aria/aria.ts delete mode 100644 src/vs/base/browser/ui/breadcrumbs/breadcrumbsWidget.css delete mode 100644 src/vs/base/browser/ui/breadcrumbs/breadcrumbsWidget.ts delete mode 100644 src/vs/base/browser/ui/centered/centeredViewLayout.ts delete mode 100644 src/vs/base/browser/ui/codicons/codicon/codicon-modifiers.css delete mode 100644 src/vs/base/browser/ui/codicons/codicon/codicon.css delete mode 100644 src/vs/base/browser/ui/codicons/codiconStyles.ts delete mode 100644 src/vs/base/browser/ui/countBadge/countBadge.css delete mode 100644 src/vs/base/browser/ui/countBadge/countBadge.ts delete mode 100644 src/vs/base/browser/ui/grid/grid.ts delete mode 100644 src/vs/base/browser/ui/grid/gridview.css delete mode 100644 src/vs/base/browser/ui/grid/gridview.ts delete mode 100644 src/vs/base/browser/ui/mouseCursor/mouseCursor.css delete mode 100644 src/vs/base/browser/ui/mouseCursor/mouseCursor.ts delete mode 100644 src/vs/base/browser/ui/progressbar/progressAccessibilitySignal.ts delete mode 100644 src/vs/base/browser/ui/progressbar/progressbar.css delete mode 100644 src/vs/base/browser/ui/progressbar/progressbar.ts delete mode 100644 src/vs/base/browser/ui/resizable/resizable.ts delete mode 100644 src/vs/base/browser/ui/sash/sash.css delete mode 100644 src/vs/base/browser/ui/sash/sash.ts delete mode 100644 src/vs/base/browser/ui/splitview/paneview.css delete mode 100644 src/vs/base/browser/ui/splitview/paneview.ts delete mode 100644 src/vs/base/browser/ui/splitview/splitview.css delete mode 100644 src/vs/base/browser/ui/splitview/splitview.ts delete mode 100644 src/vs/base/common/actions.ts delete mode 100644 src/vs/base/common/amd.ts delete mode 100644 src/vs/base/common/buffer.ts delete mode 100644 src/vs/base/common/cache.ts delete mode 100644 src/vs/base/common/codiconsUtil.ts delete mode 100644 src/vs/base/common/color.ts delete mode 100644 src/vs/base/common/comparers.ts delete mode 100644 src/vs/base/common/controlFlow.ts delete mode 100644 src/vs/base/common/date.ts delete mode 100644 src/vs/base/common/desktopEnvironmentInfo.ts delete mode 100644 src/vs/base/common/errorMessage.ts delete mode 100644 src/vs/base/common/extpath.ts delete mode 100644 src/vs/base/common/glob.ts delete mode 100644 src/vs/base/common/hierarchicalKind.ts delete mode 100644 src/vs/base/common/history.ts delete mode 100644 src/vs/base/common/hotReload.ts delete mode 100644 src/vs/base/common/hotReloadHelpers.ts delete mode 100644 src/vs/base/common/idGenerator.ts delete mode 100644 src/vs/base/common/ime.ts delete mode 100644 src/vs/base/common/json.ts delete mode 100644 src/vs/base/common/jsonEdit.ts delete mode 100644 src/vs/base/common/jsonErrorMessages.ts delete mode 100644 src/vs/base/common/jsonFormatter.ts delete mode 100644 src/vs/base/common/jsonSchema.ts delete mode 100644 src/vs/base/common/jsonc.d.ts delete mode 100644 src/vs/base/common/jsonc.js delete mode 100644 src/vs/base/common/keybindingLabels.ts delete mode 100644 src/vs/base/common/keybindingParser.ts delete mode 100644 src/vs/base/common/linkedText.ts delete mode 100644 src/vs/base/common/mime.ts delete mode 100644 src/vs/base/common/navigator.ts delete mode 100644 src/vs/base/common/network.ts delete mode 100644 src/vs/base/common/objects.ts delete mode 100644 src/vs/base/common/paging.ts delete mode 100644 src/vs/base/common/parsers.ts delete mode 100644 src/vs/base/common/path.ts delete mode 100644 src/vs/base/common/performance.d.ts delete mode 100644 src/vs/base/common/performance.js delete mode 100644 src/vs/base/common/ports.ts delete mode 100644 src/vs/base/common/prefixTree.ts delete mode 100644 src/vs/base/common/process.ts delete mode 100644 src/vs/base/common/processes.ts delete mode 100644 src/vs/base/common/product.ts delete mode 100644 src/vs/base/common/range.ts delete mode 100644 src/vs/base/common/search.ts delete mode 100644 src/vs/base/common/severity.ts delete mode 100644 src/vs/base/common/skipList.ts delete mode 100644 src/vs/base/common/stream.ts delete mode 100644 src/vs/base/common/tfIdf.ts delete mode 100644 src/vs/base/common/types.ts delete mode 100644 src/vs/base/common/uuid.ts delete mode 100644 src/vs/base/common/verifier.ts diff --git a/src/vs/base/browser/broadcast.ts b/src/vs/base/browser/broadcast.ts deleted file mode 100644 index 53c921fdb2..0000000000 --- a/src/vs/base/browser/broadcast.ts +++ /dev/null @@ -1,70 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { mainWindow } from 'vs/base/browser/window'; -import { getErrorMessage } from 'vs/base/common/errors'; -import { Emitter } from 'vs/base/common/event'; -import { Disposable, toDisposable } from 'vs/base/common/lifecycle'; - -export class BroadcastDataChannel extends Disposable { - - private broadcastChannel: BroadcastChannel | undefined; - - private readonly _onDidReceiveData = this._register(new Emitter()); - readonly onDidReceiveData = this._onDidReceiveData.event; - - constructor(private readonly channelName: string) { - super(); - - // Use BroadcastChannel - if ('BroadcastChannel' in mainWindow) { - try { - this.broadcastChannel = new BroadcastChannel(channelName); - const listener = (event: MessageEvent) => { - this._onDidReceiveData.fire(event.data); - }; - this.broadcastChannel.addEventListener('message', listener); - this._register(toDisposable(() => { - if (this.broadcastChannel) { - this.broadcastChannel.removeEventListener('message', listener); - this.broadcastChannel.close(); - } - })); - } catch (error) { - console.warn('Error while creating broadcast channel. Falling back to localStorage.', getErrorMessage(error)); - } - } - - // BroadcastChannel is not supported. Use storage. - if (!this.broadcastChannel) { - this.channelName = `BroadcastDataChannel.${channelName}`; - this.createBroadcastChannel(); - } - } - - private createBroadcastChannel(): void { - const listener = (event: StorageEvent) => { - if (event.key === this.channelName && event.newValue) { - this._onDidReceiveData.fire(JSON.parse(event.newValue)); - } - }; - mainWindow.addEventListener('storage', listener); - this._register(toDisposable(() => mainWindow.removeEventListener('storage', listener))); - } - - /** - * Sends the data to other BroadcastChannel objects set up for this channel. Data can be structured objects, e.g. nested objects and arrays. - * @param data data to broadcast - */ - postData(data: T): void { - if (this.broadcastChannel) { - this.broadcastChannel.postMessage(data); - } else { - // remove previous changes so that event is triggered even if new changes are same as old changes - localStorage.removeItem(this.channelName); - localStorage.setItem(this.channelName, JSON.stringify(data)); - } - } -} diff --git a/src/vs/base/browser/defaultWorkerFactory.ts b/src/vs/base/browser/defaultWorkerFactory.ts deleted file mode 100644 index 834fa4d3c1..0000000000 --- a/src/vs/base/browser/defaultWorkerFactory.ts +++ /dev/null @@ -1,174 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { createTrustedTypesPolicy } from 'vs/base/browser/trustedTypes'; -import { onUnexpectedError } from 'vs/base/common/errors'; -import { COI } from 'vs/base/common/network'; -import { IWorker, IWorkerCallback, IWorkerFactory, logOnceWebWorkerWarning } from 'vs/base/common/worker/simpleWorker'; -import { Disposable, toDisposable } from 'vs/base/common/lifecycle'; - -const ttPolicy = createTrustedTypesPolicy('defaultWorkerFactory', { createScriptURL: value => value }); - -export function createBlobWorker(blobUrl: string, options?: WorkerOptions): Worker { - if (!blobUrl.startsWith('blob:')) { - throw new URIError('Not a blob-url: ' + blobUrl); - } - return new Worker(ttPolicy ? ttPolicy.createScriptURL(blobUrl) as unknown as string : blobUrl, options); -} - -function getWorker(label: string): Worker | Promise { - // Option for hosts to overwrite the worker script (used in the standalone editor) - interface IMonacoEnvironment { - getWorker?(moduleId: string, label: string): Worker | Promise; - getWorkerUrl?(moduleId: string, label: string): string; - } - const monacoEnvironment: IMonacoEnvironment | undefined = (globalThis as any).MonacoEnvironment; - if (monacoEnvironment) { - if (typeof monacoEnvironment.getWorker === 'function') { - return monacoEnvironment.getWorker('workerMain.js', label); - } - if (typeof monacoEnvironment.getWorkerUrl === 'function') { - const workerUrl = monacoEnvironment.getWorkerUrl('workerMain.js', label); - return new Worker(ttPolicy ? ttPolicy.createScriptURL(workerUrl) as unknown as string : workerUrl, { name: label }); - } - } - // ESM-comment-begin - if (typeof require === 'function') { - // check if the JS lives on a different origin - const workerMain = require.toUrl('vs/base/worker/workerMain.js'); // explicitly using require.toUrl(), see https://github.com/microsoft/vscode/issues/107440#issuecomment-698982321 - const workerUrl = getWorkerBootstrapUrl(workerMain, label); - return new Worker(ttPolicy ? ttPolicy.createScriptURL(workerUrl) as unknown as string : workerUrl, { name: label }); - } - // ESM-comment-end - throw new Error(`You must define a function MonacoEnvironment.getWorkerUrl or MonacoEnvironment.getWorker`); -} - -// ESM-comment-begin -export function getWorkerBootstrapUrl(scriptPath: string, label: string): string { - if (/^((http:)|(https:)|(file:))/.test(scriptPath) && scriptPath.substring(0, globalThis.origin.length) !== globalThis.origin) { - // this is the cross-origin case - // i.e. the webpage is running at a different origin than where the scripts are loaded from - } else { - const start = scriptPath.lastIndexOf('?'); - const end = scriptPath.lastIndexOf('#', start); - const params = start > 0 - ? new URLSearchParams(scriptPath.substring(start + 1, ~end ? end : undefined)) - : new URLSearchParams(); - - COI.addSearchParam(params, true, true); - const search = params.toString(); - if (!search) { - scriptPath = `${scriptPath}#${label}`; - } else { - scriptPath = `${scriptPath}?${params.toString()}#${label}`; - } - } - - const factoryModuleId = 'vs/base/worker/defaultWorkerFactory.js'; - const workerBaseUrl = require.toUrl(factoryModuleId).slice(0, -factoryModuleId.length); // explicitly using require.toUrl(), see https://github.com/microsoft/vscode/issues/107440#issuecomment-698982321 - const blob = new Blob([[ - `/*${label}*/`, - `globalThis.MonacoEnvironment = { baseUrl: '${workerBaseUrl}' };`, - // VSCODE_GLOBALS: NLS - `globalThis._VSCODE_NLS_MESSAGES = ${JSON.stringify(globalThis._VSCODE_NLS_MESSAGES)};`, - `globalThis._VSCODE_NLS_LANGUAGE = ${JSON.stringify(globalThis._VSCODE_NLS_LANGUAGE)};`, - `const ttPolicy = globalThis.trustedTypes?.createPolicy('defaultWorkerFactory', { createScriptURL: value => value });`, - `importScripts(ttPolicy?.createScriptURL('${scriptPath}') ?? '${scriptPath}');`, - `/*${label}*/` - ].join('')], { type: 'application/javascript' }); - return URL.createObjectURL(blob); -} -// ESM-comment-end - -function isPromiseLike(obj: any): obj is PromiseLike { - if (typeof obj.then === 'function') { - return true; - } - return false; -} - -/** - * A worker that uses HTML5 web workers so that is has - * its own global scope and its own thread. - */ -class WebWorker extends Disposable implements IWorker { - - private readonly id: number; - private readonly label: string; - private worker: Promise | null; - - constructor(moduleId: string, id: number, label: string, onMessageCallback: IWorkerCallback, onErrorCallback: (err: any) => void) { - super(); - this.id = id; - this.label = label; - const workerOrPromise = getWorker(label); - if (isPromiseLike(workerOrPromise)) { - this.worker = workerOrPromise; - } else { - this.worker = Promise.resolve(workerOrPromise); - } - this.postMessage(moduleId, []); - this.worker.then((w) => { - w.onmessage = function (ev) { - onMessageCallback(ev.data); - }; - w.onmessageerror = onErrorCallback; - if (typeof w.addEventListener === 'function') { - w.addEventListener('error', onErrorCallback); - } - }); - this._register(toDisposable(() => { - this.worker?.then(w => { - w.onmessage = null; - w.onmessageerror = null; - w.removeEventListener('error', onErrorCallback); - w.terminate(); - }); - this.worker = null; - })); - } - - public getId(): number { - return this.id; - } - - public postMessage(message: any, transfer: Transferable[]): void { - this.worker?.then(w => { - try { - w.postMessage(message, transfer); - } catch (err) { - onUnexpectedError(err); - onUnexpectedError(new Error(`FAILED to post message to '${this.label}'-worker`, { cause: err })); - } - }); - } -} - -export class DefaultWorkerFactory implements IWorkerFactory { - - private static LAST_WORKER_ID = 0; - - private _label: string | undefined; - private _webWorkerFailedBeforeError: any; - - constructor(label: string | undefined) { - this._label = label; - this._webWorkerFailedBeforeError = false; - } - - public create(moduleId: string, onMessageCallback: IWorkerCallback, onErrorCallback: (err: any) => void): IWorker { - const workerId = (++DefaultWorkerFactory.LAST_WORKER_ID); - - if (this._webWorkerFailedBeforeError) { - throw this._webWorkerFailedBeforeError; - } - - return new WebWorker(moduleId, workerId, this._label || 'anonymous' + workerId, onMessageCallback, (err) => { - logOnceWebWorkerWarning(err); - this._webWorkerFailedBeforeError = err; - onErrorCallback(err); - }); - } -} diff --git a/src/vs/base/browser/deviceAccess.ts b/src/vs/base/browser/deviceAccess.ts deleted file mode 100644 index 17cb1beb02..0000000000 --- a/src/vs/base/browser/deviceAccess.ts +++ /dev/null @@ -1,108 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -// https://wicg.github.io/webusb/ - -export interface UsbDeviceData { - readonly deviceClass: number; - readonly deviceProtocol: number; - readonly deviceSubclass: number; - readonly deviceVersionMajor: number; - readonly deviceVersionMinor: number; - readonly deviceVersionSubminor: number; - readonly manufacturerName?: string; - readonly productId: number; - readonly productName?: string; - readonly serialNumber?: string; - readonly usbVersionMajor: number; - readonly usbVersionMinor: number; - readonly usbVersionSubminor: number; - readonly vendorId: number; -} - -export async function requestUsbDevice(options?: { filters?: unknown[] }): Promise { - const usb = (navigator as any).usb; - if (!usb) { - return undefined; - } - - const device = await usb.requestDevice({ filters: options?.filters ?? [] }); - if (!device) { - return undefined; - } - - return { - deviceClass: device.deviceClass, - deviceProtocol: device.deviceProtocol, - deviceSubclass: device.deviceSubclass, - deviceVersionMajor: device.deviceVersionMajor, - deviceVersionMinor: device.deviceVersionMinor, - deviceVersionSubminor: device.deviceVersionSubminor, - manufacturerName: device.manufacturerName, - productId: device.productId, - productName: device.productName, - serialNumber: device.serialNumber, - usbVersionMajor: device.usbVersionMajor, - usbVersionMinor: device.usbVersionMinor, - usbVersionSubminor: device.usbVersionSubminor, - vendorId: device.vendorId, - }; -} - -// https://wicg.github.io/serial/ - -export interface SerialPortData { - readonly usbVendorId?: number | undefined; - readonly usbProductId?: number | undefined; -} - -export async function requestSerialPort(options?: { filters?: unknown[] }): Promise { - const serial = (navigator as any).serial; - if (!serial) { - return undefined; - } - - const port = await serial.requestPort({ filters: options?.filters ?? [] }); - if (!port) { - return undefined; - } - - const info = port.getInfo(); - return { - usbVendorId: info.usbVendorId, - usbProductId: info.usbProductId - }; -} - -// https://wicg.github.io/webhid/ - -export interface HidDeviceData { - readonly opened: boolean; - readonly vendorId: number; - readonly productId: number; - readonly productName: string; - readonly collections: []; -} - -export async function requestHidDevice(options?: { filters?: unknown[] }): Promise { - const hid = (navigator as any).hid; - if (!hid) { - return undefined; - } - - const devices = await hid.requestDevice({ filters: options?.filters ?? [] }); - if (!devices.length) { - return undefined; - } - - const device = devices[0]; - return { - opened: device.opened, - vendorId: device.vendorId, - productId: device.productId, - productName: device.productName, - collections: device.collections - }; -} diff --git a/src/vs/base/browser/dnd.ts b/src/vs/base/browser/dnd.ts deleted file mode 100644 index 96259a4e63..0000000000 --- a/src/vs/base/browser/dnd.ts +++ /dev/null @@ -1,110 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { addDisposableListener, getWindow } from 'vs/base/browser/dom'; -import { Disposable } from 'vs/base/common/lifecycle'; -import { Mimes } from 'vs/base/common/mime'; - -/** - * A helper that will execute a provided function when the provided HTMLElement receives - * dragover event for 800ms. If the drag is aborted before, the callback will not be triggered. - */ -export class DelayedDragHandler extends Disposable { - private timeout: any; - - constructor(container: HTMLElement, callback: () => void) { - super(); - - this._register(addDisposableListener(container, 'dragover', e => { - e.preventDefault(); // needed so that the drop event fires (https://stackoverflow.com/questions/21339924/drop-event-not-firing-in-chrome) - - if (!this.timeout) { - this.timeout = setTimeout(() => { - callback(); - - this.timeout = null; - }, 800); - } - })); - - ['dragleave', 'drop', 'dragend'].forEach(type => { - this._register(addDisposableListener(container, type, () => { - this.clearDragTimeout(); - })); - }); - } - - private clearDragTimeout(): void { - if (this.timeout) { - clearTimeout(this.timeout); - this.timeout = null; - } - } - - override dispose(): void { - super.dispose(); - - this.clearDragTimeout(); - } -} - -// Common data transfers -export const DataTransfers = { - - /** - * Application specific resource transfer type - */ - RESOURCES: 'ResourceURLs', - - /** - * Browser specific transfer type to download - */ - DOWNLOAD_URL: 'DownloadURL', - - /** - * Browser specific transfer type for files - */ - FILES: 'Files', - - /** - * Typically transfer type for copy/paste transfers. - */ - TEXT: Mimes.text, - - /** - * Internal type used to pass around text/uri-list data. - * - * This is needed to work around https://bugs.chromium.org/p/chromium/issues/detail?id=239745. - */ - INTERNAL_URI_LIST: 'application/vnd.code.uri-list', -}; - -export function applyDragImage(event: DragEvent, label: string | null, clazz: string, backgroundColor?: string | null, foregroundColor?: string | null): void { - const dragImage = document.createElement('div'); - dragImage.className = clazz; - dragImage.textContent = label; - - if (foregroundColor) { - dragImage.style.color = foregroundColor; - } - - if (backgroundColor) { - dragImage.style.background = backgroundColor; - } - - if (event.dataTransfer) { - const ownerDocument = getWindow(event).document; - ownerDocument.body.appendChild(dragImage); - event.dataTransfer.setDragImage(dragImage, -10, -10); - - // Removes the element when the DND operation is done - setTimeout(() => dragImage.remove(), 0); - } -} - -export interface IDragAndDropData { - update(dataTransfer: DataTransfer): void; - getData(): unknown; -} diff --git a/src/vs/base/browser/domObservable.ts b/src/vs/base/browser/domObservable.ts deleted file mode 100644 index dd20637727..0000000000 --- a/src/vs/base/browser/domObservable.ts +++ /dev/null @@ -1,17 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { createStyleSheet2 } from 'vs/base/browser/dom'; -import { DisposableStore, IDisposable } from 'vs/base/common/lifecycle'; -import { autorun, IObservable } from 'vs/base/common/observable'; - -export function createStyleSheetFromObservable(css: IObservable): IDisposable { - const store = new DisposableStore(); - const w = store.add(createStyleSheet2()); - store.add(autorun(reader => { - w.setStyle(css.read(reader)); - })); - return store; -} diff --git a/src/vs/base/browser/event.ts b/src/vs/base/browser/event.ts deleted file mode 100644 index 760f8646b0..0000000000 --- a/src/vs/base/browser/event.ts +++ /dev/null @@ -1,50 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { GestureEvent } from 'vs/base/browser/touch'; -import { Emitter, Event as BaseEvent } from 'vs/base/common/event'; -import { IDisposable } from 'vs/base/common/lifecycle'; - -export type EventHandler = HTMLElement | HTMLDocument | Window; - -export interface IDomEvent { - (element: EventHandler, type: K, useCapture?: boolean): BaseEvent; - (element: EventHandler, type: string, useCapture?: boolean): BaseEvent; -} - -export interface DOMEventMap extends HTMLElementEventMap, DocumentEventMap, WindowEventMap { - '-monaco-gesturetap': GestureEvent; - '-monaco-gesturechange': GestureEvent; - '-monaco-gesturestart': GestureEvent; - '-monaco-gesturesend': GestureEvent; - '-monaco-gesturecontextmenu': GestureEvent; - 'compositionstart': CompositionEvent; - 'compositionupdate': CompositionEvent; - 'compositionend': CompositionEvent; -} - -export class DomEmitter implements IDisposable { - - private emitter: Emitter; - - get event(): BaseEvent { - return this.emitter.event; - } - - constructor(element: Window & typeof globalThis, type: WindowEventMap, useCapture?: boolean); - constructor(element: Document, type: DocumentEventMap, useCapture?: boolean); - constructor(element: EventHandler, type: K, useCapture?: boolean); - constructor(element: EventHandler, type: K, useCapture?: boolean) { - const fn = (e: Event) => this.emitter.fire(e as DOMEventMap[K]); - this.emitter = new Emitter({ - onWillAddFirstListener: () => element.addEventListener(type, fn, useCapture), - onDidRemoveLastListener: () => element.removeEventListener(type, fn, useCapture) - }); - } - - dispose(): void { - this.emitter.dispose(); - } -} diff --git a/src/vs/base/browser/fonts.ts b/src/vs/base/browser/fonts.ts deleted file mode 100644 index a5e78d00be..0000000000 --- a/src/vs/base/browser/fonts.ts +++ /dev/null @@ -1,16 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { isMacintosh, isWindows } from 'vs/base/common/platform'; - -/** - * The best font-family to be used in CSS based on the platform: - * - Windows: Segoe preferred, fallback to sans-serif - * - macOS: standard system font, fallback to sans-serif - * - Linux: standard system font preferred, fallback to Ubuntu fonts - * - * Note: this currently does not adjust for different locales. - */ -export const DEFAULT_FONT_FAMILY = isWindows ? '"Segoe WPC", "Segoe UI", sans-serif' : isMacintosh ? '-apple-system, BlinkMacSystemFont, sans-serif' : 'system-ui, "Ubuntu", "Droid Sans", sans-serif'; diff --git a/src/vs/base/browser/formattedTextRenderer.ts b/src/vs/base/browser/formattedTextRenderer.ts deleted file mode 100644 index 12371671c7..0000000000 --- a/src/vs/base/browser/formattedTextRenderer.ts +++ /dev/null @@ -1,226 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import * as DOM from 'vs/base/browser/dom'; -import { IKeyboardEvent } from 'vs/base/browser/keyboardEvent'; -import { IMouseEvent } from 'vs/base/browser/mouseEvent'; -import { DisposableStore } from 'vs/base/common/lifecycle'; - -export interface IContentActionHandler { - callback: (content: string, event: IMouseEvent | IKeyboardEvent) => void; - readonly disposables: DisposableStore; -} - -export interface FormattedTextRenderOptions { - readonly className?: string; - readonly inline?: boolean; - readonly actionHandler?: IContentActionHandler; - readonly renderCodeSegments?: boolean; -} - -export function renderText(text: string, options: FormattedTextRenderOptions = {}): HTMLElement { - const element = createElement(options); - element.textContent = text; - return element; -} - -export function renderFormattedText(formattedText: string, options: FormattedTextRenderOptions = {}): HTMLElement { - const element = createElement(options); - _renderFormattedText(element, parseFormattedText(formattedText, !!options.renderCodeSegments), options.actionHandler, options.renderCodeSegments); - return element; -} - -export function createElement(options: FormattedTextRenderOptions): HTMLElement { - const tagName = options.inline ? 'span' : 'div'; - const element = document.createElement(tagName); - if (options.className) { - element.className = options.className; - } - return element; -} - -class StringStream { - private source: string; - private index: number; - - constructor(source: string) { - this.source = source; - this.index = 0; - } - - public eos(): boolean { - return this.index >= this.source.length; - } - - public next(): string { - const next = this.peek(); - this.advance(); - return next; - } - - public peek(): string { - return this.source[this.index]; - } - - public advance(): void { - this.index++; - } -} - -const enum FormatType { - Invalid, - Root, - Text, - Bold, - Italics, - Action, - ActionClose, - Code, - NewLine -} - -interface IFormatParseTree { - type: FormatType; - content?: string; - index?: number; - children?: IFormatParseTree[]; -} - -function _renderFormattedText(element: Node, treeNode: IFormatParseTree, actionHandler?: IContentActionHandler, renderCodeSegments?: boolean) { - let child: Node | undefined; - - if (treeNode.type === FormatType.Text) { - child = document.createTextNode(treeNode.content || ''); - } else if (treeNode.type === FormatType.Bold) { - child = document.createElement('b'); - } else if (treeNode.type === FormatType.Italics) { - child = document.createElement('i'); - } else if (treeNode.type === FormatType.Code && renderCodeSegments) { - child = document.createElement('code'); - } else if (treeNode.type === FormatType.Action && actionHandler) { - const a = document.createElement('a'); - actionHandler.disposables.add(DOM.addStandardDisposableListener(a, 'click', (event) => { - actionHandler.callback(String(treeNode.index), event); - })); - - child = a; - } else if (treeNode.type === FormatType.NewLine) { - child = document.createElement('br'); - } else if (treeNode.type === FormatType.Root) { - child = element; - } - - if (child && element !== child) { - element.appendChild(child); - } - - if (child && Array.isArray(treeNode.children)) { - treeNode.children.forEach((nodeChild) => { - _renderFormattedText(child, nodeChild, actionHandler, renderCodeSegments); - }); - } -} - -function parseFormattedText(content: string, parseCodeSegments: boolean): IFormatParseTree { - - const root: IFormatParseTree = { - type: FormatType.Root, - children: [] - }; - - let actionViewItemIndex = 0; - let current = root; - const stack: IFormatParseTree[] = []; - const stream = new StringStream(content); - - while (!stream.eos()) { - let next = stream.next(); - - const isEscapedFormatType = (next === '\\' && formatTagType(stream.peek(), parseCodeSegments) !== FormatType.Invalid); - if (isEscapedFormatType) { - next = stream.next(); // unread the backslash if it escapes a format tag type - } - - if (!isEscapedFormatType && isFormatTag(next, parseCodeSegments) && next === stream.peek()) { - stream.advance(); - - if (current.type === FormatType.Text) { - current = stack.pop()!; - } - - const type = formatTagType(next, parseCodeSegments); - if (current.type === type || (current.type === FormatType.Action && type === FormatType.ActionClose)) { - current = stack.pop()!; - } else { - const newCurrent: IFormatParseTree = { - type: type, - children: [] - }; - - if (type === FormatType.Action) { - newCurrent.index = actionViewItemIndex; - actionViewItemIndex++; - } - - current.children!.push(newCurrent); - stack.push(current); - current = newCurrent; - } - } else if (next === '\n') { - if (current.type === FormatType.Text) { - current = stack.pop()!; - } - - current.children!.push({ - type: FormatType.NewLine - }); - - } else { - if (current.type !== FormatType.Text) { - const textCurrent: IFormatParseTree = { - type: FormatType.Text, - content: next - }; - current.children!.push(textCurrent); - stack.push(current); - current = textCurrent; - - } else { - current.content += next; - } - } - } - - if (current.type === FormatType.Text) { - current = stack.pop()!; - } - - if (stack.length) { - // incorrectly formatted string literal - } - - return root; -} - -function isFormatTag(char: string, supportCodeSegments: boolean): boolean { - return formatTagType(char, supportCodeSegments) !== FormatType.Invalid; -} - -function formatTagType(char: string, supportCodeSegments: boolean): FormatType { - switch (char) { - case '*': - return FormatType.Bold; - case '_': - return FormatType.Italics; - case '[': - return FormatType.Action; - case ']': - return FormatType.ActionClose; - case '`': - return supportCodeSegments ? FormatType.Code : FormatType.Invalid; - default: - return FormatType.Invalid; - } -} diff --git a/src/vs/base/browser/hash.ts b/src/vs/base/browser/hash.ts deleted file mode 100644 index a9a5b66900..0000000000 --- a/src/vs/base/browser/hash.ts +++ /dev/null @@ -1,32 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { VSBuffer } from 'vs/base/common/buffer'; -import { StringSHA1, toHexString } from 'vs/base/common/hash'; - -export async function sha1Hex(str: string): Promise { - - // Prefer to use browser's crypto module - if (globalThis?.crypto?.subtle) { - - // Careful to use `dontUseNodeBuffer` when passing the - // buffer to the browser `crypto` API. Users reported - // native crashes in certain cases that we could trace - // back to passing node.js `Buffer` around - // (https://github.com/microsoft/vscode/issues/114227) - const buffer = VSBuffer.fromString(str, { dontUseNodeBuffer: true }).buffer; - const hash = await globalThis.crypto.subtle.digest({ name: 'sha-1' }, buffer); - - return toHexString(hash); - } - - // Otherwise fallback to `StringSHA1` - else { - const computer = new StringSHA1(); - computer.update(str); - - return computer.digest(); - } -} diff --git a/src/vs/base/browser/history.ts b/src/vs/base/browser/history.ts deleted file mode 100644 index e31b68ed56..0000000000 --- a/src/vs/base/browser/history.ts +++ /dev/null @@ -1,20 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { Event } from 'vs/base/common/event'; - -export interface IHistoryNavigationWidget { - - readonly element: HTMLElement; - - showPreviousValue(): void; - - showNextValue(): void; - - onDidFocus: Event; - - onDidBlur: Event; - -} diff --git a/src/vs/base/browser/indexedDB.ts b/src/vs/base/browser/indexedDB.ts deleted file mode 100644 index 6d56022c98..0000000000 --- a/src/vs/base/browser/indexedDB.ts +++ /dev/null @@ -1,177 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { toErrorMessage } from 'vs/base/common/errorMessage'; -import { ErrorNoTelemetry, getErrorMessage } from 'vs/base/common/errors'; -import { mark } from 'vs/base/common/performance'; - -class MissingStoresError extends Error { - constructor(readonly db: IDBDatabase) { - super('Missing stores'); - } -} - -export class DBClosedError extends Error { - readonly code = 'DBClosed'; - constructor(dbName: string) { - super(`IndexedDB database '${dbName}' is closed.`); - } -} - -export class IndexedDB { - - static async create(name: string, version: number | undefined, stores: string[]): Promise { - const database = await IndexedDB.openDatabase(name, version, stores); - return new IndexedDB(database, name); - } - - private static async openDatabase(name: string, version: number | undefined, stores: string[]): Promise { - mark(`code/willOpenDatabase/${name}`); - try { - return await IndexedDB.doOpenDatabase(name, version, stores); - } catch (err) { - if (err instanceof MissingStoresError) { - console.info(`Attempting to recreate the IndexedDB once.`, name); - - try { - // Try to delete the db - await IndexedDB.deleteDatabase(err.db); - } catch (error) { - console.error(`Error while deleting the IndexedDB`, getErrorMessage(error)); - throw error; - } - - return await IndexedDB.doOpenDatabase(name, version, stores); - } - - throw err; - } finally { - mark(`code/didOpenDatabase/${name}`); - } - } - - private static doOpenDatabase(name: string, version: number | undefined, stores: string[]): Promise { - return new Promise((c, e) => { - const request = indexedDB.open(name, version); - request.onerror = () => e(request.error); - request.onsuccess = () => { - const db = request.result; - for (const store of stores) { - if (!db.objectStoreNames.contains(store)) { - console.error(`Error while opening IndexedDB. Could not find '${store}'' object store`); - e(new MissingStoresError(db)); - return; - } - } - c(db); - }; - request.onupgradeneeded = () => { - const db = request.result; - for (const store of stores) { - if (!db.objectStoreNames.contains(store)) { - db.createObjectStore(store); - } - } - }; - }); - } - - private static deleteDatabase(database: IDBDatabase): Promise { - return new Promise((c, e) => { - // Close any opened connections - database.close(); - - // Delete the db - const deleteRequest = indexedDB.deleteDatabase(database.name); - deleteRequest.onerror = (err) => e(deleteRequest.error); - deleteRequest.onsuccess = () => c(); - }); - } - - private database: IDBDatabase | null = null; - private readonly pendingTransactions: IDBTransaction[] = []; - - constructor(database: IDBDatabase, private readonly name: string) { - this.database = database; - } - - hasPendingTransactions(): boolean { - return this.pendingTransactions.length > 0; - } - - close(): void { - if (this.pendingTransactions.length) { - this.pendingTransactions.splice(0, this.pendingTransactions.length).forEach(transaction => transaction.abort()); - } - this.database?.close(); - this.database = null; - } - - runInTransaction(store: string, transactionMode: IDBTransactionMode, dbRequestFn: (store: IDBObjectStore) => IDBRequest[]): Promise; - runInTransaction(store: string, transactionMode: IDBTransactionMode, dbRequestFn: (store: IDBObjectStore) => IDBRequest): Promise; - async runInTransaction(store: string, transactionMode: IDBTransactionMode, dbRequestFn: (store: IDBObjectStore) => IDBRequest | IDBRequest[]): Promise { - if (!this.database) { - throw new DBClosedError(this.name); - } - const transaction = this.database.transaction(store, transactionMode); - this.pendingTransactions.push(transaction); - return new Promise((c, e) => { - transaction.oncomplete = () => { - if (Array.isArray(request)) { - c(request.map(r => r.result)); - } else { - c(request.result); - } - }; - transaction.onerror = () => e(transaction.error ? ErrorNoTelemetry.fromError(transaction.error) : new ErrorNoTelemetry('unknown error')); - transaction.onabort = () => e(transaction.error ? ErrorNoTelemetry.fromError(transaction.error) : new ErrorNoTelemetry('unknown error')); - const request = dbRequestFn(transaction.objectStore(store)); - }).finally(() => this.pendingTransactions.splice(this.pendingTransactions.indexOf(transaction), 1)); - } - - async getKeyValues(store: string, isValid: (value: unknown) => value is V): Promise> { - if (!this.database) { - throw new DBClosedError(this.name); - } - const transaction = this.database.transaction(store, 'readonly'); - this.pendingTransactions.push(transaction); - return new Promise>(resolve => { - const items = new Map(); - - const objectStore = transaction.objectStore(store); - - // Open a IndexedDB Cursor to iterate over key/values - const cursor = objectStore.openCursor(); - if (!cursor) { - return resolve(items); // this means the `ItemTable` was empty - } - - // Iterate over rows of `ItemTable` until the end - cursor.onsuccess = () => { - if (cursor.result) { - - // Keep cursor key/value in our map - if (isValid(cursor.result.value)) { - items.set(cursor.result.key.toString(), cursor.result.value); - } - - // Advance cursor to next row - cursor.result.continue(); - } else { - resolve(items); // reached end of table - } - }; - - // Error handlers - const onError = (error: Error | null) => { - console.error(`IndexedDB getKeyValues(): ${toErrorMessage(error, true)}`); - - resolve(items); - }; - cursor.onerror = () => onError(cursor.error); - transaction.onerror = () => onError(transaction.error); - }).finally(() => this.pendingTransactions.splice(this.pendingTransactions.indexOf(transaction), 1)); - } -} diff --git a/src/vs/base/browser/ui/aria/aria.css b/src/vs/base/browser/ui/aria/aria.css deleted file mode 100644 index c04b878444..0000000000 --- a/src/vs/base/browser/ui/aria/aria.css +++ /dev/null @@ -1,9 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -.monaco-aria-container { - position: absolute; /* try to hide from window but not from screen readers */ - left:-999em; -} diff --git a/src/vs/base/browser/ui/aria/aria.ts b/src/vs/base/browser/ui/aria/aria.ts deleted file mode 100644 index 52c0f91e5a..0000000000 --- a/src/vs/base/browser/ui/aria/aria.ts +++ /dev/null @@ -1,163 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import * as dom from 'vs/base/browser/dom'; -// import 'vs/css!./aria'; - -// Use a max length since we are inserting the whole msg in the DOM and that can cause browsers to freeze for long messages #94233 -const MAX_MESSAGE_LENGTH = 20000; -let ariaContainer: HTMLElement; -let alertContainer: HTMLElement; -let alertContainer2: HTMLElement; -let statusContainer: HTMLElement; -let statusContainer2: HTMLElement; -export function setARIAContainer(parent: HTMLElement) { - ariaContainer = document.createElement('div'); - ariaContainer.className = 'monaco-aria-container'; - - const createAlertContainer = () => { - const element = document.createElement('div'); - element.className = 'monaco-alert'; - element.setAttribute('role', 'alert'); - element.setAttribute('aria-atomic', 'true'); - ariaContainer.appendChild(element); - return element; - }; - alertContainer = createAlertContainer(); - alertContainer2 = createAlertContainer(); - - const createStatusContainer = () => { - const element = document.createElement('div'); - element.className = 'monaco-status'; - element.setAttribute('aria-live', 'polite'); - element.setAttribute('aria-atomic', 'true'); - ariaContainer.appendChild(element); - return element; - }; - statusContainer = createStatusContainer(); - statusContainer2 = createStatusContainer(); - - parent.appendChild(ariaContainer); -} -/** - * Given the provided message, will make sure that it is read as alert to screen readers. - */ -export function alert(msg: string): void { - if (!ariaContainer) { - return; - } - - // Use alternate containers such that duplicated messages get read out by screen readers #99466 - if (alertContainer.textContent !== msg) { - dom.clearNode(alertContainer2); - insertMessage(alertContainer, msg); - } else { - dom.clearNode(alertContainer); - insertMessage(alertContainer2, msg); - } -} - -/** - * Given the provided message, will make sure that it is read as status to screen readers. - */ -export function status(msg: string): void { - if (!ariaContainer) { - return; - } - - if (statusContainer.textContent !== msg) { - dom.clearNode(statusContainer2); - insertMessage(statusContainer, msg); - } else { - dom.clearNode(statusContainer); - insertMessage(statusContainer2, msg); - } -} - -function insertMessage(target: HTMLElement, msg: string): void { - dom.clearNode(target); - if (msg.length > MAX_MESSAGE_LENGTH) { - msg = msg.substr(0, MAX_MESSAGE_LENGTH); - } - target.textContent = msg; - - // See https://www.paciellogroup.com/blog/2012/06/html5-accessibility-chops-aria-rolealert-browser-support/ - target.style.visibility = 'hidden'; - target.style.visibility = 'visible'; -} - -// Copied from @types/react which original came from https://www.w3.org/TR/wai-aria-1.1/#role_definitions -export type AriaRole = - | 'alert' - | 'alertdialog' - | 'application' - | 'article' - | 'banner' - | 'button' - | 'cell' - | 'checkbox' - | 'columnheader' - | 'combobox' - | 'complementary' - | 'contentinfo' - | 'definition' - | 'dialog' - | 'directory' - | 'document' - | 'feed' - | 'figure' - | 'form' - | 'grid' - | 'gridcell' - | 'group' - | 'heading' - | 'img' - | 'link' - | 'list' - | 'listbox' - | 'listitem' - | 'log' - | 'main' - | 'marquee' - | 'math' - | 'menu' - | 'menubar' - | 'menuitem' - | 'menuitemcheckbox' - | 'menuitemradio' - | 'navigation' - | 'none' - | 'note' - | 'option' - | 'presentation' - | 'progressbar' - | 'radio' - | 'radiogroup' - | 'region' - | 'row' - | 'rowgroup' - | 'rowheader' - | 'scrollbar' - | 'search' - | 'searchbox' - | 'separator' - | 'slider' - | 'spinbutton' - | 'status' - | 'switch' - | 'tab' - | 'table' - | 'tablist' - | 'tabpanel' - | 'term' - | 'textbox' - | 'timer' - | 'toolbar' - | 'tooltip' - | 'tree' - | 'treegrid' - | 'treeitem' - | (string & {}) // Prevent type collapsing to `string` - ; diff --git a/src/vs/base/browser/ui/breadcrumbs/breadcrumbsWidget.css b/src/vs/base/browser/ui/breadcrumbs/breadcrumbsWidget.css deleted file mode 100644 index 4a3cf07479..0000000000 --- a/src/vs/base/browser/ui/breadcrumbs/breadcrumbsWidget.css +++ /dev/null @@ -1,36 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -.monaco-breadcrumbs { - user-select: none; - -webkit-user-select: none; - display: flex; - flex-direction: row; - flex-wrap: nowrap; - justify-content: flex-start; - outline-style: none; -} - -.monaco-breadcrumbs .monaco-breadcrumb-item { - display: flex; - align-items: center; - flex: 0 1 auto; - white-space: nowrap; - cursor: pointer; - align-self: center; - height: 100%; - outline: none; -} -.monaco-breadcrumbs.disabled .monaco-breadcrumb-item { - cursor: default; -} - -.monaco-breadcrumbs .monaco-breadcrumb-item .codicon-breadcrumb-separator { - color: inherit; -} - -.monaco-breadcrumbs .monaco-breadcrumb-item:first-of-type::before { - content: ' '; -} diff --git a/src/vs/base/browser/ui/breadcrumbs/breadcrumbsWidget.ts b/src/vs/base/browser/ui/breadcrumbs/breadcrumbsWidget.ts deleted file mode 100644 index ea33ce5c51..0000000000 --- a/src/vs/base/browser/ui/breadcrumbs/breadcrumbsWidget.ts +++ /dev/null @@ -1,356 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import * as dom from 'vs/base/browser/dom'; -import { IMouseEvent } from 'vs/base/browser/mouseEvent'; -import { DomScrollableElement } from 'vs/base/browser/ui/scrollbar/scrollableElement'; -import { commonPrefixLength } from 'vs/base/common/arrays'; -import { ThemeIcon } from 'vs/base/common/themables'; -import { Emitter, Event } from 'vs/base/common/event'; -import { DisposableStore, dispose, IDisposable } from 'vs/base/common/lifecycle'; -import { ScrollbarVisibility } from 'vs/base/common/scrollable'; -// import 'vs/css!./breadcrumbsWidget'; - -export abstract class BreadcrumbsItem { - abstract dispose(): void; - abstract equals(other: BreadcrumbsItem): boolean; - abstract render(container: HTMLElement): void; -} - -export interface IBreadcrumbsWidgetStyles { - readonly breadcrumbsBackground: string | undefined; - readonly breadcrumbsForeground: string | undefined; - readonly breadcrumbsHoverForeground: string | undefined; - readonly breadcrumbsFocusForeground: string | undefined; - readonly breadcrumbsFocusAndSelectionForeground: string | undefined; -} - -export interface IBreadcrumbsItemEvent { - type: 'select' | 'focus'; - item: BreadcrumbsItem; - node: HTMLElement; - payload: any; -} - -export class BreadcrumbsWidget { - - private readonly _disposables = new DisposableStore(); - private readonly _domNode: HTMLDivElement; - private readonly _scrollable: DomScrollableElement; - - private readonly _onDidSelectItem = new Emitter(); - private readonly _onDidFocusItem = new Emitter(); - private readonly _onDidChangeFocus = new Emitter(); - - readonly onDidSelectItem: Event = this._onDidSelectItem.event; - readonly onDidFocusItem: Event = this._onDidFocusItem.event; - readonly onDidChangeFocus: Event = this._onDidChangeFocus.event; - - private readonly _items = new Array(); - private readonly _nodes = new Array(); - private readonly _freeNodes = new Array(); - private readonly _separatorIcon: ThemeIcon; - - private _enabled: boolean = true; - private _focusedItemIdx: number = -1; - private _selectedItemIdx: number = -1; - - private _pendingDimLayout: IDisposable | undefined; - private _pendingLayout: IDisposable | undefined; - private _dimension: dom.Dimension | undefined; - - constructor( - container: HTMLElement, - horizontalScrollbarSize: number, - separatorIcon: ThemeIcon, - styles: IBreadcrumbsWidgetStyles - ) { - this._domNode = document.createElement('div'); - this._domNode.className = 'monaco-breadcrumbs'; - this._domNode.tabIndex = 0; - this._domNode.setAttribute('role', 'list'); - this._scrollable = new DomScrollableElement(this._domNode, { - vertical: ScrollbarVisibility.Hidden, - horizontal: ScrollbarVisibility.Auto, - horizontalScrollbarSize, - useShadows: false, - scrollYToX: true - }); - this._separatorIcon = separatorIcon; - this._disposables.add(this._scrollable); - this._disposables.add(dom.addStandardDisposableListener(this._domNode, 'click', e => this._onClick(e))); - container.appendChild(this._scrollable.getDomNode()); - - const styleElement = dom.createStyleSheet(this._domNode); - this._style(styleElement, styles); - - const focusTracker = dom.trackFocus(this._domNode); - this._disposables.add(focusTracker); - this._disposables.add(focusTracker.onDidBlur(_ => this._onDidChangeFocus.fire(false))); - this._disposables.add(focusTracker.onDidFocus(_ => this._onDidChangeFocus.fire(true))); - } - - setHorizontalScrollbarSize(size: number) { - this._scrollable.updateOptions({ - horizontalScrollbarSize: size - }); - } - - dispose(): void { - this._disposables.dispose(); - this._pendingLayout?.dispose(); - this._pendingDimLayout?.dispose(); - this._onDidSelectItem.dispose(); - this._onDidFocusItem.dispose(); - this._onDidChangeFocus.dispose(); - this._domNode.remove(); - this._nodes.length = 0; - this._freeNodes.length = 0; - } - - layout(dim: dom.Dimension | undefined): void { - if (dim && dom.Dimension.equals(dim, this._dimension)) { - return; - } - if (dim) { - // only measure - this._pendingDimLayout?.dispose(); - this._pendingDimLayout = this._updateDimensions(dim); - } else { - this._pendingLayout?.dispose(); - this._pendingLayout = this._updateScrollbar(); - } - } - - private _updateDimensions(dim: dom.Dimension): IDisposable { - const disposables = new DisposableStore(); - disposables.add(dom.modify(dom.getWindow(this._domNode), () => { - this._dimension = dim; - this._domNode.style.width = `${dim.width}px`; - this._domNode.style.height = `${dim.height}px`; - disposables.add(this._updateScrollbar()); - })); - return disposables; - } - - private _updateScrollbar(): IDisposable { - return dom.measure(dom.getWindow(this._domNode), () => { - dom.measure(dom.getWindow(this._domNode), () => { // double RAF - this._scrollable.setRevealOnScroll(false); - this._scrollable.scanDomNode(); - this._scrollable.setRevealOnScroll(true); - }); - }); - } - - private _style(styleElement: HTMLStyleElement, style: IBreadcrumbsWidgetStyles): void { - let content = ''; - if (style.breadcrumbsBackground) { - content += `.monaco-breadcrumbs { background-color: ${style.breadcrumbsBackground}}`; - } - if (style.breadcrumbsForeground) { - content += `.monaco-breadcrumbs .monaco-breadcrumb-item { color: ${style.breadcrumbsForeground}}\n`; - } - if (style.breadcrumbsFocusForeground) { - content += `.monaco-breadcrumbs .monaco-breadcrumb-item.focused { color: ${style.breadcrumbsFocusForeground}}\n`; - } - if (style.breadcrumbsFocusAndSelectionForeground) { - content += `.monaco-breadcrumbs .monaco-breadcrumb-item.focused.selected { color: ${style.breadcrumbsFocusAndSelectionForeground}}\n`; - } - if (style.breadcrumbsHoverForeground) { - content += `.monaco-breadcrumbs:not(.disabled ) .monaco-breadcrumb-item:hover:not(.focused):not(.selected) { color: ${style.breadcrumbsHoverForeground}}\n`; - } - styleElement.innerText = content; - } - - setEnabled(value: boolean) { - this._enabled = value; - this._domNode.classList.toggle('disabled', !this._enabled); - } - - domFocus(): void { - const idx = this._focusedItemIdx >= 0 ? this._focusedItemIdx : this._items.length - 1; - if (idx >= 0 && idx < this._items.length) { - this._focus(idx, undefined); - } else { - this._domNode.focus(); - } - } - - isDOMFocused(): boolean { - return dom.isAncestorOfActiveElement(this._domNode); - } - - getFocused(): BreadcrumbsItem { - return this._items[this._focusedItemIdx]; - } - - setFocused(item: BreadcrumbsItem | undefined, payload?: any): void { - this._focus(this._items.indexOf(item!), payload); - } - - focusPrev(payload?: any): any { - if (this._focusedItemIdx > 0) { - this._focus(this._focusedItemIdx - 1, payload); - } - } - - focusNext(payload?: any): any { - if (this._focusedItemIdx + 1 < this._nodes.length) { - this._focus(this._focusedItemIdx + 1, payload); - } - } - - private _focus(nth: number, payload: any): void { - this._focusedItemIdx = -1; - for (let i = 0; i < this._nodes.length; i++) { - const node = this._nodes[i]; - if (i !== nth) { - node.classList.remove('focused'); - } else { - this._focusedItemIdx = i; - node.classList.add('focused'); - node.focus(); - } - } - this._reveal(this._focusedItemIdx, true); - this._onDidFocusItem.fire({ type: 'focus', item: this._items[this._focusedItemIdx], node: this._nodes[this._focusedItemIdx], payload }); - } - - reveal(item: BreadcrumbsItem): void { - const idx = this._items.indexOf(item); - if (idx >= 0) { - this._reveal(idx, false); - } - } - - revealLast(): void { - this._reveal(this._items.length - 1, false); - } - - private _reveal(nth: number, minimal: boolean): void { - if (nth < 0 || nth >= this._nodes.length) { - return; - } - const node = this._nodes[nth]; - if (!node) { - return; - } - const { width } = this._scrollable.getScrollDimensions(); - const { scrollLeft } = this._scrollable.getScrollPosition(); - if (!minimal || node.offsetLeft > scrollLeft + width || node.offsetLeft < scrollLeft) { - this._scrollable.setRevealOnScroll(false); - this._scrollable.setScrollPosition({ scrollLeft: node.offsetLeft }); - this._scrollable.setRevealOnScroll(true); - } - } - - getSelection(): BreadcrumbsItem { - return this._items[this._selectedItemIdx]; - } - - setSelection(item: BreadcrumbsItem | undefined, payload?: any): void { - this._select(this._items.indexOf(item!), payload); - } - - private _select(nth: number, payload: any): void { - this._selectedItemIdx = -1; - for (let i = 0; i < this._nodes.length; i++) { - const node = this._nodes[i]; - if (i !== nth) { - node.classList.remove('selected'); - } else { - this._selectedItemIdx = i; - node.classList.add('selected'); - } - } - this._onDidSelectItem.fire({ type: 'select', item: this._items[this._selectedItemIdx], node: this._nodes[this._selectedItemIdx], payload }); - } - - getItems(): readonly BreadcrumbsItem[] { - return this._items; - } - - setItems(items: BreadcrumbsItem[]): void { - let prefix: number | undefined; - let removed: BreadcrumbsItem[] = []; - try { - prefix = commonPrefixLength(this._items, items, (a, b) => a.equals(b)); - removed = this._items.splice(prefix, this._items.length - prefix, ...items.slice(prefix)); - this._render(prefix); - dispose(removed); - this._focus(-1, undefined); - } catch (e) { - const newError = new Error(`BreadcrumbsItem#setItems: newItems: ${items.length}, prefix: ${prefix}, removed: ${removed.length}`); - newError.name = e.name; - newError.stack = e.stack; - throw newError; - } - } - - private _render(start: number): void { - let didChange = false; - for (; start < this._items.length && start < this._nodes.length; start++) { - const item = this._items[start]; - const node = this._nodes[start]; - this._renderItem(item, node); - didChange = true; - } - // case a: more nodes -> remove them - while (start < this._nodes.length) { - const free = this._nodes.pop(); - if (free) { - this._freeNodes.push(free); - free.remove(); - didChange = true; - } - } - - // case b: more items -> render them - for (; start < this._items.length; start++) { - const item = this._items[start]; - const node = this._freeNodes.length > 0 ? this._freeNodes.pop() : document.createElement('div'); - if (node) { - this._renderItem(item, node); - this._domNode.appendChild(node); - this._nodes.push(node); - didChange = true; - } - } - if (didChange) { - this.layout(undefined); - } - } - - private _renderItem(item: BreadcrumbsItem, container: HTMLDivElement): void { - dom.clearNode(container); - container.className = ''; - try { - item.render(container); - } catch (err) { - container.innerText = '<>'; - console.error(err); - } - container.tabIndex = -1; - container.setAttribute('role', 'listitem'); - container.classList.add('monaco-breadcrumb-item'); - const iconContainer = dom.$(ThemeIcon.asCSSSelector(this._separatorIcon)); - container.appendChild(iconContainer); - } - - private _onClick(event: IMouseEvent): void { - if (!this._enabled) { - return; - } - for (let el: HTMLElement | null = event.target; el; el = el.parentElement) { - const idx = this._nodes.indexOf(el as HTMLDivElement); - if (idx >= 0) { - this._focus(idx, event); - this._select(idx, event); - break; - } - } - } -} diff --git a/src/vs/base/browser/ui/centered/centeredViewLayout.ts b/src/vs/base/browser/ui/centered/centeredViewLayout.ts deleted file mode 100644 index b6bc80b1d9..0000000000 --- a/src/vs/base/browser/ui/centered/centeredViewLayout.ts +++ /dev/null @@ -1,225 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { $, IDomNodePagePosition } from 'vs/base/browser/dom'; -import { IView, IViewSize } from 'vs/base/browser/ui/grid/grid'; -import { IBoundarySashes } from 'vs/base/browser/ui/sash/sash'; -import { DistributeSizing, ISplitViewStyles, IView as ISplitViewView, Orientation, SplitView } from 'vs/base/browser/ui/splitview/splitview'; -import { Color } from 'vs/base/common/color'; -import { Event } from 'vs/base/common/event'; -import { DisposableStore, IDisposable } from 'vs/base/common/lifecycle'; - -export interface CenteredViewState { - // width of the fixed centered layout - targetWidth: number; - // proportional size of left margin - leftMarginRatio: number; - // proportional size of right margin - rightMarginRatio: number; -} - -const defaultState: CenteredViewState = { - targetWidth: 900, - leftMarginRatio: 0.1909, - rightMarginRatio: 0.1909, -}; - -const distributeSizing: DistributeSizing = { type: 'distribute' }; - -function createEmptyView(background: Color | undefined): ISplitViewView<{ top: number; left: number }> { - const element = $('.centered-layout-margin'); - element.style.height = '100%'; - if (background) { - element.style.backgroundColor = background.toString(); - } - - return { - element, - layout: () => undefined, - minimumSize: 60, - maximumSize: Number.POSITIVE_INFINITY, - onDidChange: Event.None - }; -} - -function toSplitViewView(view: IView, getHeight: () => number): ISplitViewView<{ top: number; left: number }> { - return { - element: view.element, - get maximumSize() { return view.maximumWidth; }, - get minimumSize() { return view.minimumWidth; }, - onDidChange: Event.map(view.onDidChange, e => e && e.width), - layout: (size, offset, ctx) => view.layout(size, getHeight(), ctx?.top ?? 0, (ctx?.left ?? 0) + offset) - }; -} - -export interface ICenteredViewStyles extends ISplitViewStyles { - background: Color; -} - -export class CenteredViewLayout implements IDisposable { - - private splitView?: SplitView<{ top: number; left: number }>; - private lastLayoutPosition: IDomNodePagePosition = { width: 0, height: 0, left: 0, top: 0 }; - private style!: ICenteredViewStyles; - private didLayout = false; - private emptyViews: ISplitViewView<{ top: number; left: number }>[] | undefined; - private readonly splitViewDisposables = new DisposableStore(); - - constructor( - private container: HTMLElement, - private view: IView, - public state: CenteredViewState = { ...defaultState }, - private centeredLayoutFixedWidth: boolean = false - ) { - this.container.appendChild(this.view.element); - // Make sure to hide the split view overflow like sashes #52892 - this.container.style.overflow = 'hidden'; - } - - get minimumWidth(): number { return this.splitView ? this.splitView.minimumSize : this.view.minimumWidth; } - get maximumWidth(): number { return this.splitView ? this.splitView.maximumSize : this.view.maximumWidth; } - get minimumHeight(): number { return this.view.minimumHeight; } - get maximumHeight(): number { return this.view.maximumHeight; } - get onDidChange(): Event { return this.view.onDidChange; } - - private _boundarySashes: IBoundarySashes = {}; - get boundarySashes(): IBoundarySashes { return this._boundarySashes; } - set boundarySashes(boundarySashes: IBoundarySashes) { - this._boundarySashes = boundarySashes; - - if (!this.splitView) { - return; - } - - this.splitView.orthogonalStartSash = boundarySashes.top; - this.splitView.orthogonalEndSash = boundarySashes.bottom; - } - - layout(width: number, height: number, top: number, left: number): void { - this.lastLayoutPosition = { width, height, top, left }; - if (this.splitView) { - this.splitView.layout(width, this.lastLayoutPosition); - if (!this.didLayout || this.centeredLayoutFixedWidth) { - this.resizeSplitViews(); - } - } else { - this.view.layout(width, height, top, left); - } - - this.didLayout = true; - } - - private resizeSplitViews(): void { - if (!this.splitView) { - return; - } - if (this.centeredLayoutFixedWidth) { - const centerViewWidth = Math.min(this.lastLayoutPosition.width, this.state.targetWidth); - const marginWidthFloat = (this.lastLayoutPosition.width - centerViewWidth) / 2; - this.splitView.resizeView(0, Math.floor(marginWidthFloat)); - this.splitView.resizeView(1, centerViewWidth); - this.splitView.resizeView(2, Math.ceil(marginWidthFloat)); - } else { - const leftMargin = this.state.leftMarginRatio * this.lastLayoutPosition.width; - const rightMargin = this.state.rightMarginRatio * this.lastLayoutPosition.width; - const center = this.lastLayoutPosition.width - leftMargin - rightMargin; - this.splitView.resizeView(0, leftMargin); - this.splitView.resizeView(1, center); - this.splitView.resizeView(2, rightMargin); - } - } - - setFixedWidth(option: boolean) { - this.centeredLayoutFixedWidth = option; - if (!!this.splitView) { - this.updateState(); - this.resizeSplitViews(); - } - } - - private updateState() { - if (!!this.splitView) { - this.state.targetWidth = this.splitView.getViewSize(1); - this.state.leftMarginRatio = this.splitView.getViewSize(0) / this.lastLayoutPosition.width; - this.state.rightMarginRatio = this.splitView.getViewSize(2) / this.lastLayoutPosition.width; - } - } - - isActive(): boolean { - return !!this.splitView; - } - - styles(style: ICenteredViewStyles): void { - this.style = style; - if (this.splitView && this.emptyViews) { - this.splitView.style(this.style); - this.emptyViews[0].element.style.backgroundColor = this.style.background.toString(); - this.emptyViews[1].element.style.backgroundColor = this.style.background.toString(); - } - } - - activate(active: boolean): void { - if (active === this.isActive()) { - return; - } - - if (active) { - this.view.element.remove(); - this.splitView = new SplitView(this.container, { - inverseAltBehavior: true, - orientation: Orientation.HORIZONTAL, - styles: this.style - }); - this.splitView.orthogonalStartSash = this.boundarySashes.top; - this.splitView.orthogonalEndSash = this.boundarySashes.bottom; - - this.splitViewDisposables.add(this.splitView.onDidSashChange(() => { - if (!!this.splitView) { - this.updateState(); - } - })); - this.splitViewDisposables.add(this.splitView.onDidSashReset(() => { - this.state = { ...defaultState }; - this.resizeSplitViews(); - })); - - this.splitView.layout(this.lastLayoutPosition.width, this.lastLayoutPosition); - const backgroundColor = this.style ? this.style.background : undefined; - this.emptyViews = [createEmptyView(backgroundColor), createEmptyView(backgroundColor)]; - - this.splitView.addView(this.emptyViews[0], distributeSizing, 0); - this.splitView.addView(toSplitViewView(this.view, () => this.lastLayoutPosition.height), distributeSizing, 1); - this.splitView.addView(this.emptyViews[1], distributeSizing, 2); - - this.resizeSplitViews(); - } else { - this.splitView?.el.remove(); - this.splitViewDisposables.clear(); - this.splitView?.dispose(); - this.splitView = undefined; - this.emptyViews = undefined; - this.container.appendChild(this.view.element); - this.view.layout(this.lastLayoutPosition.width, this.lastLayoutPosition.height, this.lastLayoutPosition.top, this.lastLayoutPosition.left); - } - } - - isDefault(state: CenteredViewState): boolean { - if (this.centeredLayoutFixedWidth) { - return state.targetWidth === defaultState.targetWidth; - } else { - return state.leftMarginRatio === defaultState.leftMarginRatio - && state.rightMarginRatio === defaultState.rightMarginRatio; - } - } - - dispose(): void { - this.splitViewDisposables.dispose(); - - if (this.splitView) { - this.splitView.dispose(); - this.splitView = undefined; - } - } -} diff --git a/src/vs/base/browser/ui/codicons/codicon/codicon-modifiers.css b/src/vs/base/browser/ui/codicons/codicon/codicon-modifiers.css deleted file mode 100644 index 9666216f6a..0000000000 --- a/src/vs/base/browser/ui/codicons/codicon/codicon-modifiers.css +++ /dev/null @@ -1,33 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -.codicon-wrench-subaction { - opacity: 0.5; -} - -@keyframes codicon-spin { - 100% { - transform:rotate(360deg); - } -} - -.codicon-sync.codicon-modifier-spin, -.codicon-loading.codicon-modifier-spin, -.codicon-gear.codicon-modifier-spin, -.codicon-notebook-state-executing.codicon-modifier-spin { - /* Use steps to throttle FPS to reduce CPU usage */ - animation: codicon-spin 1.5s steps(30) infinite; -} - -.codicon-modifier-disabled { - opacity: 0.4; -} - -/* custom speed & easing for loading icon */ -.codicon-loading, -.codicon-tree-item-loading::before { - animation-duration: 1s !important; - animation-timing-function: cubic-bezier(0.53, 0.21, 0.29, 0.67) !important; -} diff --git a/src/vs/base/browser/ui/codicons/codicon/codicon.css b/src/vs/base/browser/ui/codicons/codicon/codicon.css deleted file mode 100644 index 02154e77b6..0000000000 --- a/src/vs/base/browser/ui/codicons/codicon/codicon.css +++ /dev/null @@ -1,25 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -@font-face { - font-family: "codicon"; - font-display: block; - src: url("./codicon.ttf?5d4d76ab2ce5108968ad644d591a16a6") format("truetype"); -} - -.codicon[class*='codicon-'] { - font: normal normal normal 16px/1 codicon; - display: inline-block; - text-decoration: none; - text-rendering: auto; - text-align: center; - text-transform: none; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - user-select: none; - -webkit-user-select: none; -} - -/* icon rules are dynamically created by the platform theme service (see iconsStyleSheet.ts) */ diff --git a/src/vs/base/browser/ui/codicons/codiconStyles.ts b/src/vs/base/browser/ui/codicons/codiconStyles.ts deleted file mode 100644 index a88fd3ca53..0000000000 --- a/src/vs/base/browser/ui/codicons/codiconStyles.ts +++ /dev/null @@ -1,7 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -// import 'vs/css!./codicon/codicon'; -// import 'vs/css!./codicon/codicon-modifiers'; diff --git a/src/vs/base/browser/ui/countBadge/countBadge.css b/src/vs/base/browser/ui/countBadge/countBadge.css deleted file mode 100644 index eb0c0837ee..0000000000 --- a/src/vs/base/browser/ui/countBadge/countBadge.css +++ /dev/null @@ -1,24 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -.monaco-count-badge { - padding: 3px 6px; - border-radius: 11px; - font-size: 11px; - min-width: 18px; - min-height: 18px; - line-height: 11px; - font-weight: normal; - text-align: center; - display: inline-block; - box-sizing: border-box; -} - -.monaco-count-badge.long { - padding: 2px 3px; - border-radius: 2px; - min-height: auto; - line-height: normal; -} diff --git a/src/vs/base/browser/ui/countBadge/countBadge.ts b/src/vs/base/browser/ui/countBadge/countBadge.ts deleted file mode 100644 index fe2f762a96..0000000000 --- a/src/vs/base/browser/ui/countBadge/countBadge.ts +++ /dev/null @@ -1,69 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { $, append } from 'vs/base/browser/dom'; -import { format } from 'vs/base/common/strings'; -// import 'vs/css!./countBadge'; - -export interface ICountBadgeOptions { - readonly count?: number; - readonly countFormat?: string; - readonly titleFormat?: string; -} - -export interface ICountBadgeStyles { - readonly badgeBackground: string | undefined; - readonly badgeForeground: string | undefined; - readonly badgeBorder: string | undefined; -} - -export const unthemedCountStyles: ICountBadgeStyles = { - badgeBackground: '#4D4D4D', - badgeForeground: '#FFFFFF', - badgeBorder: undefined -}; - -export class CountBadge { - - private element: HTMLElement; - private count: number = 0; - private countFormat: string; - private titleFormat: string; - - constructor(container: HTMLElement, private readonly options: ICountBadgeOptions, private readonly styles: ICountBadgeStyles) { - - this.element = append(container, $('.monaco-count-badge')); - this.countFormat = this.options.countFormat || '{0}'; - this.titleFormat = this.options.titleFormat || ''; - this.setCount(this.options.count || 0); - } - - setCount(count: number) { - this.count = count; - this.render(); - } - - setCountFormat(countFormat: string) { - this.countFormat = countFormat; - this.render(); - } - - setTitleFormat(titleFormat: string) { - this.titleFormat = titleFormat; - this.render(); - } - - private render() { - this.element.textContent = format(this.countFormat, this.count); - this.element.title = format(this.titleFormat, this.count); - - this.element.style.backgroundColor = this.styles.badgeBackground ?? ''; - this.element.style.color = this.styles.badgeForeground ?? ''; - - if (this.styles.badgeBorder) { - this.element.style.border = `1px solid ${this.styles.badgeBorder}`; - } - } -} diff --git a/src/vs/base/browser/ui/grid/grid.ts b/src/vs/base/browser/ui/grid/grid.ts deleted file mode 100644 index cf7533e59f..0000000000 --- a/src/vs/base/browser/ui/grid/grid.ts +++ /dev/null @@ -1,947 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { IBoundarySashes, Orientation } from 'vs/base/browser/ui/sash/sash'; -import { equals, tail2 as tail } from 'vs/base/common/arrays'; -import { Event } from 'vs/base/common/event'; -import { Disposable } from 'vs/base/common/lifecycle'; -// import 'vs/css!./gridview'; -import { Box, GridView, IGridViewOptions, IGridViewStyles, IView as IGridViewView, IViewSize, orthogonal, Sizing as GridViewSizing, GridLocation } from './gridview'; -import type { SplitView, AutoSizing as SplitViewAutoSizing } from 'vs/base/browser/ui/splitview/splitview'; - -export type { IViewSize }; -export { LayoutPriority, Orientation, orthogonal } from './gridview'; - -export const enum Direction { - Up, - Down, - Left, - Right -} - -function oppositeDirection(direction: Direction): Direction { - switch (direction) { - case Direction.Up: return Direction.Down; - case Direction.Down: return Direction.Up; - case Direction.Left: return Direction.Right; - case Direction.Right: return Direction.Left; - } -} - -/** - * The interface to implement for views within a {@link Grid}. - */ -export interface IView extends IGridViewView { - - /** - * The preferred width for when the user double clicks a sash - * adjacent to this view. - */ - readonly preferredWidth?: number; - - /** - * The preferred height for when the user double clicks a sash - * adjacent to this view. - */ - readonly preferredHeight?: number; -} - -export interface GridLeafNode { - readonly view: T; - readonly box: Box; - readonly cachedVisibleSize: number | undefined; - readonly maximized: boolean; -} - -export interface GridBranchNode { - readonly children: GridNode[]; - readonly box: Box; -} - -export type GridNode = GridLeafNode | GridBranchNode; - -export function isGridBranchNode(node: GridNode): node is GridBranchNode { - return !!(node as any).children; -} - -function getGridNode(node: GridNode, location: GridLocation): GridNode { - if (location.length === 0) { - return node; - } - - if (!isGridBranchNode(node)) { - throw new Error('Invalid location'); - } - - const [index, ...rest] = location; - return getGridNode(node.children[index], rest); -} - -interface Range { - readonly start: number; - readonly end: number; -} - -function intersects(one: Range, other: Range): boolean { - return !(one.start >= other.end || other.start >= one.end); -} - -interface Boundary { - readonly offset: number; - readonly range: Range; -} - -function getBoxBoundary(box: Box, direction: Direction): Boundary { - const orientation = getDirectionOrientation(direction); - const offset = direction === Direction.Up ? box.top : - direction === Direction.Right ? box.left + box.width : - direction === Direction.Down ? box.top + box.height : - box.left; - - const range = { - start: orientation === Orientation.HORIZONTAL ? box.top : box.left, - end: orientation === Orientation.HORIZONTAL ? box.top + box.height : box.left + box.width - }; - - return { offset, range }; -} - -function findAdjacentBoxLeafNodes(boxNode: GridNode, direction: Direction, boundary: Boundary): GridLeafNode[] { - const result: GridLeafNode[] = []; - - function _(boxNode: GridNode, direction: Direction, boundary: Boundary): void { - if (isGridBranchNode(boxNode)) { - for (const child of boxNode.children) { - _(child, direction, boundary); - } - } else { - const { offset, range } = getBoxBoundary(boxNode.box, direction); - - if (offset === boundary.offset && intersects(range, boundary.range)) { - result.push(boxNode); - } - } - } - - _(boxNode, direction, boundary); - return result; -} - -function getLocationOrientation(rootOrientation: Orientation, location: GridLocation): Orientation { - return location.length % 2 === 0 ? orthogonal(rootOrientation) : rootOrientation; -} - -function getDirectionOrientation(direction: Direction): Orientation { - return direction === Direction.Up || direction === Direction.Down ? Orientation.VERTICAL : Orientation.HORIZONTAL; -} - -export function getRelativeLocation(rootOrientation: Orientation, location: GridLocation, direction: Direction): GridLocation { - const orientation = getLocationOrientation(rootOrientation, location); - const directionOrientation = getDirectionOrientation(direction); - - if (orientation === directionOrientation) { - let [rest, index] = tail(location); - - if (direction === Direction.Right || direction === Direction.Down) { - index += 1; - } - - return [...rest, index]; - } else { - const index = (direction === Direction.Right || direction === Direction.Down) ? 1 : 0; - return [...location, index]; - } -} - -function indexInParent(element: HTMLElement): number { - const parentElement = element.parentElement; - - if (!parentElement) { - throw new Error('Invalid grid element'); - } - - let el = parentElement.firstElementChild; - let index = 0; - - while (el !== element && el !== parentElement.lastElementChild && el) { - el = el.nextElementSibling; - index++; - } - - return index; -} - -/** - * Find the grid location of a specific DOM element by traversing the parent - * chain and finding each child index on the way. - * - * This will break as soon as DOM structures of the Splitview or Gridview change. - */ -function getGridLocation(element: HTMLElement): GridLocation { - const parentElement = element.parentElement; - - if (!parentElement) { - throw new Error('Invalid grid element'); - } - - if (/\bmonaco-grid-view\b/.test(parentElement.className)) { - return []; - } - - const index = indexInParent(parentElement); - const ancestor = parentElement.parentElement!.parentElement!.parentElement!.parentElement!; - return [...getGridLocation(ancestor), index]; -} - -export type DistributeSizing = { type: 'distribute' }; -export type SplitSizing = { type: 'split' }; -export type AutoSizing = { type: 'auto' }; -export type InvisibleSizing = { type: 'invisible'; cachedVisibleSize: number }; -export type Sizing = DistributeSizing | SplitSizing | AutoSizing | InvisibleSizing; - -export namespace Sizing { - export const Distribute: DistributeSizing = { type: 'distribute' }; - export const Split: SplitSizing = { type: 'split' }; - export const Auto: AutoSizing = { type: 'auto' }; - export function Invisible(cachedVisibleSize: number): InvisibleSizing { return { type: 'invisible', cachedVisibleSize }; } -} - -export interface IGridStyles extends IGridViewStyles { } -export interface IGridOptions extends IGridViewOptions { } - -/** - * The {@link Grid} exposes a Grid widget in a friendlier API than the underlying - * {@link GridView} widget. Namely, all mutation operations are addressed by the - * model elements, rather than indexes. - * - * It support the same features as the {@link GridView}. - */ -export class Grid extends Disposable { - - protected gridview: GridView; - private views = new Map(); - - /** - * The orientation of the grid. Matches the orientation of the root - * {@link SplitView} in the grid's {@link GridLocation} model. - */ - get orientation(): Orientation { return this.gridview.orientation; } - set orientation(orientation: Orientation) { this.gridview.orientation = orientation; } - - /** - * The width of the grid. - */ - get width(): number { return this.gridview.width; } - - /** - * The height of the grid. - */ - get height(): number { return this.gridview.height; } - - /** - * The minimum width of the grid. - */ - get minimumWidth(): number { return this.gridview.minimumWidth; } - - /** - * The minimum height of the grid. - */ - get minimumHeight(): number { return this.gridview.minimumHeight; } - - /** - * The maximum width of the grid. - */ - get maximumWidth(): number { return this.gridview.maximumWidth; } - - /** - * The maximum height of the grid. - */ - get maximumHeight(): number { return this.gridview.maximumHeight; } - - /** - * Fires whenever a view within the grid changes its size constraints. - */ - readonly onDidChange: Event<{ width: number; height: number } | undefined>; - - /** - * Fires whenever the user scrolls a {@link SplitView} within - * the grid. - */ - readonly onDidScroll: Event; - - /** - * A collection of sashes perpendicular to each edge of the grid. - * Corner sashes will be created for each intersection. - */ - get boundarySashes(): IBoundarySashes { return this.gridview.boundarySashes; } - set boundarySashes(boundarySashes: IBoundarySashes) { this.gridview.boundarySashes = boundarySashes; } - - /** - * Enable/disable edge snapping across all grid views. - */ - set edgeSnapping(edgeSnapping: boolean) { this.gridview.edgeSnapping = edgeSnapping; } - - /** - * The DOM element for this view. - */ - get element(): HTMLElement { return this.gridview.element; } - - private didLayout = false; - - readonly onDidChangeViewMaximized: Event; - /** - * Create a new {@link Grid}. A grid must *always* have a view - * inside. - * - * @param view An initial view for this Grid. - */ - constructor(view: T | GridView, options: IGridOptions = {}) { - super(); - - if (view instanceof GridView) { - this.gridview = view; - this.gridview.getViewMap(this.views); - } else { - this.gridview = new GridView(options); - } - - this._register(this.gridview); - this._register(this.gridview.onDidSashReset(this.onDidSashReset, this)); - - if (!(view instanceof GridView)) { - this._addView(view, 0, [0]); - } - - this.onDidChange = this.gridview.onDidChange; - this.onDidScroll = this.gridview.onDidScroll; - this.onDidChangeViewMaximized = this.gridview.onDidChangeViewMaximized; - } - - style(styles: IGridStyles): void { - this.gridview.style(styles); - } - - /** - * Layout the {@link Grid}. - * - * Optionally provide a `top` and `left` positions, those will propagate - * as an origin for positions passed to {@link IView.layout}. - * - * @param width The width of the {@link Grid}. - * @param height The height of the {@link Grid}. - * @param top Optional, the top location of the {@link Grid}. - * @param left Optional, the left location of the {@link Grid}. - */ - layout(width: number, height: number, top: number = 0, left: number = 0): void { - this.gridview.layout(width, height, top, left); - this.didLayout = true; - } - - /** - * Add a {@link IView view} to this {@link Grid}, based on another reference view. - * - * Take this grid as an example: - * - * ``` - * +-----+---------------+ - * | A | B | - * +-----+---------+-----+ - * | C | | - * +---------------+ D | - * | E | | - * +---------------+-----+ - * ``` - * - * Calling `addView(X, Sizing.Distribute, C, Direction.Right)` will make the following - * changes: - * - * ``` - * +-----+---------------+ - * | A | B | - * +-----+-+-------+-----+ - * | C | X | | - * +-------+-------+ D | - * | E | | - * +---------------+-----+ - * ``` - * - * Or `addView(X, Sizing.Distribute, D, Direction.Down)`: - * - * ``` - * +-----+---------------+ - * | A | B | - * +-----+---------+-----+ - * | C | D | - * +---------------+-----+ - * | E | X | - * +---------------+-----+ - * ``` - * - * @param newView The view to add. - * @param size Either a fixed size, or a dynamic {@link Sizing} strategy. - * @param referenceView Another view to place this new view next to. - * @param direction The direction the new view should be placed next to the reference view. - */ - addView(newView: T, size: number | Sizing, referenceView: T, direction: Direction): void { - if (this.views.has(newView)) { - throw new Error('Can\'t add same view twice'); - } - - const orientation = getDirectionOrientation(direction); - - if (this.views.size === 1 && this.orientation !== orientation) { - this.orientation = orientation; - } - - const referenceLocation = this.getViewLocation(referenceView); - const location = getRelativeLocation(this.gridview.orientation, referenceLocation, direction); - - let viewSize: number | GridViewSizing; - - if (typeof size === 'number') { - viewSize = size; - } else if (size.type === 'split') { - const [, index] = tail(referenceLocation); - viewSize = GridViewSizing.Split(index); - } else if (size.type === 'distribute') { - viewSize = GridViewSizing.Distribute; - } else if (size.type === 'auto') { - const [, index] = tail(referenceLocation); - viewSize = GridViewSizing.Auto(index); - } else { - viewSize = size; - } - - this._addView(newView, viewSize, location); - } - - private addViewAt(newView: T, size: number | DistributeSizing | InvisibleSizing, location: GridLocation): void { - if (this.views.has(newView)) { - throw new Error('Can\'t add same view twice'); - } - - let viewSize: number | GridViewSizing; - - if (typeof size === 'number') { - viewSize = size; - } else if (size.type === 'distribute') { - viewSize = GridViewSizing.Distribute; - } else { - viewSize = size; - } - - this._addView(newView, viewSize, location); - } - - protected _addView(newView: T, size: number | GridViewSizing, location: GridLocation): void { - this.views.set(newView, newView.element); - this.gridview.addView(newView, size, location); - } - - /** - * Remove a {@link IView view} from this {@link Grid}. - * - * @param view The {@link IView view} to remove. - * @param sizing Whether to distribute other {@link IView view}'s sizes. - */ - removeView(view: T, sizing?: Sizing): void { - if (this.views.size === 1) { - throw new Error('Can\'t remove last view'); - } - - const location = this.getViewLocation(view); - - let gridViewSizing: DistributeSizing | SplitViewAutoSizing | undefined; - - if (sizing?.type === 'distribute') { - gridViewSizing = GridViewSizing.Distribute; - } else if (sizing?.type === 'auto') { - const index = location[location.length - 1]; - gridViewSizing = GridViewSizing.Auto(index === 0 ? 1 : index - 1); - } - - this.gridview.removeView(location, gridViewSizing); - this.views.delete(view); - } - - /** - * Move a {@link IView view} to another location in the grid. - * - * @remarks See {@link Grid.addView}. - * - * @param view The {@link IView view} to move. - * @param sizing Either a fixed size, or a dynamic {@link Sizing} strategy. - * @param referenceView Another view to place the view next to. - * @param direction The direction the view should be placed next to the reference view. - */ - moveView(view: T, sizing: number | Sizing, referenceView: T, direction: Direction): void { - const sourceLocation = this.getViewLocation(view); - const [sourceParentLocation, from] = tail(sourceLocation); - - const referenceLocation = this.getViewLocation(referenceView); - const targetLocation = getRelativeLocation(this.gridview.orientation, referenceLocation, direction); - const [targetParentLocation, to] = tail(targetLocation); - - if (equals(sourceParentLocation, targetParentLocation)) { - this.gridview.moveView(sourceParentLocation, from, to); - } else { - this.removeView(view, typeof sizing === 'number' ? undefined : sizing); - this.addView(view, sizing, referenceView, direction); - } - } - - /** - * Move a {@link IView view} to another location in the grid. - * - * @remarks Internal method, do not use without knowing what you're doing. - * @remarks See {@link GridView.moveView}. - * - * @param view The {@link IView view} to move. - * @param location The {@link GridLocation location} to insert the view on. - */ - moveViewTo(view: T, location: GridLocation): void { - const sourceLocation = this.getViewLocation(view); - const [sourceParentLocation, from] = tail(sourceLocation); - const [targetParentLocation, to] = tail(location); - - if (equals(sourceParentLocation, targetParentLocation)) { - this.gridview.moveView(sourceParentLocation, from, to); - } else { - const size = this.getViewSize(view); - const orientation = getLocationOrientation(this.gridview.orientation, sourceLocation); - const cachedViewSize = this.getViewCachedVisibleSize(view); - const sizing = typeof cachedViewSize === 'undefined' - ? (orientation === Orientation.HORIZONTAL ? size.width : size.height) - : Sizing.Invisible(cachedViewSize); - - this.removeView(view); - this.addViewAt(view, sizing, location); - } - } - - /** - * Swap two {@link IView views} within the {@link Grid}. - * - * @param from One {@link IView view}. - * @param to Another {@link IView view}. - */ - swapViews(from: T, to: T): void { - const fromLocation = this.getViewLocation(from); - const toLocation = this.getViewLocation(to); - return this.gridview.swapViews(fromLocation, toLocation); - } - - /** - * Resize a {@link IView view}. - * - * @param view The {@link IView view} to resize. - * @param size The size the view should be. - */ - resizeView(view: T, size: IViewSize): void { - const location = this.getViewLocation(view); - return this.gridview.resizeView(location, size); - } - - /** - * Returns whether all other {@link IView views} are at their minimum size. - * - * @param view The reference {@link IView view}. - */ - isViewExpanded(view: T): boolean { - const location = this.getViewLocation(view); - return this.gridview.isViewExpanded(location); - } - - /** - * Returns whether the {@link IView view} is maximized. - * - * @param view The reference {@link IView view}. - */ - isViewMaximized(view: T): boolean { - const location = this.getViewLocation(view); - return this.gridview.isViewMaximized(location); - } - - /** - * Returns whether the {@link IView view} is maximized. - * - * @param view The reference {@link IView view}. - */ - hasMaximizedView(): boolean { - return this.gridview.hasMaximizedView(); - } - - /** - * Get the size of a {@link IView view}. - * - * @param view The {@link IView view}. Provide `undefined` to get the size - * of the grid itself. - */ - getViewSize(view?: T): IViewSize { - if (!view) { - return this.gridview.getViewSize(); - } - - const location = this.getViewLocation(view); - return this.gridview.getViewSize(location); - } - - /** - * Get the cached visible size of a {@link IView view}. This was the size - * of the view at the moment it last became hidden. - * - * @param view The {@link IView view}. - */ - getViewCachedVisibleSize(view: T): number | undefined { - const location = this.getViewLocation(view); - return this.gridview.getViewCachedVisibleSize(location); - } - - /** - * Maximizes the specified view and hides all other views. - * @param view The view to maximize. - */ - maximizeView(view: T) { - if (this.views.size < 2) { - throw new Error('At least two views are required to maximize a view'); - } - const location = this.getViewLocation(view); - this.gridview.maximizeView(location); - } - - exitMaximizedView(): void { - this.gridview.exitMaximizedView(); - } - - /** - * Expand the size of a {@link IView view} by collapsing all other views - * to their minimum sizes. - * - * @param view The {@link IView view}. - */ - expandView(view: T): void { - const location = this.getViewLocation(view); - this.gridview.expandView(location); - } - - /** - * Distribute the size among all {@link IView views} within the entire - * grid or within a single {@link SplitView}. - */ - distributeViewSizes(): void { - this.gridview.distributeViewSizes(); - } - - /** - * Returns whether a {@link IView view} is visible. - * - * @param view The {@link IView view}. - */ - isViewVisible(view: T): boolean { - const location = this.getViewLocation(view); - return this.gridview.isViewVisible(location); - } - - /** - * Set the visibility state of a {@link IView view}. - * - * @param view The {@link IView view}. - */ - setViewVisible(view: T, visible: boolean): void { - const location = this.getViewLocation(view); - this.gridview.setViewVisible(location, visible); - } - - /** - * Returns a descriptor for the entire grid. - */ - getViews(): GridBranchNode { - return this.gridview.getView() as GridBranchNode; - } - - /** - * Utility method to return the collection all views which intersect - * a view's edge. - * - * @param view The {@link IView view}. - * @param direction Which direction edge to be considered. - * @param wrap Whether the grid wraps around (from right to left, from bottom to top). - */ - getNeighborViews(view: T, direction: Direction, wrap: boolean = false): T[] { - if (!this.didLayout) { - throw new Error('Can\'t call getNeighborViews before first layout'); - } - - const location = this.getViewLocation(view); - const root = this.getViews(); - const node = getGridNode(root, location); - let boundary = getBoxBoundary(node.box, direction); - - if (wrap) { - if (direction === Direction.Up && node.box.top === 0) { - boundary = { offset: root.box.top + root.box.height, range: boundary.range }; - } else if (direction === Direction.Right && node.box.left + node.box.width === root.box.width) { - boundary = { offset: 0, range: boundary.range }; - } else if (direction === Direction.Down && node.box.top + node.box.height === root.box.height) { - boundary = { offset: 0, range: boundary.range }; - } else if (direction === Direction.Left && node.box.left === 0) { - boundary = { offset: root.box.left + root.box.width, range: boundary.range }; - } - } - - return findAdjacentBoxLeafNodes(root, oppositeDirection(direction), boundary) - .map(node => node.view); - } - - private getViewLocation(view: T): GridLocation { - const element = this.views.get(view); - - if (!element) { - throw new Error('View not found'); - } - - return getGridLocation(element); - } - - private onDidSashReset(location: GridLocation): void { - const resizeToPreferredSize = (location: GridLocation): boolean => { - const node = this.gridview.getView(location) as GridNode; - - if (isGridBranchNode(node)) { - return false; - } - - const direction = getLocationOrientation(this.orientation, location); - const size = direction === Orientation.HORIZONTAL ? node.view.preferredWidth : node.view.preferredHeight; - - if (typeof size !== 'number') { - return false; - } - - const viewSize = direction === Orientation.HORIZONTAL ? { width: Math.round(size) } : { height: Math.round(size) }; - this.gridview.resizeView(location, viewSize); - return true; - }; - - if (resizeToPreferredSize(location)) { - return; - } - - const [parentLocation, index] = tail(location); - - if (resizeToPreferredSize([...parentLocation, index + 1])) { - return; - } - - this.gridview.distributeViewSizes(parentLocation); - } -} - -export interface ISerializableView extends IView { - toJSON(): object; -} - -export interface IViewDeserializer { - fromJSON(json: any): T; -} - -export interface ISerializedLeafNode { - type: 'leaf'; - data: any; - size: number; - visible?: boolean; - maximized?: boolean; -} - -export interface ISerializedBranchNode { - type: 'branch'; - data: ISerializedNode[]; - size: number; - visible?: boolean; -} - -export type ISerializedNode = ISerializedLeafNode | ISerializedBranchNode; - -export interface ISerializedGrid { - root: ISerializedNode; - orientation: Orientation; - width: number; - height: number; -} - -/** - * A {@link Grid} which can serialize itself. - */ -export class SerializableGrid extends Grid { - - private static serializeNode(node: GridNode, orientation: Orientation): ISerializedNode { - const size = orientation === Orientation.VERTICAL ? node.box.width : node.box.height; - - if (!isGridBranchNode(node)) { - const serializedLeafNode: ISerializedLeafNode = { type: 'leaf', data: node.view.toJSON(), size }; - - if (typeof node.cachedVisibleSize === 'number') { - serializedLeafNode.size = node.cachedVisibleSize; - serializedLeafNode.visible = false; - } else if (node.maximized) { - serializedLeafNode.maximized = true; - } - - return serializedLeafNode; - } - - const data = node.children.map(c => SerializableGrid.serializeNode(c, orthogonal(orientation))); - if (data.some(c => c.visible !== false)) { - return { type: 'branch', data: data, size }; - } - return { type: 'branch', data: data, size, visible: false }; - } - - /** - * Construct a new {@link SerializableGrid} from a JSON object. - * - * @param json The JSON object. - * @param deserializer A deserializer which can revive each view. - * @returns A new {@link SerializableGrid} instance. - */ - static deserialize(json: ISerializedGrid, deserializer: IViewDeserializer, options: IGridOptions = {}): SerializableGrid { - if (typeof json.orientation !== 'number') { - throw new Error('Invalid JSON: \'orientation\' property must be a number.'); - } else if (typeof json.width !== 'number') { - throw new Error('Invalid JSON: \'width\' property must be a number.'); - } else if (typeof json.height !== 'number') { - throw new Error('Invalid JSON: \'height\' property must be a number.'); - } - - const gridview = GridView.deserialize(json, deserializer, options); - const result = new SerializableGrid(gridview, options); - - return result; - } - - /** - * Construct a new {@link SerializableGrid} from a grid descriptor. - * - * @param gridDescriptor A grid descriptor in which leaf nodes point to actual views. - * @returns A new {@link SerializableGrid} instance. - */ - static from(gridDescriptor: GridDescriptor, options: IGridOptions = {}): SerializableGrid { - return SerializableGrid.deserialize(createSerializedGrid(gridDescriptor), { fromJSON: view => view }, options); - } - - /** - * Useful information in order to proportionally restore view sizes - * upon the very first layout call. - */ - private initialLayoutContext: boolean = true; - - /** - * Serialize this grid into a JSON object. - */ - serialize(): ISerializedGrid { - return { - root: SerializableGrid.serializeNode(this.getViews(), this.orientation), - orientation: this.orientation, - width: this.width, - height: this.height - }; - } - - override layout(width: number, height: number, top: number = 0, left: number = 0): void { - super.layout(width, height, top, left); - - if (this.initialLayoutContext) { - this.initialLayoutContext = false; - this.gridview.trySet2x2(); - } - } -} - -export type GridLeafNodeDescriptor = { size?: number; data?: any }; -export type GridBranchNodeDescriptor = { size?: number; groups: GridNodeDescriptor[] }; -export type GridNodeDescriptor = GridBranchNodeDescriptor | GridLeafNodeDescriptor; -export type GridDescriptor = { orientation: Orientation } & GridBranchNodeDescriptor; - -function isGridBranchNodeDescriptor(nodeDescriptor: GridNodeDescriptor): nodeDescriptor is GridBranchNodeDescriptor { - return !!(nodeDescriptor as GridBranchNodeDescriptor).groups; -} - -export function sanitizeGridNodeDescriptor(nodeDescriptor: GridNodeDescriptor, rootNode: boolean): void { - if (!rootNode && (nodeDescriptor as any).groups && (nodeDescriptor as any).groups.length <= 1) { - (nodeDescriptor as any).groups = undefined; - } - - if (!isGridBranchNodeDescriptor(nodeDescriptor)) { - return; - } - - let totalDefinedSize = 0; - let totalDefinedSizeCount = 0; - - for (const child of nodeDescriptor.groups) { - sanitizeGridNodeDescriptor(child, false); - - if (child.size) { - totalDefinedSize += child.size; - totalDefinedSizeCount++; - } - } - - const totalUndefinedSize = totalDefinedSizeCount > 0 ? totalDefinedSize : 1; - const totalUndefinedSizeCount = nodeDescriptor.groups.length - totalDefinedSizeCount; - const eachUndefinedSize = totalUndefinedSize / totalUndefinedSizeCount; - - for (const child of nodeDescriptor.groups) { - if (!child.size) { - child.size = eachUndefinedSize; - } - } -} - -function createSerializedNode(nodeDescriptor: GridNodeDescriptor): ISerializedNode { - if (isGridBranchNodeDescriptor(nodeDescriptor)) { - return { type: 'branch', data: nodeDescriptor.groups.map(c => createSerializedNode(c)), size: nodeDescriptor.size! }; - } else { - return { type: 'leaf', data: nodeDescriptor.data, size: nodeDescriptor.size! }; - } -} - -function getDimensions(node: ISerializedNode, orientation: Orientation): { width?: number; height?: number } { - if (node.type === 'branch') { - const childrenDimensions = node.data.map(c => getDimensions(c, orthogonal(orientation))); - - if (orientation === Orientation.VERTICAL) { - const width = node.size || (childrenDimensions.length === 0 ? undefined : Math.max(...childrenDimensions.map(d => d.width || 0))); - const height = childrenDimensions.length === 0 ? undefined : childrenDimensions.reduce((r, d) => r + (d.height || 0), 0); - return { width, height }; - } else { - const width = childrenDimensions.length === 0 ? undefined : childrenDimensions.reduce((r, d) => r + (d.width || 0), 0); - const height = node.size || (childrenDimensions.length === 0 ? undefined : Math.max(...childrenDimensions.map(d => d.height || 0))); - return { width, height }; - } - } else { - const width = orientation === Orientation.VERTICAL ? node.size : undefined; - const height = orientation === Orientation.VERTICAL ? undefined : node.size; - return { width, height }; - } -} - -/** - * Creates a new JSON object from a {@link GridDescriptor}, which can - * be deserialized by {@link SerializableGrid.deserialize}. - */ -export function createSerializedGrid(gridDescriptor: GridDescriptor): ISerializedGrid { - sanitizeGridNodeDescriptor(gridDescriptor, true); - - const root = createSerializedNode(gridDescriptor); - const { width, height } = getDimensions(root, gridDescriptor.orientation); - - return { - root, - orientation: gridDescriptor.orientation, - width: width || 1, - height: height || 1 - }; -} diff --git a/src/vs/base/browser/ui/grid/gridview.css b/src/vs/base/browser/ui/grid/gridview.css deleted file mode 100644 index d38154de9c..0000000000 --- a/src/vs/base/browser/ui/grid/gridview.css +++ /dev/null @@ -1,16 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -.monaco-grid-view { - position: relative; - overflow: hidden; - width: 100%; - height: 100%; -} - -.monaco-grid-branch-node { - width: 100%; - height: 100%; -} diff --git a/src/vs/base/browser/ui/grid/gridview.ts b/src/vs/base/browser/ui/grid/gridview.ts deleted file mode 100644 index 0d2179a66a..0000000000 --- a/src/vs/base/browser/ui/grid/gridview.ts +++ /dev/null @@ -1,1836 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { $ } from 'vs/base/browser/dom'; -import { IBoundarySashes, Orientation, Sash } from 'vs/base/browser/ui/sash/sash'; -import { DistributeSizing, ISplitViewStyles, IView as ISplitView, LayoutPriority, Sizing, AutoSizing, SplitView } from 'vs/base/browser/ui/splitview/splitview'; -import { equals as arrayEquals, tail2 as tail } from 'vs/base/common/arrays'; -import { Color } from 'vs/base/common/color'; -import { Emitter, Event, Relay } from 'vs/base/common/event'; -import { Disposable, DisposableStore, IDisposable, toDisposable } from 'vs/base/common/lifecycle'; -import { rot } from 'vs/base/common/numbers'; -import { isUndefined } from 'vs/base/common/types'; -// import 'vs/css!./gridview'; - -export { Orientation } from 'vs/base/browser/ui/sash/sash'; -export { LayoutPriority, Sizing } from 'vs/base/browser/ui/splitview/splitview'; - -export interface IGridViewStyles extends ISplitViewStyles { } - -const defaultStyles: IGridViewStyles = { - separatorBorder: Color.transparent -}; - -export interface IViewSize { - readonly width: number; - readonly height: number; -} - -interface IRelativeBoundarySashes { - readonly start?: Sash; - readonly end?: Sash; - readonly orthogonalStart?: Sash; - readonly orthogonalEnd?: Sash; -} - -/** - * The interface to implement for views within a {@link GridView}. - */ -export interface IView { - - /** - * The DOM element for this view. - */ - readonly element: HTMLElement; - - /** - * A minimum width for this view. - * - * @remarks If none, set it to `0`. - */ - readonly minimumWidth: number; - - /** - * A minimum width for this view. - * - * @remarks If none, set it to `Number.POSITIVE_INFINITY`. - */ - readonly maximumWidth: number; - - /** - * A minimum height for this view. - * - * @remarks If none, set it to `0`. - */ - readonly minimumHeight: number; - - /** - * A minimum height for this view. - * - * @remarks If none, set it to `Number.POSITIVE_INFINITY`. - */ - readonly maximumHeight: number; - - /** - * The priority of the view when the {@link GridView} layout algorithm - * runs. Views with higher priority will be resized first. - * - * @remarks Only used when `proportionalLayout` is false. - */ - readonly priority?: LayoutPriority; - - /** - * If the {@link GridView} supports proportional layout, - * this property allows for finer control over the proportional layout algorithm, per view. - * - * @defaultValue `true` - */ - readonly proportionalLayout?: boolean; - - /** - * Whether the view will snap whenever the user reaches its minimum size or - * attempts to grow it beyond the minimum size. - * - * @defaultValue `false` - */ - readonly snap?: boolean; - - /** - * View instances are supposed to fire this event whenever any of the constraint - * properties have changed: - * - * - {@link IView.minimumWidth} - * - {@link IView.maximumWidth} - * - {@link IView.minimumHeight} - * - {@link IView.maximumHeight} - * - {@link IView.priority} - * - {@link IView.snap} - * - * The {@link GridView} will relayout whenever that happens. The event can - * optionally emit the view's preferred size for that relayout. - */ - readonly onDidChange: Event; - - /** - * This will be called by the {@link GridView} during layout. A view meant to - * pass along the layout information down to its descendants. - */ - layout(width: number, height: number, top: number, left: number): void; - - /** - * This will be called by the {@link GridView} whenever this view is made - * visible or hidden. - * - * @param visible Whether the view becomes visible. - */ - setVisible?(visible: boolean): void; - - /** - * This will be called by the {@link GridView} whenever this view is on - * an edge of the grid and the grid's - * {@link GridView.boundarySashes boundary sashes} change. - */ - setBoundarySashes?(sashes: IBoundarySashes): void; -} - -export interface ISerializableView extends IView { - toJSON(): object; -} - -export interface IViewDeserializer { - fromJSON(json: any): T; -} - -export interface ISerializedLeafNode { - type: 'leaf'; - data: any; - size: number; - visible?: boolean; - maximized?: boolean; -} - -export interface ISerializedBranchNode { - type: 'branch'; - data: ISerializedNode[]; - size: number; - visible?: boolean; -} - -export type ISerializedNode = ISerializedLeafNode | ISerializedBranchNode; - -export interface ISerializedGridView { - root: ISerializedNode; - orientation: Orientation; - width: number; - height: number; -} - -export function orthogonal(orientation: Orientation): Orientation { - return orientation === Orientation.VERTICAL ? Orientation.HORIZONTAL : Orientation.VERTICAL; -} - -export interface Box { - readonly top: number; - readonly left: number; - readonly width: number; - readonly height: number; -} - -export interface GridLeafNode { - readonly view: IView; - readonly box: Box; - readonly cachedVisibleSize: number | undefined; - readonly maximized: boolean; -} - -export interface GridBranchNode { - readonly children: GridNode[]; - readonly box: Box; -} - -export type GridNode = GridLeafNode | GridBranchNode; - -export function isGridBranchNode(node: GridNode): node is GridBranchNode { - return !!(node as any).children; -} - -class LayoutController { - constructor(public isLayoutEnabled: boolean) { } -} - -export interface IGridViewOptions { - - /** - * Styles overriding the {@link defaultStyles default ones}. - */ - readonly styles?: IGridViewStyles; - - /** - * Resize each view proportionally when resizing the {@link GridView}. - * - * @defaultValue `true` - */ - readonly proportionalLayout?: boolean; // default true -} - -interface ILayoutContext { - readonly orthogonalSize: number; - readonly absoluteOffset: number; - readonly absoluteOrthogonalOffset: number; - readonly absoluteSize: number; - readonly absoluteOrthogonalSize: number; -} - -function toAbsoluteBoundarySashes(sashes: IRelativeBoundarySashes, orientation: Orientation): IBoundarySashes { - if (orientation === Orientation.HORIZONTAL) { - return { left: sashes.start, right: sashes.end, top: sashes.orthogonalStart, bottom: sashes.orthogonalEnd }; - } else { - return { top: sashes.start, bottom: sashes.end, left: sashes.orthogonalStart, right: sashes.orthogonalEnd }; - } -} - -function fromAbsoluteBoundarySashes(sashes: IBoundarySashes, orientation: Orientation): IRelativeBoundarySashes { - if (orientation === Orientation.HORIZONTAL) { - return { start: sashes.left, end: sashes.right, orthogonalStart: sashes.top, orthogonalEnd: sashes.bottom }; - } else { - return { start: sashes.top, end: sashes.bottom, orthogonalStart: sashes.left, orthogonalEnd: sashes.right }; - } -} - -function validateIndex(index: number, numChildren: number): number { - if (Math.abs(index) > numChildren) { - throw new Error('Invalid index'); - } - - return rot(index, numChildren + 1); -} - -class BranchNode implements ISplitView, IDisposable { - - readonly element: HTMLElement; - readonly children: Node[] = []; - private splitview: SplitView; - - private _size: number; - get size(): number { return this._size; } - - private _orthogonalSize: number; - get orthogonalSize(): number { return this._orthogonalSize; } - - private _absoluteOffset: number = 0; - get absoluteOffset(): number { return this._absoluteOffset; } - - private _absoluteOrthogonalOffset: number = 0; - get absoluteOrthogonalOffset(): number { return this._absoluteOrthogonalOffset; } - - private absoluteOrthogonalSize: number = 0; - - private _styles: IGridViewStyles; - get styles(): IGridViewStyles { return this._styles; } - - get width(): number { - return this.orientation === Orientation.HORIZONTAL ? this.size : this.orthogonalSize; - } - - get height(): number { - return this.orientation === Orientation.HORIZONTAL ? this.orthogonalSize : this.size; - } - - get top(): number { - return this.orientation === Orientation.HORIZONTAL ? this._absoluteOffset : this._absoluteOrthogonalOffset; - } - - get left(): number { - return this.orientation === Orientation.HORIZONTAL ? this._absoluteOrthogonalOffset : this._absoluteOffset; - } - - get minimumSize(): number { - return this.children.length === 0 ? 0 : Math.max(...this.children.map((c, index) => this.splitview.isViewVisible(index) ? c.minimumOrthogonalSize : 0)); - } - - get maximumSize(): number { - return Math.min(...this.children.map((c, index) => this.splitview.isViewVisible(index) ? c.maximumOrthogonalSize : Number.POSITIVE_INFINITY)); - } - - get priority(): LayoutPriority { - if (this.children.length === 0) { - return LayoutPriority.Normal; - } - - const priorities = this.children.map(c => typeof c.priority === 'undefined' ? LayoutPriority.Normal : c.priority); - - if (priorities.some(p => p === LayoutPriority.High)) { - return LayoutPriority.High; - } else if (priorities.some(p => p === LayoutPriority.Low)) { - return LayoutPriority.Low; - } - - return LayoutPriority.Normal; - } - - get proportionalLayout(): boolean { - if (this.children.length === 0) { - return true; - } - - return this.children.every(c => c.proportionalLayout); - } - - get minimumOrthogonalSize(): number { - return this.splitview.minimumSize; - } - - get maximumOrthogonalSize(): number { - return this.splitview.maximumSize; - } - - get minimumWidth(): number { - return this.orientation === Orientation.HORIZONTAL ? this.minimumOrthogonalSize : this.minimumSize; - } - - get minimumHeight(): number { - return this.orientation === Orientation.HORIZONTAL ? this.minimumSize : this.minimumOrthogonalSize; - } - - get maximumWidth(): number { - return this.orientation === Orientation.HORIZONTAL ? this.maximumOrthogonalSize : this.maximumSize; - } - - get maximumHeight(): number { - return this.orientation === Orientation.HORIZONTAL ? this.maximumSize : this.maximumOrthogonalSize; - } - - private readonly _onDidChange = new Emitter(); - readonly onDidChange: Event = this._onDidChange.event; - - private readonly _onDidVisibilityChange = new Emitter(); - readonly onDidVisibilityChange: Event = this._onDidVisibilityChange.event; - private readonly childrenVisibilityChangeDisposable: DisposableStore = new DisposableStore(); - - private _onDidScroll = new Emitter(); - private onDidScrollDisposable: IDisposable = Disposable.None; - readonly onDidScroll: Event = this._onDidScroll.event; - - private childrenChangeDisposable: IDisposable = Disposable.None; - - private readonly _onDidSashReset = new Emitter(); - readonly onDidSashReset: Event = this._onDidSashReset.event; - private splitviewSashResetDisposable: IDisposable = Disposable.None; - private childrenSashResetDisposable: IDisposable = Disposable.None; - - private _boundarySashes: IRelativeBoundarySashes = {}; - get boundarySashes(): IRelativeBoundarySashes { return this._boundarySashes; } - set boundarySashes(boundarySashes: IRelativeBoundarySashes) { - if (this._boundarySashes.start === boundarySashes.start - && this._boundarySashes.end === boundarySashes.end - && this._boundarySashes.orthogonalStart === boundarySashes.orthogonalStart - && this._boundarySashes.orthogonalEnd === boundarySashes.orthogonalEnd) { - return; - } - - this._boundarySashes = boundarySashes; - - this.splitview.orthogonalStartSash = boundarySashes.orthogonalStart; - this.splitview.orthogonalEndSash = boundarySashes.orthogonalEnd; - - for (let index = 0; index < this.children.length; index++) { - const child = this.children[index]; - const first = index === 0; - const last = index === this.children.length - 1; - - child.boundarySashes = { - start: boundarySashes.orthogonalStart, - end: boundarySashes.orthogonalEnd, - orthogonalStart: first ? boundarySashes.start : child.boundarySashes.orthogonalStart, - orthogonalEnd: last ? boundarySashes.end : child.boundarySashes.orthogonalEnd, - }; - } - } - - private _edgeSnapping = false; - get edgeSnapping(): boolean { return this._edgeSnapping; } - set edgeSnapping(edgeSnapping: boolean) { - if (this._edgeSnapping === edgeSnapping) { - return; - } - - this._edgeSnapping = edgeSnapping; - - for (const child of this.children) { - if (child instanceof BranchNode) { - child.edgeSnapping = edgeSnapping; - } - } - - this.updateSplitviewEdgeSnappingEnablement(); - } - - constructor( - readonly orientation: Orientation, - readonly layoutController: LayoutController, - styles: IGridViewStyles, - readonly splitviewProportionalLayout: boolean, - size: number = 0, - orthogonalSize: number = 0, - edgeSnapping: boolean = false, - childDescriptors?: INodeDescriptor[] - ) { - this._styles = styles; - this._size = size; - this._orthogonalSize = orthogonalSize; - - this.element = $('.monaco-grid-branch-node'); - - if (!childDescriptors) { - // Normal behavior, we have no children yet, just set up the splitview - this.splitview = new SplitView(this.element, { orientation, styles, proportionalLayout: splitviewProportionalLayout }); - this.splitview.layout(size, { orthogonalSize, absoluteOffset: 0, absoluteOrthogonalOffset: 0, absoluteSize: size, absoluteOrthogonalSize: orthogonalSize }); - } else { - // Reconstruction behavior, we want to reconstruct a splitview - const descriptor = { - views: childDescriptors.map(childDescriptor => { - return { - view: childDescriptor.node, - size: childDescriptor.node.size, - visible: childDescriptor.visible !== false - }; - }), - size: this.orthogonalSize - }; - - const options = { proportionalLayout: splitviewProportionalLayout, orientation, styles }; - - this.children = childDescriptors.map(c => c.node); - this.splitview = new SplitView(this.element, { ...options, descriptor }); - - this.children.forEach((node, index) => { - const first = index === 0; - const last = index === this.children.length; - - node.boundarySashes = { - start: this.boundarySashes.orthogonalStart, - end: this.boundarySashes.orthogonalEnd, - orthogonalStart: first ? this.boundarySashes.start : this.splitview.sashes[index - 1], - orthogonalEnd: last ? this.boundarySashes.end : this.splitview.sashes[index], - }; - }); - } - - const onDidSashReset = Event.map(this.splitview.onDidSashReset, i => [i]); - this.splitviewSashResetDisposable = onDidSashReset(this._onDidSashReset.fire, this._onDidSashReset); - - this.updateChildrenEvents(); - } - - style(styles: IGridViewStyles): void { - this._styles = styles; - this.splitview.style(styles); - - for (const child of this.children) { - if (child instanceof BranchNode) { - child.style(styles); - } - } - } - - layout(size: number, offset: number, ctx: ILayoutContext | undefined): void { - if (!this.layoutController.isLayoutEnabled) { - return; - } - - if (typeof ctx === 'undefined') { - throw new Error('Invalid state'); - } - - // branch nodes should flip the normal/orthogonal directions - this._size = ctx.orthogonalSize; - this._orthogonalSize = size; - this._absoluteOffset = ctx.absoluteOffset + offset; - this._absoluteOrthogonalOffset = ctx.absoluteOrthogonalOffset; - this.absoluteOrthogonalSize = ctx.absoluteOrthogonalSize; - - this.splitview.layout(ctx.orthogonalSize, { - orthogonalSize: size, - absoluteOffset: this._absoluteOrthogonalOffset, - absoluteOrthogonalOffset: this._absoluteOffset, - absoluteSize: ctx.absoluteOrthogonalSize, - absoluteOrthogonalSize: ctx.absoluteSize - }); - - this.updateSplitviewEdgeSnappingEnablement(); - } - - setVisible(visible: boolean): void { - for (const child of this.children) { - child.setVisible(visible); - } - } - - addChild(node: Node, size: number | Sizing, index: number, skipLayout?: boolean): void { - index = validateIndex(index, this.children.length); - - this.splitview.addView(node, size, index, skipLayout); - this.children.splice(index, 0, node); - - this.updateBoundarySashes(); - this.onDidChildrenChange(); - } - - removeChild(index: number, sizing?: Sizing): Node { - index = validateIndex(index, this.children.length); - - const result = this.splitview.removeView(index, sizing); - this.children.splice(index, 1); - - this.updateBoundarySashes(); - this.onDidChildrenChange(); - - return result; - } - - removeAllChildren(): Node[] { - const result = this.splitview.removeAllViews(); - - this.children.splice(0, this.children.length); - - this.updateBoundarySashes(); - this.onDidChildrenChange(); - - return result; - } - - moveChild(from: number, to: number): void { - from = validateIndex(from, this.children.length); - to = validateIndex(to, this.children.length); - - if (from === to) { - return; - } - - if (from < to) { - to -= 1; - } - - this.splitview.moveView(from, to); - this.children.splice(to, 0, this.children.splice(from, 1)[0]); - - this.updateBoundarySashes(); - this.onDidChildrenChange(); - } - - swapChildren(from: number, to: number): void { - from = validateIndex(from, this.children.length); - to = validateIndex(to, this.children.length); - - if (from === to) { - return; - } - - this.splitview.swapViews(from, to); - - // swap boundary sashes - [this.children[from].boundarySashes, this.children[to].boundarySashes] - = [this.children[from].boundarySashes, this.children[to].boundarySashes]; - - // swap children - [this.children[from], this.children[to]] = [this.children[to], this.children[from]]; - - this.onDidChildrenChange(); - } - - resizeChild(index: number, size: number): void { - index = validateIndex(index, this.children.length); - - this.splitview.resizeView(index, size); - } - - isChildExpanded(index: number): boolean { - return this.splitview.isViewExpanded(index); - } - - distributeViewSizes(recursive = false): void { - this.splitview.distributeViewSizes(); - - if (recursive) { - for (const child of this.children) { - if (child instanceof BranchNode) { - child.distributeViewSizes(true); - } - } - } - } - - getChildSize(index: number): number { - index = validateIndex(index, this.children.length); - - return this.splitview.getViewSize(index); - } - - isChildVisible(index: number): boolean { - index = validateIndex(index, this.children.length); - - return this.splitview.isViewVisible(index); - } - - setChildVisible(index: number, visible: boolean): void { - index = validateIndex(index, this.children.length); - - if (this.splitview.isViewVisible(index) === visible) { - return; - } - - const wereAllChildrenHidden = this.splitview.contentSize === 0; - this.splitview.setViewVisible(index, visible); - const areAllChildrenHidden = this.splitview.contentSize === 0; - - // If all children are hidden then the parent should hide the entire splitview - // If the entire splitview is hidden then the parent should show the splitview when a child is shown - if ((visible && wereAllChildrenHidden) || (!visible && areAllChildrenHidden)) { - this._onDidVisibilityChange.fire(visible); - } - } - - getChildCachedVisibleSize(index: number): number | undefined { - index = validateIndex(index, this.children.length); - - return this.splitview.getViewCachedVisibleSize(index); - } - - private updateBoundarySashes(): void { - for (let i = 0; i < this.children.length; i++) { - this.children[i].boundarySashes = { - start: this.boundarySashes.orthogonalStart, - end: this.boundarySashes.orthogonalEnd, - orthogonalStart: i === 0 ? this.boundarySashes.start : this.splitview.sashes[i - 1], - orthogonalEnd: i === this.children.length - 1 ? this.boundarySashes.end : this.splitview.sashes[i], - }; - } - } - - private onDidChildrenChange(): void { - this.updateChildrenEvents(); - this._onDidChange.fire(undefined); - } - - private updateChildrenEvents(): void { - const onDidChildrenChange = Event.map(Event.any(...this.children.map(c => c.onDidChange)), () => undefined); - this.childrenChangeDisposable.dispose(); - this.childrenChangeDisposable = onDidChildrenChange(this._onDidChange.fire, this._onDidChange); - - const onDidChildrenSashReset = Event.any(...this.children.map((c, i) => Event.map(c.onDidSashReset, location => [i, ...location]))); - this.childrenSashResetDisposable.dispose(); - this.childrenSashResetDisposable = onDidChildrenSashReset(this._onDidSashReset.fire, this._onDidSashReset); - - const onDidScroll = Event.any(Event.signal(this.splitview.onDidScroll), ...this.children.map(c => c.onDidScroll)); - this.onDidScrollDisposable.dispose(); - this.onDidScrollDisposable = onDidScroll(this._onDidScroll.fire, this._onDidScroll); - - this.childrenVisibilityChangeDisposable.clear(); - this.children.forEach((child, index) => { - if (child instanceof BranchNode) { - this.childrenVisibilityChangeDisposable.add(child.onDidVisibilityChange((visible) => { - this.setChildVisible(index, visible); - })); - } - }); - } - - trySet2x2(other: BranchNode): IDisposable { - if (this.children.length !== 2 || other.children.length !== 2) { - return Disposable.None; - } - - if (this.getChildSize(0) !== other.getChildSize(0)) { - return Disposable.None; - } - - const [firstChild, secondChild] = this.children; - const [otherFirstChild, otherSecondChild] = other.children; - - if (!(firstChild instanceof LeafNode) || !(secondChild instanceof LeafNode)) { - return Disposable.None; - } - - if (!(otherFirstChild instanceof LeafNode) || !(otherSecondChild instanceof LeafNode)) { - return Disposable.None; - } - - if (this.orientation === Orientation.VERTICAL) { - secondChild.linkedWidthNode = otherFirstChild.linkedHeightNode = firstChild; - firstChild.linkedWidthNode = otherSecondChild.linkedHeightNode = secondChild; - otherSecondChild.linkedWidthNode = firstChild.linkedHeightNode = otherFirstChild; - otherFirstChild.linkedWidthNode = secondChild.linkedHeightNode = otherSecondChild; - } else { - otherFirstChild.linkedWidthNode = secondChild.linkedHeightNode = firstChild; - otherSecondChild.linkedWidthNode = firstChild.linkedHeightNode = secondChild; - firstChild.linkedWidthNode = otherSecondChild.linkedHeightNode = otherFirstChild; - secondChild.linkedWidthNode = otherFirstChild.linkedHeightNode = otherSecondChild; - } - - const mySash = this.splitview.sashes[0]; - const otherSash = other.splitview.sashes[0]; - mySash.linkedSash = otherSash; - otherSash.linkedSash = mySash; - - this._onDidChange.fire(undefined); - other._onDidChange.fire(undefined); - - return toDisposable(() => { - mySash.linkedSash = otherSash.linkedSash = undefined; - firstChild.linkedHeightNode = firstChild.linkedWidthNode = undefined; - secondChild.linkedHeightNode = secondChild.linkedWidthNode = undefined; - otherFirstChild.linkedHeightNode = otherFirstChild.linkedWidthNode = undefined; - otherSecondChild.linkedHeightNode = otherSecondChild.linkedWidthNode = undefined; - }); - } - - private updateSplitviewEdgeSnappingEnablement(): void { - this.splitview.startSnappingEnabled = this._edgeSnapping || this._absoluteOrthogonalOffset > 0; - this.splitview.endSnappingEnabled = this._edgeSnapping || this._absoluteOrthogonalOffset + this._size < this.absoluteOrthogonalSize; - } - - dispose(): void { - for (const child of this.children) { - child.dispose(); - } - - this._onDidChange.dispose(); - this._onDidSashReset.dispose(); - this._onDidVisibilityChange.dispose(); - - this.childrenVisibilityChangeDisposable.dispose(); - this.splitviewSashResetDisposable.dispose(); - this.childrenSashResetDisposable.dispose(); - this.childrenChangeDisposable.dispose(); - this.onDidScrollDisposable.dispose(); - this.splitview.dispose(); - } -} - -/** - * Creates a latched event that avoids being fired when the view - * constraints do not change at all. - */ -function createLatchedOnDidChangeViewEvent(view: IView): Event { - const [onDidChangeViewConstraints, onDidSetViewSize] = Event.split(view.onDidChange, isUndefined); - - return Event.any( - onDidSetViewSize, - Event.map( - Event.latch( - Event.map(onDidChangeViewConstraints, _ => ([view.minimumWidth, view.maximumWidth, view.minimumHeight, view.maximumHeight])), - arrayEquals - ), - _ => undefined - ) - ); -} - -class LeafNode implements ISplitView, IDisposable { - - private _size: number = 0; - get size(): number { return this._size; } - - private _orthogonalSize: number; - get orthogonalSize(): number { return this._orthogonalSize; } - - private absoluteOffset: number = 0; - private absoluteOrthogonalOffset: number = 0; - - readonly onDidScroll: Event = Event.None; - readonly onDidSashReset: Event = Event.None; - - private _onDidLinkedWidthNodeChange = new Relay(); - private _linkedWidthNode: LeafNode | undefined = undefined; - get linkedWidthNode(): LeafNode | undefined { return this._linkedWidthNode; } - set linkedWidthNode(node: LeafNode | undefined) { - this._onDidLinkedWidthNodeChange.input = node ? node._onDidViewChange : Event.None; - this._linkedWidthNode = node; - this._onDidSetLinkedNode.fire(undefined); - } - - private _onDidLinkedHeightNodeChange = new Relay(); - private _linkedHeightNode: LeafNode | undefined = undefined; - get linkedHeightNode(): LeafNode | undefined { return this._linkedHeightNode; } - set linkedHeightNode(node: LeafNode | undefined) { - this._onDidLinkedHeightNodeChange.input = node ? node._onDidViewChange : Event.None; - this._linkedHeightNode = node; - this._onDidSetLinkedNode.fire(undefined); - } - - private readonly _onDidSetLinkedNode = new Emitter(); - private _onDidViewChange: Event; - readonly onDidChange: Event; - - private readonly disposables = new DisposableStore(); - - constructor( - readonly view: IView, - readonly orientation: Orientation, - readonly layoutController: LayoutController, - orthogonalSize: number, - size: number = 0 - ) { - this._orthogonalSize = orthogonalSize; - this._size = size; - - const onDidChange = createLatchedOnDidChangeViewEvent(view); - this._onDidViewChange = Event.map(onDidChange, e => e && (this.orientation === Orientation.VERTICAL ? e.width : e.height), this.disposables); - this.onDidChange = Event.any(this._onDidViewChange, this._onDidSetLinkedNode.event, this._onDidLinkedWidthNodeChange.event, this._onDidLinkedHeightNodeChange.event); - } - - get width(): number { - return this.orientation === Orientation.HORIZONTAL ? this.orthogonalSize : this.size; - } - - get height(): number { - return this.orientation === Orientation.HORIZONTAL ? this.size : this.orthogonalSize; - } - - get top(): number { - return this.orientation === Orientation.HORIZONTAL ? this.absoluteOffset : this.absoluteOrthogonalOffset; - } - - get left(): number { - return this.orientation === Orientation.HORIZONTAL ? this.absoluteOrthogonalOffset : this.absoluteOffset; - } - - get element(): HTMLElement { - return this.view.element; - } - - private get minimumWidth(): number { - return this.linkedWidthNode ? Math.max(this.linkedWidthNode.view.minimumWidth, this.view.minimumWidth) : this.view.minimumWidth; - } - - private get maximumWidth(): number { - return this.linkedWidthNode ? Math.min(this.linkedWidthNode.view.maximumWidth, this.view.maximumWidth) : this.view.maximumWidth; - } - - private get minimumHeight(): number { - return this.linkedHeightNode ? Math.max(this.linkedHeightNode.view.minimumHeight, this.view.minimumHeight) : this.view.minimumHeight; - } - - private get maximumHeight(): number { - return this.linkedHeightNode ? Math.min(this.linkedHeightNode.view.maximumHeight, this.view.maximumHeight) : this.view.maximumHeight; - } - - get minimumSize(): number { - return this.orientation === Orientation.HORIZONTAL ? this.minimumHeight : this.minimumWidth; - } - - get maximumSize(): number { - return this.orientation === Orientation.HORIZONTAL ? this.maximumHeight : this.maximumWidth; - } - - get priority(): LayoutPriority | undefined { - return this.view.priority; - } - - get proportionalLayout(): boolean { - return this.view.proportionalLayout ?? true; - } - - get snap(): boolean | undefined { - return this.view.snap; - } - - get minimumOrthogonalSize(): number { - return this.orientation === Orientation.HORIZONTAL ? this.minimumWidth : this.minimumHeight; - } - - get maximumOrthogonalSize(): number { - return this.orientation === Orientation.HORIZONTAL ? this.maximumWidth : this.maximumHeight; - } - - private _boundarySashes: IRelativeBoundarySashes = {}; - get boundarySashes(): IRelativeBoundarySashes { return this._boundarySashes; } - set boundarySashes(boundarySashes: IRelativeBoundarySashes) { - this._boundarySashes = boundarySashes; - - this.view.setBoundarySashes?.(toAbsoluteBoundarySashes(boundarySashes, this.orientation)); - } - - layout(size: number, offset: number, ctx: ILayoutContext | undefined): void { - if (!this.layoutController.isLayoutEnabled) { - return; - } - - if (typeof ctx === 'undefined') { - throw new Error('Invalid state'); - } - - this._size = size; - this._orthogonalSize = ctx.orthogonalSize; - this.absoluteOffset = ctx.absoluteOffset + offset; - this.absoluteOrthogonalOffset = ctx.absoluteOrthogonalOffset; - - this._layout(this.width, this.height, this.top, this.left); - } - - private cachedWidth: number = 0; - private cachedHeight: number = 0; - private cachedTop: number = 0; - private cachedLeft: number = 0; - - private _layout(width: number, height: number, top: number, left: number): void { - if (this.cachedWidth === width && this.cachedHeight === height && this.cachedTop === top && this.cachedLeft === left) { - return; - } - - this.cachedWidth = width; - this.cachedHeight = height; - this.cachedTop = top; - this.cachedLeft = left; - this.view.layout(width, height, top, left); - } - - setVisible(visible: boolean): void { - this.view.setVisible?.(visible); - } - - dispose(): void { - this.disposables.dispose(); - } -} - -type Node = BranchNode | LeafNode; - -export interface INodeDescriptor { - node: Node; - visible?: boolean; -} - -function flipNode(node: BranchNode, size: number, orthogonalSize: number): BranchNode; -function flipNode(node: LeafNode, size: number, orthogonalSize: number): LeafNode; -function flipNode(node: Node, size: number, orthogonalSize: number): Node; -function flipNode(node: Node, size: number, orthogonalSize: number): Node { - if (node instanceof BranchNode) { - const result = new BranchNode(orthogonal(node.orientation), node.layoutController, node.styles, node.splitviewProportionalLayout, size, orthogonalSize, node.edgeSnapping); - - let totalSize = 0; - - for (let i = node.children.length - 1; i >= 0; i--) { - const child = node.children[i]; - const childSize = child instanceof BranchNode ? child.orthogonalSize : child.size; - - let newSize = node.size === 0 ? 0 : Math.round((size * childSize) / node.size); - totalSize += newSize; - - // The last view to add should adjust to rounding errors - if (i === 0) { - newSize += size - totalSize; - } - - result.addChild(flipNode(child, orthogonalSize, newSize), newSize, 0, true); - } - - node.dispose(); - return result; - } else { - const result = new LeafNode(node.view, orthogonal(node.orientation), node.layoutController, orthogonalSize); - node.dispose(); - return result; - } -} - -/** - * The location of a {@link IView view} within a {@link GridView}. - * - * A GridView is a tree composition of multiple {@link SplitView} instances, orthogonal - * between one another. Here's an example: - * - * ``` - * +-----+---------------+ - * | A | B | - * +-----+---------+-----+ - * | C | | - * +---------------+ D | - * | E | | - * +---------------+-----+ - * ``` - * - * The above grid's tree structure is: - * - * ``` - * Vertical SplitView - * +-Horizontal SplitView - * | +-A - * | +-B - * +- Horizontal SplitView - * +-Vertical SplitView - * | +-C - * | +-E - * +-D - * ``` - * - * So, {@link IView views} within a {@link GridView} can be referenced by - * a sequence of indexes, each index referencing each SplitView. Here are - * each view's locations, from the example above: - * - * - `A`: `[0,0]` - * - `B`: `[0,1]` - * - `C`: `[1,0,0]` - * - `D`: `[1,1]` - * - `E`: `[1,0,1]` - */ -export type GridLocation = number[]; - -/** - * The {@link GridView} is the UI component which implements a two dimensional - * flex-like layout algorithm for a collection of {@link IView} instances, which - * are mostly HTMLElement instances with size constraints. A {@link GridView} is a - * tree composition of multiple {@link SplitView} instances, orthogonal between - * one another. It will respect view's size contraints, just like the SplitView. - * - * It has a low-level index based API, allowing for fine grain performant operations. - * Look into the {@link Grid} widget for a higher-level API. - * - * Features: - * - flex-like layout algorithm - * - snap support - * - corner sash support - * - Alt key modifier behavior, macOS style - * - layout (de)serialization - */ -export class GridView implements IDisposable { - - /** - * The DOM element for this view. - */ - readonly element: HTMLElement; - - private styles: IGridViewStyles; - private proportionalLayout: boolean; - private _root!: BranchNode; - private onDidSashResetRelay = new Relay(); - private _onDidScroll = new Relay(); - private _onDidChange = new Relay(); - private _boundarySashes: IBoundarySashes = {}; - - /** - * The layout controller makes sure layout only propagates - * to the views after the very first call to {@link GridView.layout}. - */ - private layoutController: LayoutController; - private disposable2x2: IDisposable = Disposable.None; - - private get root(): BranchNode { return this._root; } - - private set root(root: BranchNode) { - const oldRoot = this._root; - - if (oldRoot) { - oldRoot.element.remove(); - oldRoot.dispose(); - } - - this._root = root; - this.element.appendChild(root.element); - this.onDidSashResetRelay.input = root.onDidSashReset; - this._onDidChange.input = Event.map(root.onDidChange, () => undefined); // TODO - this._onDidScroll.input = root.onDidScroll; - } - - /** - * Fires whenever the user double clicks a {@link Sash sash}. - */ - readonly onDidSashReset = this.onDidSashResetRelay.event; - - /** - * Fires whenever the user scrolls a {@link SplitView} within - * the grid. - */ - readonly onDidScroll = this._onDidScroll.event; - - /** - * Fires whenever a view within the grid changes its size constraints. - */ - readonly onDidChange = this._onDidChange.event; - - /** - * The width of the grid. - */ - get width(): number { return this.root.width; } - - /** - * The height of the grid. - */ - get height(): number { return this.root.height; } - - /** - * The minimum width of the grid. - */ - get minimumWidth(): number { return this.root.minimumWidth; } - - /** - * The minimum height of the grid. - */ - get minimumHeight(): number { return this.root.minimumHeight; } - - /** - * The maximum width of the grid. - */ - get maximumWidth(): number { return this.root.maximumHeight; } - - /** - * The maximum height of the grid. - */ - get maximumHeight(): number { return this.root.maximumHeight; } - - get orientation(): Orientation { return this._root.orientation; } - get boundarySashes(): IBoundarySashes { return this._boundarySashes; } - - /** - * The orientation of the grid. Matches the orientation of the root - * {@link SplitView} in the grid's tree model. - */ - set orientation(orientation: Orientation) { - if (this._root.orientation === orientation) { - return; - } - - const { size, orthogonalSize, absoluteOffset, absoluteOrthogonalOffset } = this._root; - this.root = flipNode(this._root, orthogonalSize, size); - this.root.layout(size, 0, { orthogonalSize, absoluteOffset: absoluteOrthogonalOffset, absoluteOrthogonalOffset: absoluteOffset, absoluteSize: size, absoluteOrthogonalSize: orthogonalSize }); - this.boundarySashes = this.boundarySashes; - } - - /** - * A collection of sashes perpendicular to each edge of the grid. - * Corner sashes will be created for each intersection. - */ - set boundarySashes(boundarySashes: IBoundarySashes) { - this._boundarySashes = boundarySashes; - this.root.boundarySashes = fromAbsoluteBoundarySashes(boundarySashes, this.orientation); - } - - /** - * Enable/disable edge snapping across all grid views. - */ - set edgeSnapping(edgeSnapping: boolean) { - this.root.edgeSnapping = edgeSnapping; - } - - private maximizedNode: LeafNode | undefined = undefined; - - private readonly _onDidChangeViewMaximized = new Emitter(); - readonly onDidChangeViewMaximized = this._onDidChangeViewMaximized.event; - - /** - * Create a new {@link GridView} instance. - * - * @remarks It's the caller's responsibility to append the - * {@link GridView.element} to the page's DOM. - */ - constructor(options: IGridViewOptions = {}) { - this.element = $('.monaco-grid-view'); - this.styles = options.styles || defaultStyles; - this.proportionalLayout = typeof options.proportionalLayout !== 'undefined' ? !!options.proportionalLayout : true; - this.layoutController = new LayoutController(false); - this.root = new BranchNode(Orientation.VERTICAL, this.layoutController, this.styles, this.proportionalLayout); - } - - style(styles: IGridViewStyles): void { - this.styles = styles; - this.root.style(styles); - } - - /** - * Layout the {@link GridView}. - * - * Optionally provide a `top` and `left` positions, those will propagate - * as an origin for positions passed to {@link IView.layout}. - * - * @param width The width of the {@link GridView}. - * @param height The height of the {@link GridView}. - * @param top Optional, the top location of the {@link GridView}. - * @param left Optional, the left location of the {@link GridView}. - */ - layout(width: number, height: number, top: number = 0, left: number = 0): void { - this.layoutController.isLayoutEnabled = true; - - const [size, orthogonalSize, offset, orthogonalOffset] = this.root.orientation === Orientation.HORIZONTAL ? [height, width, top, left] : [width, height, left, top]; - this.root.layout(size, 0, { orthogonalSize, absoluteOffset: offset, absoluteOrthogonalOffset: orthogonalOffset, absoluteSize: size, absoluteOrthogonalSize: orthogonalSize }); - } - - /** - * Add a {@link IView view} to this {@link GridView}. - * - * @param view The view to add. - * @param size Either a fixed size, or a dynamic {@link Sizing} strategy. - * @param location The {@link GridLocation location} to insert the view on. - */ - addView(view: IView, size: number | Sizing, location: GridLocation): void { - if (this.hasMaximizedView()) { - this.exitMaximizedView(); - } - - this.disposable2x2.dispose(); - this.disposable2x2 = Disposable.None; - - const [rest, index] = tail(location); - const [pathToParent, parent] = this.getNode(rest); - - if (parent instanceof BranchNode) { - const node = new LeafNode(view, orthogonal(parent.orientation), this.layoutController, parent.orthogonalSize); - - try { - parent.addChild(node, size, index); - } catch (err) { - node.dispose(); - throw err; - } - } else { - const [, grandParent] = tail(pathToParent); - const [, parentIndex] = tail(rest); - - let newSiblingSize: number | Sizing = 0; - - const newSiblingCachedVisibleSize = grandParent.getChildCachedVisibleSize(parentIndex); - if (typeof newSiblingCachedVisibleSize === 'number') { - newSiblingSize = Sizing.Invisible(newSiblingCachedVisibleSize); - } - - const oldChild = grandParent.removeChild(parentIndex); - oldChild.dispose(); - - const newParent = new BranchNode(parent.orientation, parent.layoutController, this.styles, this.proportionalLayout, parent.size, parent.orthogonalSize, grandParent.edgeSnapping); - grandParent.addChild(newParent, parent.size, parentIndex); - - const newSibling = new LeafNode(parent.view, grandParent.orientation, this.layoutController, parent.size); - newParent.addChild(newSibling, newSiblingSize, 0); - - if (typeof size !== 'number' && size.type === 'split') { - size = Sizing.Split(0); - } - - const node = new LeafNode(view, grandParent.orientation, this.layoutController, parent.size); - newParent.addChild(node, size, index); - } - - this.trySet2x2(); - } - - /** - * Remove a {@link IView view} from this {@link GridView}. - * - * @param location The {@link GridLocation location} of the {@link IView view}. - * @param sizing Whether to distribute other {@link IView view}'s sizes. - */ - removeView(location: GridLocation, sizing?: DistributeSizing | AutoSizing): IView { - if (this.hasMaximizedView()) { - this.exitMaximizedView(); - } - - this.disposable2x2.dispose(); - this.disposable2x2 = Disposable.None; - - const [rest, index] = tail(location); - const [pathToParent, parent] = this.getNode(rest); - - if (!(parent instanceof BranchNode)) { - throw new Error('Invalid location'); - } - - const node = parent.children[index]; - - if (!(node instanceof LeafNode)) { - throw new Error('Invalid location'); - } - - parent.removeChild(index, sizing); - node.dispose(); - - if (parent.children.length === 0) { - throw new Error('Invalid grid state'); - } - - if (parent.children.length > 1) { - this.trySet2x2(); - return node.view; - } - - if (pathToParent.length === 0) { // parent is root - const sibling = parent.children[0]; - - if (sibling instanceof LeafNode) { - return node.view; - } - - // we must promote sibling to be the new root - parent.removeChild(0); - parent.dispose(); - this.root = sibling; - this.boundarySashes = this.boundarySashes; - this.trySet2x2(); - return node.view; - } - - const [, grandParent] = tail(pathToParent); - const [, parentIndex] = tail(rest); - - const isSiblingVisible = parent.isChildVisible(0); - const sibling = parent.removeChild(0); - - const sizes = grandParent.children.map((_, i) => grandParent.getChildSize(i)); - grandParent.removeChild(parentIndex, sizing); - parent.dispose(); - - if (sibling instanceof BranchNode) { - sizes.splice(parentIndex, 1, ...sibling.children.map(c => c.size)); - - const siblingChildren = sibling.removeAllChildren(); - - for (let i = 0; i < siblingChildren.length; i++) { - grandParent.addChild(siblingChildren[i], siblingChildren[i].size, parentIndex + i); - } - } else { - const newSibling = new LeafNode(sibling.view, orthogonal(sibling.orientation), this.layoutController, sibling.size); - const sizing = isSiblingVisible ? sibling.orthogonalSize : Sizing.Invisible(sibling.orthogonalSize); - grandParent.addChild(newSibling, sizing, parentIndex); - } - - sibling.dispose(); - - for (let i = 0; i < sizes.length; i++) { - grandParent.resizeChild(i, sizes[i]); - } - - this.trySet2x2(); - return node.view; - } - - /** - * Move a {@link IView view} within its parent. - * - * @param parentLocation The {@link GridLocation location} of the {@link IView view}'s parent. - * @param from The index of the {@link IView view} to move. - * @param to The index where the {@link IView view} should move to. - */ - moveView(parentLocation: GridLocation, from: number, to: number): void { - if (this.hasMaximizedView()) { - this.exitMaximizedView(); - } - - const [, parent] = this.getNode(parentLocation); - - if (!(parent instanceof BranchNode)) { - throw new Error('Invalid location'); - } - - parent.moveChild(from, to); - - this.trySet2x2(); - } - - /** - * Swap two {@link IView views} within the {@link GridView}. - * - * @param from The {@link GridLocation location} of one view. - * @param to The {@link GridLocation location} of another view. - */ - swapViews(from: GridLocation, to: GridLocation): void { - if (this.hasMaximizedView()) { - this.exitMaximizedView(); - } - - const [fromRest, fromIndex] = tail(from); - const [, fromParent] = this.getNode(fromRest); - - if (!(fromParent instanceof BranchNode)) { - throw new Error('Invalid from location'); - } - - const fromSize = fromParent.getChildSize(fromIndex); - const fromNode = fromParent.children[fromIndex]; - - if (!(fromNode instanceof LeafNode)) { - throw new Error('Invalid from location'); - } - - const [toRest, toIndex] = tail(to); - const [, toParent] = this.getNode(toRest); - - if (!(toParent instanceof BranchNode)) { - throw new Error('Invalid to location'); - } - - const toSize = toParent.getChildSize(toIndex); - const toNode = toParent.children[toIndex]; - - if (!(toNode instanceof LeafNode)) { - throw new Error('Invalid to location'); - } - - if (fromParent === toParent) { - fromParent.swapChildren(fromIndex, toIndex); - } else { - fromParent.removeChild(fromIndex); - toParent.removeChild(toIndex); - - fromParent.addChild(toNode, fromSize, fromIndex); - toParent.addChild(fromNode, toSize, toIndex); - } - - this.trySet2x2(); - } - - /** - * Resize a {@link IView view}. - * - * @param location The {@link GridLocation location} of the view. - * @param size The size the view should be. Optionally provide a single dimension. - */ - resizeView(location: GridLocation, size: Partial): void { - if (this.hasMaximizedView()) { - this.exitMaximizedView(); - } - - const [rest, index] = tail(location); - const [pathToParent, parent] = this.getNode(rest); - - if (!(parent instanceof BranchNode)) { - throw new Error('Invalid location'); - } - - if (!size.width && !size.height) { - return; - } - - const [parentSize, grandParentSize] = parent.orientation === Orientation.HORIZONTAL ? [size.width, size.height] : [size.height, size.width]; - - if (typeof grandParentSize === 'number' && pathToParent.length > 0) { - const [, grandParent] = tail(pathToParent); - const [, parentIndex] = tail(rest); - - grandParent.resizeChild(parentIndex, grandParentSize); - } - - if (typeof parentSize === 'number') { - parent.resizeChild(index, parentSize); - } - - this.trySet2x2(); - } - - /** - * Get the size of a {@link IView view}. - * - * @param location The {@link GridLocation location} of the view. Provide `undefined` to get - * the size of the grid itself. - */ - getViewSize(location?: GridLocation): IViewSize { - if (!location) { - return { width: this.root.width, height: this.root.height }; - } - - const [, node] = this.getNode(location); - return { width: node.width, height: node.height }; - } - - /** - * Get the cached visible size of a {@link IView view}. This was the size - * of the view at the moment it last became hidden. - * - * @param location The {@link GridLocation location} of the view. - */ - getViewCachedVisibleSize(location: GridLocation): number | undefined { - const [rest, index] = tail(location); - const [, parent] = this.getNode(rest); - - if (!(parent instanceof BranchNode)) { - throw new Error('Invalid location'); - } - - return parent.getChildCachedVisibleSize(index); - } - - /** - * Maximize the size of a {@link IView view} by collapsing all other views - * to their minimum sizes. - * - * @param location The {@link GridLocation location} of the view. - */ - expandView(location: GridLocation): void { - if (this.hasMaximizedView()) { - this.exitMaximizedView(); - } - - const [ancestors, node] = this.getNode(location); - - if (!(node instanceof LeafNode)) { - throw new Error('Invalid location'); - } - - for (let i = 0; i < ancestors.length; i++) { - ancestors[i].resizeChild(location[i], Number.POSITIVE_INFINITY); - } - } - - /** - * Returns whether all other {@link IView views} are at their minimum size. - * - * @param location The {@link GridLocation location} of the view. - */ - isViewExpanded(location: GridLocation): boolean { - if (this.hasMaximizedView()) { - // No view can be expanded when a view is maximized - return false; - } - - const [ancestors, node] = this.getNode(location); - - if (!(node instanceof LeafNode)) { - throw new Error('Invalid location'); - } - - for (let i = 0; i < ancestors.length; i++) { - if (!ancestors[i].isChildExpanded(location[i])) { - return false; - } - } - - return true; - } - - maximizeView(location: GridLocation) { - const [, nodeToMaximize] = this.getNode(location); - if (!(nodeToMaximize instanceof LeafNode)) { - throw new Error('Location is not a LeafNode'); - } - - if (this.maximizedNode === nodeToMaximize) { - return; - } - - if (this.hasMaximizedView()) { - this.exitMaximizedView(); - } - - function hideAllViewsBut(parent: BranchNode, exclude: LeafNode): void { - for (let i = 0; i < parent.children.length; i++) { - const child = parent.children[i]; - if (child instanceof LeafNode) { - if (child !== exclude) { - parent.setChildVisible(i, false); - } - } else { - hideAllViewsBut(child, exclude); - } - } - } - - hideAllViewsBut(this.root, nodeToMaximize); - - this.maximizedNode = nodeToMaximize; - this._onDidChangeViewMaximized.fire(true); - } - - exitMaximizedView(): void { - if (!this.maximizedNode) { - return; - } - this.maximizedNode = undefined; - - // When hiding a view, it's previous size is cached. - // To restore the sizes of all views, they need to be made visible in reverse order. - function showViewsInReverseOrder(parent: BranchNode): void { - for (let index = parent.children.length - 1; index >= 0; index--) { - const child = parent.children[index]; - if (child instanceof LeafNode) { - parent.setChildVisible(index, true); - } else { - showViewsInReverseOrder(child); - } - } - } - - showViewsInReverseOrder(this.root); - - this._onDidChangeViewMaximized.fire(false); - } - - hasMaximizedView(): boolean { - return this.maximizedNode !== undefined; - } - - /** - * Returns whether the {@link IView view} is maximized. - * - * @param location The {@link GridLocation location} of the view. - */ - isViewMaximized(location: GridLocation): boolean { - const [, node] = this.getNode(location); - if (!(node instanceof LeafNode)) { - throw new Error('Location is not a LeafNode'); - } - return node === this.maximizedNode; - } - - /** - * Distribute the size among all {@link IView views} within the entire - * grid or within a single {@link SplitView}. - * - * @param location The {@link GridLocation location} of a view containing - * children views, which will have their sizes distributed within the parent - * view's size. Provide `undefined` to recursively distribute all views' sizes - * in the entire grid. - */ - distributeViewSizes(location?: GridLocation): void { - if (this.hasMaximizedView()) { - this.exitMaximizedView(); - } - - if (!location) { - this.root.distributeViewSizes(true); - return; - } - - const [, node] = this.getNode(location); - - if (!(node instanceof BranchNode)) { - throw new Error('Invalid location'); - } - - node.distributeViewSizes(); - this.trySet2x2(); - } - - /** - * Returns whether a {@link IView view} is visible. - * - * @param location The {@link GridLocation location} of the view. - */ - isViewVisible(location: GridLocation): boolean { - const [rest, index] = tail(location); - const [, parent] = this.getNode(rest); - - if (!(parent instanceof BranchNode)) { - throw new Error('Invalid from location'); - } - - return parent.isChildVisible(index); - } - - /** - * Set the visibility state of a {@link IView view}. - * - * @param location The {@link GridLocation location} of the view. - */ - setViewVisible(location: GridLocation, visible: boolean): void { - if (this.hasMaximizedView()) { - this.exitMaximizedView(); - return; - } - - const [rest, index] = tail(location); - const [, parent] = this.getNode(rest); - - if (!(parent instanceof BranchNode)) { - throw new Error('Invalid from location'); - } - - parent.setChildVisible(index, visible); - } - - /** - * Returns a descriptor for the entire grid. - */ - getView(): GridBranchNode; - - /** - * Returns a descriptor for a {@link GridLocation subtree} within the - * {@link GridView}. - * - * @param location The {@link GridLocation location} of the root of - * the {@link GridLocation subtree}. - */ - getView(location: GridLocation): GridNode; - getView(location?: GridLocation): GridNode { - const node = location ? this.getNode(location)[1] : this._root; - return this._getViews(node, this.orientation); - } - - /** - * Construct a new {@link GridView} from a JSON object. - * - * @param json The JSON object. - * @param deserializer A deserializer which can revive each view. - * @returns A new {@link GridView} instance. - */ - static deserialize(json: ISerializedGridView, deserializer: IViewDeserializer, options: IGridViewOptions = {}): GridView { - if (typeof json.orientation !== 'number') { - throw new Error('Invalid JSON: \'orientation\' property must be a number.'); - } else if (typeof json.width !== 'number') { - throw new Error('Invalid JSON: \'width\' property must be a number.'); - } else if (typeof json.height !== 'number') { - throw new Error('Invalid JSON: \'height\' property must be a number.'); - } else if (json.root?.type !== 'branch') { - throw new Error('Invalid JSON: \'root\' property must have \'type\' value of branch.'); - } - - const orientation = json.orientation; - const height = json.height; - - const result = new GridView(options); - result._deserialize(json.root as ISerializedBranchNode, orientation, deserializer, height); - - return result; - } - - private _deserialize(root: ISerializedBranchNode, orientation: Orientation, deserializer: IViewDeserializer, orthogonalSize: number): void { - this.root = this._deserializeNode(root, orientation, deserializer, orthogonalSize) as BranchNode; - } - - private _deserializeNode(node: ISerializedNode, orientation: Orientation, deserializer: IViewDeserializer, orthogonalSize: number): Node { - let result: Node; - if (node.type === 'branch') { - const serializedChildren = node.data as ISerializedNode[]; - const children = serializedChildren.map(serializedChild => { - return { - node: this._deserializeNode(serializedChild, orthogonal(orientation), deserializer, node.size), - visible: (serializedChild as { visible?: boolean }).visible - } satisfies INodeDescriptor; - }); - - result = new BranchNode(orientation, this.layoutController, this.styles, this.proportionalLayout, node.size, orthogonalSize, undefined, children); - } else { - result = new LeafNode(deserializer.fromJSON(node.data), orientation, this.layoutController, orthogonalSize, node.size); - if (node.maximized && !this.maximizedNode) { - this.maximizedNode = result; - this._onDidChangeViewMaximized.fire(true); - } - } - - return result; - } - - private _getViews(node: Node, orientation: Orientation, cachedVisibleSize?: number): GridNode { - const box = { top: node.top, left: node.left, width: node.width, height: node.height }; - - if (node instanceof LeafNode) { - return { view: node.view, box, cachedVisibleSize, maximized: this.maximizedNode === node }; - } - - const children: GridNode[] = []; - - for (let i = 0; i < node.children.length; i++) { - const child = node.children[i]; - const cachedVisibleSize = node.getChildCachedVisibleSize(i); - - children.push(this._getViews(child, orthogonal(orientation), cachedVisibleSize)); - } - - return { children, box }; - } - - private getNode(location: GridLocation, node: Node = this.root, path: BranchNode[] = []): [BranchNode[], Node] { - if (location.length === 0) { - return [path, node]; - } - - if (!(node instanceof BranchNode)) { - throw new Error('Invalid location'); - } - - const [index, ...rest] = location; - - if (index < 0 || index >= node.children.length) { - throw new Error('Invalid location'); - } - - const child = node.children[index]; - path.push(node); - - return this.getNode(rest, child, path); - } - - /** - * Attempt to lock the {@link Sash sashes} in this {@link GridView} so - * the grid behaves as a 2x2 matrix, with a corner sash in the middle. - * - * In case the grid isn't a 2x2 grid _and_ all sashes are not aligned, - * this method is a no-op. - */ - trySet2x2(): void { - this.disposable2x2.dispose(); - this.disposable2x2 = Disposable.None; - - if (this.root.children.length !== 2) { - return; - } - - const [first, second] = this.root.children; - - if (!(first instanceof BranchNode) || !(second instanceof BranchNode)) { - return; - } - - this.disposable2x2 = first.trySet2x2(second); - } - - /** - * Populate a map with views to DOM nodes. - * @remarks To be used internally only. - */ - getViewMap(map: Map, node?: Node): void { - if (!node) { - node = this.root; - } - - if (node instanceof BranchNode) { - node.children.forEach(child => this.getViewMap(map, child)); - } else { - map.set(node.view, node.element); - } - } - - dispose(): void { - this.onDidSashResetRelay.dispose(); - this.root.dispose(); - this.element.remove(); - } -} diff --git a/src/vs/base/browser/ui/mouseCursor/mouseCursor.css b/src/vs/base/browser/ui/mouseCursor/mouseCursor.css deleted file mode 100644 index 1d7ede8417..0000000000 --- a/src/vs/base/browser/ui/mouseCursor/mouseCursor.css +++ /dev/null @@ -1,8 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -.monaco-mouse-cursor-text { - cursor: text; -} diff --git a/src/vs/base/browser/ui/mouseCursor/mouseCursor.ts b/src/vs/base/browser/ui/mouseCursor/mouseCursor.ts deleted file mode 100644 index f845a4db87..0000000000 --- a/src/vs/base/browser/ui/mouseCursor/mouseCursor.ts +++ /dev/null @@ -1,8 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -// import 'vs/css!./mouseCursor'; - -export const MOUSE_CURSOR_TEXT_CSS_CLASS_NAME = `monaco-mouse-cursor-text`; diff --git a/src/vs/base/browser/ui/progressbar/progressAccessibilitySignal.ts b/src/vs/base/browser/ui/progressbar/progressAccessibilitySignal.ts deleted file mode 100644 index 19a5deba94..0000000000 --- a/src/vs/base/browser/ui/progressbar/progressAccessibilitySignal.ts +++ /dev/null @@ -1,23 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { IDisposable } from 'vs/base/common/lifecycle'; - -export interface IScopedAccessibilityProgressSignalDelegate extends IDisposable { } - -const nullScopedAccessibilityProgressSignalFactory = () => ({ - msLoopTime: -1, - msDelayTime: -1, - dispose: () => { }, -}); -let progressAccessibilitySignalSchedulerFactory: (msDelayTime: number, msLoopTime?: number) => IScopedAccessibilityProgressSignalDelegate = nullScopedAccessibilityProgressSignalFactory; - -export function setProgressAcccessibilitySignalScheduler(progressAccessibilitySignalScheduler: (msDelayTime: number, msLoopTime?: number) => IScopedAccessibilityProgressSignalDelegate) { - progressAccessibilitySignalSchedulerFactory = progressAccessibilitySignalScheduler; -} - -export function getProgressAcccessibilitySignalScheduler(msDelayTime: number, msLoopTime?: number): IScopedAccessibilityProgressSignalDelegate { - return progressAccessibilitySignalSchedulerFactory(msDelayTime, msLoopTime); -} diff --git a/src/vs/base/browser/ui/progressbar/progressbar.css b/src/vs/base/browser/ui/progressbar/progressbar.css deleted file mode 100644 index dc23cd255e..0000000000 --- a/src/vs/base/browser/ui/progressbar/progressbar.css +++ /dev/null @@ -1,61 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -.monaco-progress-container { - width: 100%; - height: 2px; - overflow: hidden; /* keep progress bit in bounds */ -} - -.monaco-progress-container .progress-bit { - width: 2%; - height: 2px; - position: absolute; - left: 0; - display: none; -} - -.monaco-progress-container.active .progress-bit { - display: inherit; -} - -.monaco-progress-container.discrete .progress-bit { - left: 0; - transition: width 100ms linear; -} - -.monaco-progress-container.discrete.done .progress-bit { - width: 100%; -} - -.monaco-progress-container.infinite .progress-bit { - animation-name: progress; - animation-duration: 4s; - animation-iteration-count: infinite; - transform: translate3d(0px, 0px, 0px); - animation-timing-function: linear; -} - -.monaco-progress-container.infinite.infinite-long-running .progress-bit { - /* - The more smooth `linear` timing function can cause - higher GPU consumption as indicated in - https://github.com/microsoft/vscode/issues/97900 & - https://github.com/microsoft/vscode/issues/138396 - */ - animation-timing-function: steps(100); -} - -/** - * The progress bit has a width: 2% (1/50) of the parent container. The animation moves it from 0% to 100% of - * that container. Since translateX is relative to the progress bit size, we have to multiple it with - * its relative size to the parent container: - * parent width: 5000% - * bit width: 100% - * translateX should be as follow: - * 50%: 5000% * 50% - 50% (set to center) = 2450% - * 100%: 5000% * 100% - 100% (do not overflow) = 4900% - */ -@keyframes progress { from { transform: translateX(0%) scaleX(1) } 50% { transform: translateX(2500%) scaleX(3) } to { transform: translateX(4900%) scaleX(1) } } diff --git a/src/vs/base/browser/ui/progressbar/progressbar.ts b/src/vs/base/browser/ui/progressbar/progressbar.ts deleted file mode 100644 index bc717f155b..0000000000 --- a/src/vs/base/browser/ui/progressbar/progressbar.ts +++ /dev/null @@ -1,224 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { hide, show } from 'vs/base/browser/dom'; -import { getProgressAcccessibilitySignalScheduler } from 'vs/base/browser/ui/progressbar/progressAccessibilitySignal'; -import { RunOnceScheduler } from 'vs/base/common/async'; -import { Disposable, IDisposable, MutableDisposable } from 'vs/base/common/lifecycle'; -import { isNumber } from 'vs/base/common/types'; -// import 'vs/css!./progressbar'; - -const CSS_DONE = 'done'; -const CSS_ACTIVE = 'active'; -const CSS_INFINITE = 'infinite'; -const CSS_INFINITE_LONG_RUNNING = 'infinite-long-running'; -const CSS_DISCRETE = 'discrete'; - -export interface IProgressBarOptions extends IProgressBarStyles { -} - -export interface IProgressBarStyles { - progressBarBackground: string | undefined; -} - -export const unthemedProgressBarOptions: IProgressBarOptions = { - progressBarBackground: undefined -}; - -/** - * A progress bar with support for infinite or discrete progress. - */ -export class ProgressBar extends Disposable { - - /** - * After a certain time of showing the progress bar, switch - * to long-running mode and throttle animations to reduce - * the pressure on the GPU process. - * - * https://github.com/microsoft/vscode/issues/97900 - * https://github.com/microsoft/vscode/issues/138396 - */ - private static readonly LONG_RUNNING_INFINITE_THRESHOLD = 10000; - - private static readonly PROGRESS_SIGNAL_DEFAULT_DELAY = 3000; - - private workedVal: number; - private element!: HTMLElement; - private bit!: HTMLElement; - private totalWork: number | undefined; - private showDelayedScheduler: RunOnceScheduler; - private longRunningScheduler: RunOnceScheduler; - private readonly progressSignal = this._register(new MutableDisposable()); - - constructor(container: HTMLElement, options?: IProgressBarOptions) { - super(); - - this.workedVal = 0; - - this.showDelayedScheduler = this._register(new RunOnceScheduler(() => show(this.element), 0)); - this.longRunningScheduler = this._register(new RunOnceScheduler(() => this.infiniteLongRunning(), ProgressBar.LONG_RUNNING_INFINITE_THRESHOLD)); - - this.create(container, options); - } - - private create(container: HTMLElement, options?: IProgressBarOptions): void { - this.element = document.createElement('div'); - this.element.classList.add('monaco-progress-container'); - this.element.setAttribute('role', 'progressbar'); - this.element.setAttribute('aria-valuemin', '0'); - container.appendChild(this.element); - - this.bit = document.createElement('div'); - this.bit.classList.add('progress-bit'); - this.bit.style.backgroundColor = options?.progressBarBackground || '#0E70C0'; - this.element.appendChild(this.bit); - } - - private off(): void { - this.bit.style.width = 'inherit'; - this.bit.style.opacity = '1'; - this.element.classList.remove(CSS_ACTIVE, CSS_INFINITE, CSS_INFINITE_LONG_RUNNING, CSS_DISCRETE); - - this.workedVal = 0; - this.totalWork = undefined; - - this.longRunningScheduler.cancel(); - this.progressSignal.clear(); - } - - /** - * Indicates to the progress bar that all work is done. - */ - done(): ProgressBar { - return this.doDone(true); - } - - /** - * Stops the progressbar from showing any progress instantly without fading out. - */ - stop(): ProgressBar { - return this.doDone(false); - } - - private doDone(delayed: boolean): ProgressBar { - this.element.classList.add(CSS_DONE); - - // discrete: let it grow to 100% width and hide afterwards - if (!this.element.classList.contains(CSS_INFINITE)) { - this.bit.style.width = 'inherit'; - - if (delayed) { - setTimeout(() => this.off(), 200); - } else { - this.off(); - } - } - - // infinite: let it fade out and hide afterwards - else { - this.bit.style.opacity = '0'; - if (delayed) { - setTimeout(() => this.off(), 200); - } else { - this.off(); - } - } - - return this; - } - - /** - * Use this mode to indicate progress that has no total number of work units. - */ - infinite(): ProgressBar { - this.bit.style.width = '2%'; - this.bit.style.opacity = '1'; - - this.element.classList.remove(CSS_DISCRETE, CSS_DONE, CSS_INFINITE_LONG_RUNNING); - this.element.classList.add(CSS_ACTIVE, CSS_INFINITE); - - this.longRunningScheduler.schedule(); - - return this; - } - - private infiniteLongRunning(): void { - this.element.classList.add(CSS_INFINITE_LONG_RUNNING); - } - - /** - * Tells the progress bar the total number of work. Use in combination with workedVal() to let - * the progress bar show the actual progress based on the work that is done. - */ - total(value: number): ProgressBar { - this.workedVal = 0; - this.totalWork = value; - this.element.setAttribute('aria-valuemax', value.toString()); - - return this; - } - - /** - * Finds out if this progress bar is configured with total work - */ - hasTotal(): boolean { - return isNumber(this.totalWork); - } - - /** - * Tells the progress bar that an increment of work has been completed. - */ - worked(value: number): ProgressBar { - value = Math.max(1, Number(value)); - - return this.doSetWorked(this.workedVal + value); - } - - /** - * Tells the progress bar the total amount of work that has been completed. - */ - setWorked(value: number): ProgressBar { - value = Math.max(1, Number(value)); - - return this.doSetWorked(value); - } - - private doSetWorked(value: number): ProgressBar { - const totalWork = this.totalWork || 100; - - this.workedVal = value; - this.workedVal = Math.min(totalWork, this.workedVal); - - this.element.classList.remove(CSS_INFINITE, CSS_INFINITE_LONG_RUNNING, CSS_DONE); - this.element.classList.add(CSS_ACTIVE, CSS_DISCRETE); - this.element.setAttribute('aria-valuenow', value.toString()); - - this.bit.style.width = 100 * (this.workedVal / (totalWork)) + '%'; - - return this; - } - - getContainer(): HTMLElement { - return this.element; - } - - show(delay?: number): void { - this.showDelayedScheduler.cancel(); - this.progressSignal.value = getProgressAcccessibilitySignalScheduler(ProgressBar.PROGRESS_SIGNAL_DEFAULT_DELAY); - - if (typeof delay === 'number') { - this.showDelayedScheduler.schedule(delay); - } else { - show(this.element); - } - } - - hide(): void { - hide(this.element); - - this.showDelayedScheduler.cancel(); - this.progressSignal.clear(); - } -} diff --git a/src/vs/base/browser/ui/resizable/resizable.ts b/src/vs/base/browser/ui/resizable/resizable.ts deleted file mode 100644 index 95dfb06b8d..0000000000 --- a/src/vs/base/browser/ui/resizable/resizable.ts +++ /dev/null @@ -1,190 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { Dimension } from 'vs/base/browser/dom'; -import { Orientation, OrthogonalEdge, Sash, SashState } from 'vs/base/browser/ui/sash/sash'; -import { Emitter, Event } from 'vs/base/common/event'; -import { DisposableStore } from 'vs/base/common/lifecycle'; - - -export interface IResizeEvent { - dimension: Dimension; - done: boolean; - north?: boolean; - east?: boolean; - south?: boolean; - west?: boolean; -} - -export class ResizableHTMLElement { - - readonly domNode: HTMLElement; - - private readonly _onDidWillResize = new Emitter(); - readonly onDidWillResize: Event = this._onDidWillResize.event; - - private readonly _onDidResize = new Emitter(); - readonly onDidResize: Event = this._onDidResize.event; - - private readonly _northSash: Sash; - private readonly _eastSash: Sash; - private readonly _southSash: Sash; - private readonly _westSash: Sash; - private readonly _sashListener = new DisposableStore(); - - private _size = new Dimension(0, 0); - private _minSize = new Dimension(0, 0); - private _maxSize = new Dimension(Number.MAX_SAFE_INTEGER, Number.MAX_SAFE_INTEGER); - private _preferredSize?: Dimension; - - constructor() { - this.domNode = document.createElement('div'); - this._eastSash = new Sash(this.domNode, { getVerticalSashLeft: () => this._size.width }, { orientation: Orientation.VERTICAL }); - this._westSash = new Sash(this.domNode, { getVerticalSashLeft: () => 0 }, { orientation: Orientation.VERTICAL }); - this._northSash = new Sash(this.domNode, { getHorizontalSashTop: () => 0 }, { orientation: Orientation.HORIZONTAL, orthogonalEdge: OrthogonalEdge.North }); - this._southSash = new Sash(this.domNode, { getHorizontalSashTop: () => this._size.height }, { orientation: Orientation.HORIZONTAL, orthogonalEdge: OrthogonalEdge.South }); - - this._northSash.orthogonalStartSash = this._westSash; - this._northSash.orthogonalEndSash = this._eastSash; - this._southSash.orthogonalStartSash = this._westSash; - this._southSash.orthogonalEndSash = this._eastSash; - - let currentSize: Dimension | undefined; - let deltaY = 0; - let deltaX = 0; - - this._sashListener.add(Event.any(this._northSash.onDidStart, this._eastSash.onDidStart, this._southSash.onDidStart, this._westSash.onDidStart)(() => { - if (currentSize === undefined) { - this._onDidWillResize.fire(); - currentSize = this._size; - deltaY = 0; - deltaX = 0; - } - })); - this._sashListener.add(Event.any(this._northSash.onDidEnd, this._eastSash.onDidEnd, this._southSash.onDidEnd, this._westSash.onDidEnd)(() => { - if (currentSize !== undefined) { - currentSize = undefined; - deltaY = 0; - deltaX = 0; - this._onDidResize.fire({ dimension: this._size, done: true }); - } - })); - - this._sashListener.add(this._eastSash.onDidChange(e => { - if (currentSize) { - deltaX = e.currentX - e.startX; - this.layout(currentSize.height + deltaY, currentSize.width + deltaX); - this._onDidResize.fire({ dimension: this._size, done: false, east: true }); - } - })); - this._sashListener.add(this._westSash.onDidChange(e => { - if (currentSize) { - deltaX = -(e.currentX - e.startX); - this.layout(currentSize.height + deltaY, currentSize.width + deltaX); - this._onDidResize.fire({ dimension: this._size, done: false, west: true }); - } - })); - this._sashListener.add(this._northSash.onDidChange(e => { - if (currentSize) { - deltaY = -(e.currentY - e.startY); - this.layout(currentSize.height + deltaY, currentSize.width + deltaX); - this._onDidResize.fire({ dimension: this._size, done: false, north: true }); - } - })); - this._sashListener.add(this._southSash.onDidChange(e => { - if (currentSize) { - deltaY = e.currentY - e.startY; - this.layout(currentSize.height + deltaY, currentSize.width + deltaX); - this._onDidResize.fire({ dimension: this._size, done: false, south: true }); - } - })); - - this._sashListener.add(Event.any(this._eastSash.onDidReset, this._westSash.onDidReset)(e => { - if (this._preferredSize) { - this.layout(this._size.height, this._preferredSize.width); - this._onDidResize.fire({ dimension: this._size, done: true }); - } - })); - this._sashListener.add(Event.any(this._northSash.onDidReset, this._southSash.onDidReset)(e => { - if (this._preferredSize) { - this.layout(this._preferredSize.height, this._size.width); - this._onDidResize.fire({ dimension: this._size, done: true }); - } - })); - } - - dispose(): void { - this._northSash.dispose(); - this._southSash.dispose(); - this._eastSash.dispose(); - this._westSash.dispose(); - this._sashListener.dispose(); - this._onDidResize.dispose(); - this._onDidWillResize.dispose(); - this.domNode.remove(); - } - - enableSashes(north: boolean, east: boolean, south: boolean, west: boolean): void { - this._northSash.state = north ? SashState.Enabled : SashState.Disabled; - this._eastSash.state = east ? SashState.Enabled : SashState.Disabled; - this._southSash.state = south ? SashState.Enabled : SashState.Disabled; - this._westSash.state = west ? SashState.Enabled : SashState.Disabled; - } - - layout(height: number = this.size.height, width: number = this.size.width): void { - - const { height: minHeight, width: minWidth } = this._minSize; - const { height: maxHeight, width: maxWidth } = this._maxSize; - - height = Math.max(minHeight, Math.min(maxHeight, height)); - width = Math.max(minWidth, Math.min(maxWidth, width)); - - const newSize = new Dimension(width, height); - if (!Dimension.equals(newSize, this._size)) { - this.domNode.style.height = height + 'px'; - this.domNode.style.width = width + 'px'; - this._size = newSize; - this._northSash.layout(); - this._eastSash.layout(); - this._southSash.layout(); - this._westSash.layout(); - } - } - - clearSashHoverState(): void { - this._eastSash.clearSashHoverState(); - this._westSash.clearSashHoverState(); - this._northSash.clearSashHoverState(); - this._southSash.clearSashHoverState(); - } - - get size() { - return this._size; - } - - set maxSize(value: Dimension) { - this._maxSize = value; - } - - get maxSize() { - return this._maxSize; - } - - set minSize(value: Dimension) { - this._minSize = value; - } - - get minSize() { - return this._minSize; - } - - set preferredSize(value: Dimension | undefined) { - this._preferredSize = value; - } - - get preferredSize() { - return this._preferredSize; - } -} diff --git a/src/vs/base/browser/ui/sash/sash.css b/src/vs/base/browser/ui/sash/sash.css deleted file mode 100644 index 42b0f42578..0000000000 --- a/src/vs/base/browser/ui/sash/sash.css +++ /dev/null @@ -1,149 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -:root { - --vscode-sash-size: 4px; - --vscode-sash-hover-size: 4px; -} - -.monaco-sash { - position: absolute; - z-index: 35; - touch-action: none; -} - -.monaco-sash.disabled { - pointer-events: none; -} - -.monaco-sash.mac.vertical { - cursor: col-resize; -} - -.monaco-sash.vertical.minimum { - cursor: e-resize; -} - -.monaco-sash.vertical.maximum { - cursor: w-resize; -} - -.monaco-sash.mac.horizontal { - cursor: row-resize; -} - -.monaco-sash.horizontal.minimum { - cursor: s-resize; -} - -.monaco-sash.horizontal.maximum { - cursor: n-resize; -} - -.monaco-sash.disabled { - cursor: default !important; - pointer-events: none !important; -} - -.monaco-sash.vertical { - cursor: ew-resize; - top: 0; - width: var(--vscode-sash-size); - height: 100%; -} - -.monaco-sash.horizontal { - cursor: ns-resize; - left: 0; - width: 100%; - height: var(--vscode-sash-size); -} - -.monaco-sash:not(.disabled) > .orthogonal-drag-handle { - content: " "; - height: calc(var(--vscode-sash-size) * 2); - width: calc(var(--vscode-sash-size) * 2); - z-index: 100; - display: block; - cursor: all-scroll; - position: absolute; -} - -.monaco-sash.horizontal.orthogonal-edge-north:not(.disabled) - > .orthogonal-drag-handle.start, -.monaco-sash.horizontal.orthogonal-edge-south:not(.disabled) - > .orthogonal-drag-handle.end { - cursor: nwse-resize; -} - -.monaco-sash.horizontal.orthogonal-edge-north:not(.disabled) - > .orthogonal-drag-handle.end, -.monaco-sash.horizontal.orthogonal-edge-south:not(.disabled) - > .orthogonal-drag-handle.start { - cursor: nesw-resize; -} - -.monaco-sash.vertical > .orthogonal-drag-handle.start { - left: calc(var(--vscode-sash-size) * -0.5); - top: calc(var(--vscode-sash-size) * -1); -} -.monaco-sash.vertical > .orthogonal-drag-handle.end { - left: calc(var(--vscode-sash-size) * -0.5); - bottom: calc(var(--vscode-sash-size) * -1); -} -.monaco-sash.horizontal > .orthogonal-drag-handle.start { - top: calc(var(--vscode-sash-size) * -0.5); - left: calc(var(--vscode-sash-size) * -1); -} -.monaco-sash.horizontal > .orthogonal-drag-handle.end { - top: calc(var(--vscode-sash-size) * -0.5); - right: calc(var(--vscode-sash-size) * -1); -} - -.monaco-sash:before { - content: ''; - pointer-events: none; - position: absolute; - width: 100%; - height: 100%; - background: transparent; -} - -.monaco-workbench:not(.reduce-motion) .monaco-sash:before { - transition: background-color 0.1s ease-out; -} - -.monaco-sash.hover:before, -.monaco-sash.active:before { - background: var(--vscode-sash-hoverBorder); -} - -.monaco-sash.vertical:before { - width: var(--vscode-sash-hover-size); - left: calc(50% - (var(--vscode-sash-hover-size) / 2)); -} - -.monaco-sash.horizontal:before { - height: var(--vscode-sash-hover-size); - top: calc(50% - (var(--vscode-sash-hover-size) / 2)); -} - -.pointer-events-disabled { - pointer-events: none !important; -} - -/** Debug **/ - -.monaco-sash.debug { - background: cyan; -} - -.monaco-sash.debug.disabled { - background: rgba(0, 255, 255, 0.2); -} - -.monaco-sash.debug:not(.disabled) > .orthogonal-drag-handle { - background: red; -} diff --git a/src/vs/base/browser/ui/sash/sash.ts b/src/vs/base/browser/ui/sash/sash.ts deleted file mode 100644 index f469510905..0000000000 --- a/src/vs/base/browser/ui/sash/sash.ts +++ /dev/null @@ -1,688 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { $, append, createStyleSheet, EventHelper, EventLike, getWindow, isHTMLElement } from 'vs/base/browser/dom'; -import { DomEmitter } from 'vs/base/browser/event'; -import { EventType, Gesture } from 'vs/base/browser/touch'; -import { Delayer } from 'vs/base/common/async'; -import { memoize } from 'vs/base/common/decorators'; -import { Emitter, Event } from 'vs/base/common/event'; -import { Disposable, DisposableStore, toDisposable } from 'vs/base/common/lifecycle'; -import { isMacintosh } from 'vs/base/common/platform'; -// import 'vs/css!./sash'; - -/** - * Allow the sashes to be visible at runtime. - * @remark Use for development purposes only. - */ -const DEBUG = false; -// DEBUG = Boolean("true"); // done "weirdly" so that a lint warning prevents you from pushing this - -/** - * A vertical sash layout provider provides position and height for a sash. - */ -export interface IVerticalSashLayoutProvider { - getVerticalSashLeft(sash: Sash): number; - getVerticalSashTop?(sash: Sash): number; - getVerticalSashHeight?(sash: Sash): number; -} - -/** - * A vertical sash layout provider provides position and width for a sash. - */ -export interface IHorizontalSashLayoutProvider { - getHorizontalSashTop(sash: Sash): number; - getHorizontalSashLeft?(sash: Sash): number; - getHorizontalSashWidth?(sash: Sash): number; -} - -type ISashLayoutProvider = IVerticalSashLayoutProvider | IHorizontalSashLayoutProvider; - -export interface ISashEvent { - readonly startX: number; - readonly currentX: number; - readonly startY: number; - readonly currentY: number; - readonly altKey: boolean; -} - -export enum OrthogonalEdge { - North = 'north', - South = 'south', - East = 'east', - West = 'west' -} - -export interface IBoundarySashes { - readonly top?: Sash; - readonly right?: Sash; - readonly bottom?: Sash; - readonly left?: Sash; -} - -export interface ISashOptions { - - /** - * Whether a sash is horizontal or vertical. - */ - readonly orientation: Orientation; - - /** - * The width or height of a vertical or horizontal sash, respectively. - */ - readonly size?: number; - - /** - * A reference to another sash, perpendicular to this one, which - * aligns at the start of this one. A corner sash will be created - * automatically at that location. - * - * The start of a horizontal sash is its left-most position. - * The start of a vertical sash is its top-most position. - */ - readonly orthogonalStartSash?: Sash; - - /** - * A reference to another sash, perpendicular to this one, which - * aligns at the end of this one. A corner sash will be created - * automatically at that location. - * - * The end of a horizontal sash is its right-most position. - * The end of a vertical sash is its bottom-most position. - */ - readonly orthogonalEndSash?: Sash; - - /** - * Provides a hint as to what mouse cursor to use whenever the user - * hovers over a corner sash provided by this and an orthogonal sash. - */ - readonly orthogonalEdge?: OrthogonalEdge; -} - -export interface IVerticalSashOptions extends ISashOptions { - readonly orientation: Orientation.VERTICAL; -} - -export interface IHorizontalSashOptions extends ISashOptions { - readonly orientation: Orientation.HORIZONTAL; -} - -export const enum Orientation { - VERTICAL, - HORIZONTAL -} - -export const enum SashState { - - /** - * Disable any UI interaction. - */ - Disabled, - - /** - * Allow dragging down or to the right, depending on the sash orientation. - * - * Some OSs allow customizing the mouse cursor differently whenever - * some resizable component can't be any smaller, but can be larger. - */ - AtMinimum, - - /** - * Allow dragging up or to the left, depending on the sash orientation. - * - * Some OSs allow customizing the mouse cursor differently whenever - * some resizable component can't be any larger, but can be smaller. - */ - AtMaximum, - - /** - * Enable dragging. - */ - Enabled -} - -let globalSize = 4; -const onDidChangeGlobalSize = new Emitter(); -export function setGlobalSashSize(size: number): void { - globalSize = size; - onDidChangeGlobalSize.fire(size); -} - -let globalHoverDelay = 300; -const onDidChangeHoverDelay = new Emitter(); -export function setGlobalHoverDelay(size: number): void { - globalHoverDelay = size; - onDidChangeHoverDelay.fire(size); -} - -interface PointerEvent extends EventLike { - readonly pageX: number; - readonly pageY: number; - readonly altKey: boolean; - readonly target: EventTarget | null; - readonly initialTarget?: EventTarget | undefined; -} - -interface IPointerEventFactory { - readonly onPointerMove: Event; - readonly onPointerUp: Event; - dispose(): void; -} - -class MouseEventFactory implements IPointerEventFactory { - - private readonly disposables = new DisposableStore(); - - constructor(private el: HTMLElement) { } - - @memoize - get onPointerMove(): Event { - return this.disposables.add(new DomEmitter(getWindow(this.el), 'mousemove')).event; - } - - @memoize - get onPointerUp(): Event { - return this.disposables.add(new DomEmitter(getWindow(this.el), 'mouseup')).event; - } - - dispose(): void { - this.disposables.dispose(); - } -} - -class GestureEventFactory implements IPointerEventFactory { - - private readonly disposables = new DisposableStore(); - - @memoize - get onPointerMove(): Event { - return this.disposables.add(new DomEmitter(this.el, EventType.Change)).event; - } - - @memoize - get onPointerUp(): Event { - return this.disposables.add(new DomEmitter(this.el, EventType.End)).event; - } - - constructor(private el: HTMLElement) { } - - dispose(): void { - this.disposables.dispose(); - } -} - -class OrthogonalPointerEventFactory implements IPointerEventFactory { - - @memoize - get onPointerMove(): Event { - return this.factory.onPointerMove; - } - - @memoize - get onPointerUp(): Event { - return this.factory.onPointerUp; - } - - constructor(private factory: IPointerEventFactory) { } - - dispose(): void { - // noop - } -} - -const PointerEventsDisabledCssClass = 'pointer-events-disabled'; - -/** - * The {@link Sash} is the UI component which allows the user to resize other - * components. It's usually an invisible horizontal or vertical line which, when - * hovered, becomes highlighted and can be dragged along the perpendicular dimension - * to its direction. - * - * Features: - * - Touch event handling - * - Corner sash support - * - Hover with different mouse cursor support - * - Configurable hover size - * - Linked sash support, for 2x2 corner sashes - */ -export class Sash extends Disposable { - - private el: HTMLElement; - private layoutProvider: ISashLayoutProvider; - private orientation: Orientation; - private size: number; - private hoverDelay = globalHoverDelay; - private hoverDelayer = this._register(new Delayer(this.hoverDelay)); - - private _state: SashState = SashState.Enabled; - private readonly onDidEnablementChange = this._register(new Emitter()); - private readonly _onDidStart = this._register(new Emitter()); - private readonly _onDidChange = this._register(new Emitter()); - private readonly _onDidReset = this._register(new Emitter()); - private readonly _onDidEnd = this._register(new Emitter()); - private readonly orthogonalStartSashDisposables = this._register(new DisposableStore()); - private _orthogonalStartSash: Sash | undefined; - private readonly orthogonalStartDragHandleDisposables = this._register(new DisposableStore()); - private _orthogonalStartDragHandle: HTMLElement | undefined; - private readonly orthogonalEndSashDisposables = this._register(new DisposableStore()); - private _orthogonalEndSash: Sash | undefined; - private readonly orthogonalEndDragHandleDisposables = this._register(new DisposableStore()); - private _orthogonalEndDragHandle: HTMLElement | undefined; - - get state(): SashState { return this._state; } - get orthogonalStartSash(): Sash | undefined { return this._orthogonalStartSash; } - get orthogonalEndSash(): Sash | undefined { return this._orthogonalEndSash; } - - /** - * The state of a sash defines whether it can be interacted with by the user - * as well as what mouse cursor to use, when hovered. - */ - set state(state: SashState) { - if (this._state === state) { - return; - } - - this.el.classList.toggle('disabled', state === SashState.Disabled); - this.el.classList.toggle('minimum', state === SashState.AtMinimum); - this.el.classList.toggle('maximum', state === SashState.AtMaximum); - - this._state = state; - this.onDidEnablementChange.fire(state); - } - - /** - * An event which fires whenever the user starts dragging this sash. - */ - readonly onDidStart: Event = this._onDidStart.event; - - /** - * An event which fires whenever the user moves the mouse while - * dragging this sash. - */ - readonly onDidChange: Event = this._onDidChange.event; - - /** - * An event which fires whenever the user double clicks this sash. - */ - readonly onDidReset: Event = this._onDidReset.event; - - /** - * An event which fires whenever the user stops dragging this sash. - */ - readonly onDidEnd: Event = this._onDidEnd.event; - - /** - * A linked sash will be forwarded the same user interactions and events - * so it moves exactly the same way as this sash. - * - * Useful in 2x2 grids. Not meant for widespread usage. - */ - linkedSash: Sash | undefined = undefined; - - /** - * A reference to another sash, perpendicular to this one, which - * aligns at the start of this one. A corner sash will be created - * automatically at that location. - * - * The start of a horizontal sash is its left-most position. - * The start of a vertical sash is its top-most position. - */ - set orthogonalStartSash(sash: Sash | undefined) { - if (this._orthogonalStartSash === sash) { - return; - } - - this.orthogonalStartDragHandleDisposables.clear(); - this.orthogonalStartSashDisposables.clear(); - - if (sash) { - const onChange = (state: SashState) => { - this.orthogonalStartDragHandleDisposables.clear(); - - if (state !== SashState.Disabled) { - this._orthogonalStartDragHandle = append(this.el, $('.orthogonal-drag-handle.start')); - this.orthogonalStartDragHandleDisposables.add(toDisposable(() => this._orthogonalStartDragHandle!.remove())); - this.orthogonalStartDragHandleDisposables.add(new DomEmitter(this._orthogonalStartDragHandle, 'mouseenter')).event - (() => Sash.onMouseEnter(sash), undefined, this.orthogonalStartDragHandleDisposables); - this.orthogonalStartDragHandleDisposables.add(new DomEmitter(this._orthogonalStartDragHandle, 'mouseleave')).event - (() => Sash.onMouseLeave(sash), undefined, this.orthogonalStartDragHandleDisposables); - } - }; - - this.orthogonalStartSashDisposables.add(sash.onDidEnablementChange.event(onChange, this)); - onChange(sash.state); - } - - this._orthogonalStartSash = sash; - } - - /** - * A reference to another sash, perpendicular to this one, which - * aligns at the end of this one. A corner sash will be created - * automatically at that location. - * - * The end of a horizontal sash is its right-most position. - * The end of a vertical sash is its bottom-most position. - */ - - set orthogonalEndSash(sash: Sash | undefined) { - if (this._orthogonalEndSash === sash) { - return; - } - - this.orthogonalEndDragHandleDisposables.clear(); - this.orthogonalEndSashDisposables.clear(); - - if (sash) { - const onChange = (state: SashState) => { - this.orthogonalEndDragHandleDisposables.clear(); - - if (state !== SashState.Disabled) { - this._orthogonalEndDragHandle = append(this.el, $('.orthogonal-drag-handle.end')); - this.orthogonalEndDragHandleDisposables.add(toDisposable(() => this._orthogonalEndDragHandle!.remove())); - this.orthogonalEndDragHandleDisposables.add(new DomEmitter(this._orthogonalEndDragHandle, 'mouseenter')).event - (() => Sash.onMouseEnter(sash), undefined, this.orthogonalEndDragHandleDisposables); - this.orthogonalEndDragHandleDisposables.add(new DomEmitter(this._orthogonalEndDragHandle, 'mouseleave')).event - (() => Sash.onMouseLeave(sash), undefined, this.orthogonalEndDragHandleDisposables); - } - }; - - this.orthogonalEndSashDisposables.add(sash.onDidEnablementChange.event(onChange, this)); - onChange(sash.state); - } - - this._orthogonalEndSash = sash; - } - - /** - * Create a new vertical sash. - * - * @param container A DOM node to append the sash to. - * @param verticalLayoutProvider A vertical layout provider. - * @param options The options. - */ - constructor(container: HTMLElement, verticalLayoutProvider: IVerticalSashLayoutProvider, options: IVerticalSashOptions); - - /** - * Create a new horizontal sash. - * - * @param container A DOM node to append the sash to. - * @param horizontalLayoutProvider A horizontal layout provider. - * @param options The options. - */ - constructor(container: HTMLElement, horizontalLayoutProvider: IHorizontalSashLayoutProvider, options: IHorizontalSashOptions); - constructor(container: HTMLElement, layoutProvider: ISashLayoutProvider, options: ISashOptions) { - super(); - - this.el = append(container, $('.monaco-sash')); - - if (options.orthogonalEdge) { - this.el.classList.add(`orthogonal-edge-${options.orthogonalEdge}`); - } - - if (isMacintosh) { - this.el.classList.add('mac'); - } - - const onMouseDown = this._register(new DomEmitter(this.el, 'mousedown')).event; - this._register(onMouseDown(e => this.onPointerStart(e, new MouseEventFactory(container)), this)); - const onMouseDoubleClick = this._register(new DomEmitter(this.el, 'dblclick')).event; - this._register(onMouseDoubleClick(this.onPointerDoublePress, this)); - const onMouseEnter = this._register(new DomEmitter(this.el, 'mouseenter')).event; - this._register(onMouseEnter(() => Sash.onMouseEnter(this))); - const onMouseLeave = this._register(new DomEmitter(this.el, 'mouseleave')).event; - this._register(onMouseLeave(() => Sash.onMouseLeave(this))); - - this._register(Gesture.addTarget(this.el)); - - const onTouchStart = this._register(new DomEmitter(this.el, EventType.Start)).event; - this._register(onTouchStart(e => this.onPointerStart(e, new GestureEventFactory(this.el)), this)); - const onTap = this._register(new DomEmitter(this.el, EventType.Tap)).event; - - let doubleTapTimeout: any = undefined; - this._register(onTap(event => { - if (doubleTapTimeout) { - clearTimeout(doubleTapTimeout); - doubleTapTimeout = undefined; - this.onPointerDoublePress(event); - return; - } - - clearTimeout(doubleTapTimeout); - doubleTapTimeout = setTimeout(() => doubleTapTimeout = undefined, 250); - }, this)); - - if (typeof options.size === 'number') { - this.size = options.size; - - if (options.orientation === Orientation.VERTICAL) { - this.el.style.width = `${this.size}px`; - } else { - this.el.style.height = `${this.size}px`; - } - } else { - this.size = globalSize; - this._register(onDidChangeGlobalSize.event(size => { - this.size = size; - this.layout(); - })); - } - - this._register(onDidChangeHoverDelay.event(delay => this.hoverDelay = delay)); - - this.layoutProvider = layoutProvider; - - this.orthogonalStartSash = options.orthogonalStartSash; - this.orthogonalEndSash = options.orthogonalEndSash; - - this.orientation = options.orientation || Orientation.VERTICAL; - - if (this.orientation === Orientation.HORIZONTAL) { - this.el.classList.add('horizontal'); - this.el.classList.remove('vertical'); - } else { - this.el.classList.remove('horizontal'); - this.el.classList.add('vertical'); - } - - this.el.classList.toggle('debug', DEBUG); - - this.layout(); - } - - private onPointerStart(event: PointerEvent, pointerEventFactory: IPointerEventFactory): void { - EventHelper.stop(event); - - let isMultisashResize = false; - - if (!(event as any).__orthogonalSashEvent) { - const orthogonalSash = this.getOrthogonalSash(event); - - if (orthogonalSash) { - isMultisashResize = true; - (event as any).__orthogonalSashEvent = true; - orthogonalSash.onPointerStart(event, new OrthogonalPointerEventFactory(pointerEventFactory)); - } - } - - if (this.linkedSash && !(event as any).__linkedSashEvent) { - (event as any).__linkedSashEvent = true; - this.linkedSash.onPointerStart(event, new OrthogonalPointerEventFactory(pointerEventFactory)); - } - - if (!this.state) { - return; - } - - const iframes = this.el.ownerDocument.getElementsByTagName('iframe'); - for (const iframe of iframes) { - iframe.classList.add(PointerEventsDisabledCssClass); // disable mouse events on iframes as long as we drag the sash - } - - const startX = event.pageX; - const startY = event.pageY; - const altKey = event.altKey; - const startEvent: ISashEvent = { startX, currentX: startX, startY, currentY: startY, altKey }; - - this.el.classList.add('active'); - this._onDidStart.fire(startEvent); - - // fix https://github.com/microsoft/vscode/issues/21675 - const style = createStyleSheet(this.el); - const updateStyle = () => { - let cursor = ''; - - if (isMultisashResize) { - cursor = 'all-scroll'; - } else if (this.orientation === Orientation.HORIZONTAL) { - if (this.state === SashState.AtMinimum) { - cursor = 's-resize'; - } else if (this.state === SashState.AtMaximum) { - cursor = 'n-resize'; - } else { - cursor = isMacintosh ? 'row-resize' : 'ns-resize'; - } - } else { - if (this.state === SashState.AtMinimum) { - cursor = 'e-resize'; - } else if (this.state === SashState.AtMaximum) { - cursor = 'w-resize'; - } else { - cursor = isMacintosh ? 'col-resize' : 'ew-resize'; - } - } - - style.textContent = `* { cursor: ${cursor} !important; }`; - }; - - const disposables = new DisposableStore(); - - updateStyle(); - - if (!isMultisashResize) { - this.onDidEnablementChange.event(updateStyle, null, disposables); - } - - const onPointerMove = (e: PointerEvent) => { - EventHelper.stop(e, false); - const event: ISashEvent = { startX, currentX: e.pageX, startY, currentY: e.pageY, altKey }; - - this._onDidChange.fire(event); - }; - - const onPointerUp = (e: PointerEvent) => { - EventHelper.stop(e, false); - - style.remove(); - - this.el.classList.remove('active'); - this._onDidEnd.fire(); - - disposables.dispose(); - - for (const iframe of iframes) { - iframe.classList.remove(PointerEventsDisabledCssClass); - } - }; - - pointerEventFactory.onPointerMove(onPointerMove, null, disposables); - pointerEventFactory.onPointerUp(onPointerUp, null, disposables); - disposables.add(pointerEventFactory); - } - - private onPointerDoublePress(e: MouseEvent): void { - const orthogonalSash = this.getOrthogonalSash(e); - - if (orthogonalSash) { - orthogonalSash._onDidReset.fire(); - } - - if (this.linkedSash) { - this.linkedSash._onDidReset.fire(); - } - - this._onDidReset.fire(); - } - - private static onMouseEnter(sash: Sash, fromLinkedSash: boolean = false): void { - if (sash.el.classList.contains('active')) { - sash.hoverDelayer.cancel(); - sash.el.classList.add('hover'); - } else { - sash.hoverDelayer.trigger(() => sash.el.classList.add('hover'), sash.hoverDelay).then(undefined, () => { }); - } - - if (!fromLinkedSash && sash.linkedSash) { - Sash.onMouseEnter(sash.linkedSash, true); - } - } - - private static onMouseLeave(sash: Sash, fromLinkedSash: boolean = false): void { - sash.hoverDelayer.cancel(); - sash.el.classList.remove('hover'); - - if (!fromLinkedSash && sash.linkedSash) { - Sash.onMouseLeave(sash.linkedSash, true); - } - } - - /** - * Forcefully stop any user interactions with this sash. - * Useful when hiding a parent component, while the user is still - * interacting with the sash. - */ - clearSashHoverState(): void { - Sash.onMouseLeave(this); - } - - /** - * Layout the sash. The sash will size and position itself - * based on its provided {@link ISashLayoutProvider layout provider}. - */ - layout(): void { - if (this.orientation === Orientation.VERTICAL) { - const verticalProvider = (this.layoutProvider); - this.el.style.left = verticalProvider.getVerticalSashLeft(this) - (this.size / 2) + 'px'; - - if (verticalProvider.getVerticalSashTop) { - this.el.style.top = verticalProvider.getVerticalSashTop(this) + 'px'; - } - - if (verticalProvider.getVerticalSashHeight) { - this.el.style.height = verticalProvider.getVerticalSashHeight(this) + 'px'; - } - } else { - const horizontalProvider = (this.layoutProvider); - this.el.style.top = horizontalProvider.getHorizontalSashTop(this) - (this.size / 2) + 'px'; - - if (horizontalProvider.getHorizontalSashLeft) { - this.el.style.left = horizontalProvider.getHorizontalSashLeft(this) + 'px'; - } - - if (horizontalProvider.getHorizontalSashWidth) { - this.el.style.width = horizontalProvider.getHorizontalSashWidth(this) + 'px'; - } - } - } - - private getOrthogonalSash(e: PointerEvent): Sash | undefined { - const target = e.initialTarget ?? e.target; - - if (!target || !(isHTMLElement(target))) { - return undefined; - } - - if (target.classList.contains('orthogonal-drag-handle')) { - return target.classList.contains('start') ? this.orthogonalStartSash : this.orthogonalEndSash; - } - - return undefined; - } - - override dispose(): void { - super.dispose(); - this.el.remove(); - } -} diff --git a/src/vs/base/browser/ui/splitview/paneview.css b/src/vs/base/browser/ui/splitview/paneview.css deleted file mode 100644 index 62cb3fe642..0000000000 --- a/src/vs/base/browser/ui/splitview/paneview.css +++ /dev/null @@ -1,152 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -.monaco-pane-view { - width: 100%; - height: 100%; -} - -.monaco-pane-view .pane { - overflow: hidden; - width: 100%; - height: 100%; - display: flex; - flex-direction: column; -} - -.monaco-pane-view .pane.horizontal:not(.expanded) { - flex-direction: row; -} - -.monaco-pane-view .pane > .pane-header { - height: 22px; - font-size: 11px; - font-weight: bold; - overflow: hidden; - display: flex; - cursor: pointer; - align-items: center; - box-sizing: border-box; -} - -.monaco-pane-view .pane > .pane-header.not-collapsible { - cursor: default; -} - -.monaco-pane-view .pane > .pane-header > .title { - text-transform: uppercase; -} - -.monaco-pane-view .pane.horizontal:not(.expanded) > .pane-header { - flex-direction: column; - height: 100%; - width: 22px; -} - -.monaco-pane-view .pane > .pane-header > .codicon:first-of-type { - margin: 0 2px; -} - -.monaco-pane-view .pane.horizontal:not(.expanded) > .pane-header > .codicon:first-of-type { - margin: 2px; -} - -/* TODO: actions should be part of the pane, but they aren't yet */ -.monaco-pane-view .pane > .pane-header > .actions { - display: none; - margin-left: auto; -} - -.monaco-pane-view .pane > .pane-header > .actions .action-item { - margin-right: 4px; -} - -.monaco-pane-view .pane > .pane-header > .actions .action-label { - padding: 2px; -} - -/* TODO: actions should be part of the pane, but they aren't yet */ -.monaco-pane-view .pane:hover > .pane-header.expanded > .actions, -.monaco-pane-view .pane:focus-within > .pane-header.expanded > .actions, -.monaco-pane-view .pane > .pane-header.actions-always-visible.expanded > .actions, -.monaco-pane-view .pane > .pane-header.focused.expanded > .actions { - display: initial; -} - -.monaco-pane-view .pane > .pane-header .monaco-action-bar .action-item.select-container { - cursor: default; -} - -.monaco-pane-view .pane > .pane-header .action-item .monaco-select-box { - cursor: pointer; - min-width: 110px; - min-height: 18px; - padding: 2px 23px 2px 8px; -} - -.linux .monaco-pane-view .pane > .pane-header .action-item .monaco-select-box, -.windows .monaco-pane-view .pane > .pane-header .action-item .monaco-select-box { - padding: 0px 23px 0px 8px; -} - -/* Bold font style does not go well with CJK fonts */ -.monaco-pane-view:lang(zh-Hans) .pane > .pane-header, -.monaco-pane-view:lang(zh-Hant) .pane > .pane-header, -.monaco-pane-view:lang(ja) .pane > .pane-header, -.monaco-pane-view:lang(ko) .pane > .pane-header { - font-weight: normal; -} - -.monaco-pane-view .pane > .pane-header.hidden { - display: none; -} - -.monaco-pane-view .pane > .pane-body { - overflow: hidden; - flex: 1; -} - -/* Animation */ - -.monaco-pane-view.animated .split-view-view { - transition-duration: 0.15s; - transition-timing-function: ease-out; -} - -.reduce-motion .monaco-pane-view .split-view-view { - transition-duration: 0s !important; -} - -.monaco-pane-view.animated.vertical .split-view-view { - transition-property: height; -} - -.monaco-pane-view.animated.horizontal .split-view-view { - transition-property: width; -} - -#monaco-pane-drop-overlay { - position: absolute; - z-index: 10000; - width: 100%; - height: 100%; - left: 0; - box-sizing: border-box; -} - -#monaco-pane-drop-overlay > .pane-overlay-indicator { - position: absolute; - width: 100%; - height: 100%; - min-height: 22px; - min-width: 19px; - - pointer-events: none; /* very important to not take events away from the parent */ - transition: opacity 150ms ease-out; -} - -#monaco-pane-drop-overlay > .pane-overlay-indicator.overlay-move-transition { - transition: top 70ms ease-out, left 70ms ease-out, width 70ms ease-out, height 70ms ease-out, opacity 150ms ease-out; -} diff --git a/src/vs/base/browser/ui/splitview/paneview.ts b/src/vs/base/browser/ui/splitview/paneview.ts deleted file mode 100644 index 464bcdded7..0000000000 --- a/src/vs/base/browser/ui/splitview/paneview.ts +++ /dev/null @@ -1,681 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { isFirefox } from 'vs/base/browser/browser'; -import { DataTransfers } from 'vs/base/browser/dnd'; -import { $, addDisposableListener, append, clearNode, EventHelper, EventType, getWindow, isHTMLElement, trackFocus } from 'vs/base/browser/dom'; -import { DomEmitter } from 'vs/base/browser/event'; -import { StandardKeyboardEvent } from 'vs/base/browser/keyboardEvent'; -import { Gesture, EventType as TouchEventType } from 'vs/base/browser/touch'; -import { IBoundarySashes, Orientation } from 'vs/base/browser/ui/sash/sash'; -import { Color, RGBA } from 'vs/base/common/color'; -import { Emitter, Event } from 'vs/base/common/event'; -import { KeyCode } from 'vs/base/common/keyCodes'; -import { Disposable, DisposableStore, IDisposable } from 'vs/base/common/lifecycle'; -import { ScrollEvent } from 'vs/base/common/scrollable'; -// import 'vs/css!./paneview'; -import { localize } from 'vs/nls'; -import { IView, Sizing, SplitView } from './splitview'; - -export interface IPaneOptions { - minimumBodySize?: number; - maximumBodySize?: number; - expanded?: boolean; - orientation?: Orientation; - title: string; - titleDescription?: string; -} - -export interface IPaneStyles { - readonly dropBackground: string | undefined; - readonly headerForeground: string | undefined; - readonly headerBackground: string | undefined; - readonly headerBorder: string | undefined; - readonly leftBorder: string | undefined; -} - -/** - * A Pane is a structured SplitView view. - * - * WARNING: You must call `render()` after you construct it. - * It can't be done automatically at the end of the ctor - * because of the order of property initialization in TypeScript. - * Subclasses wouldn't be able to set own properties - * before the `render()` call, thus forbidding their use. - */ -export abstract class Pane extends Disposable implements IView { - - private static readonly HEADER_SIZE = 22; - - readonly element: HTMLElement; - private header!: HTMLElement; - private body!: HTMLElement; - - protected _expanded: boolean; - protected _orientation: Orientation; - - private expandedSize: number | undefined = undefined; - private _headerVisible = true; - private _collapsible = true; - private _bodyRendered = false; - private _minimumBodySize: number; - private _maximumBodySize: number; - private _ariaHeaderLabel: string; - private styles: IPaneStyles = { - dropBackground: undefined, - headerBackground: undefined, - headerBorder: undefined, - headerForeground: undefined, - leftBorder: undefined - }; - private animationTimer: number | undefined = undefined; - - private readonly _onDidChange = this._register(new Emitter()); - readonly onDidChange: Event = this._onDidChange.event; - - private readonly _onDidChangeExpansionState = this._register(new Emitter()); - readonly onDidChangeExpansionState: Event = this._onDidChangeExpansionState.event; - - get ariaHeaderLabel(): string { - return this._ariaHeaderLabel; - } - - set ariaHeaderLabel(newLabel: string) { - this._ariaHeaderLabel = newLabel; - this.header.setAttribute('aria-label', this.ariaHeaderLabel); - } - - get draggableElement(): HTMLElement { - return this.header; - } - - get dropTargetElement(): HTMLElement { - return this.element; - } - - get dropBackground(): string | undefined { - return this.styles.dropBackground; - } - - get minimumBodySize(): number { - return this._minimumBodySize; - } - - set minimumBodySize(size: number) { - this._minimumBodySize = size; - this._onDidChange.fire(undefined); - } - - get maximumBodySize(): number { - return this._maximumBodySize; - } - - set maximumBodySize(size: number) { - this._maximumBodySize = size; - this._onDidChange.fire(undefined); - } - - private get headerSize(): number { - return this.headerVisible ? Pane.HEADER_SIZE : 0; - } - - get minimumSize(): number { - const headerSize = this.headerSize; - const expanded = !this.headerVisible || this.isExpanded(); - const minimumBodySize = expanded ? this.minimumBodySize : 0; - - return headerSize + minimumBodySize; - } - - get maximumSize(): number { - const headerSize = this.headerSize; - const expanded = !this.headerVisible || this.isExpanded(); - const maximumBodySize = expanded ? this.maximumBodySize : 0; - - return headerSize + maximumBodySize; - } - - orthogonalSize: number = 0; - - constructor(options: IPaneOptions) { - super(); - this._expanded = typeof options.expanded === 'undefined' ? true : !!options.expanded; - this._orientation = typeof options.orientation === 'undefined' ? Orientation.VERTICAL : options.orientation; - this._ariaHeaderLabel = localize('viewSection', "{0} Section", options.title); - this._minimumBodySize = typeof options.minimumBodySize === 'number' ? options.minimumBodySize : this._orientation === Orientation.HORIZONTAL ? 200 : 120; - this._maximumBodySize = typeof options.maximumBodySize === 'number' ? options.maximumBodySize : Number.POSITIVE_INFINITY; - - this.element = $('.pane'); - } - - isExpanded(): boolean { - return this._expanded; - } - - setExpanded(expanded: boolean): boolean { - if (!expanded && !this.collapsible) { - return false; - } - - if (this._expanded === !!expanded) { - return false; - } - - this.element?.classList.toggle('expanded', expanded); - - this._expanded = !!expanded; - this.updateHeader(); - - if (expanded) { - if (!this._bodyRendered) { - this.renderBody(this.body); - this._bodyRendered = true; - } - - if (typeof this.animationTimer === 'number') { - getWindow(this.element).clearTimeout(this.animationTimer); - } - append(this.element, this.body); - } else { - this.animationTimer = getWindow(this.element).setTimeout(() => { - this.body.remove(); - }, 200); - } - - this._onDidChangeExpansionState.fire(expanded); - this._onDidChange.fire(expanded ? this.expandedSize : undefined); - return true; - } - - get headerVisible(): boolean { - return this._headerVisible; - } - - set headerVisible(visible: boolean) { - if (this._headerVisible === !!visible) { - return; - } - - this._headerVisible = !!visible; - this.updateHeader(); - this._onDidChange.fire(undefined); - } - - get collapsible(): boolean { - return this._collapsible; - } - - set collapsible(collapsible: boolean) { - if (this._collapsible === !!collapsible) { - return; - } - - this._collapsible = !!collapsible; - this.updateHeader(); - } - - get orientation(): Orientation { - return this._orientation; - } - - set orientation(orientation: Orientation) { - if (this._orientation === orientation) { - return; - } - - this._orientation = orientation; - - if (this.element) { - this.element.classList.toggle('horizontal', this.orientation === Orientation.HORIZONTAL); - this.element.classList.toggle('vertical', this.orientation === Orientation.VERTICAL); - } - - if (this.header) { - this.updateHeader(); - } - } - - render(): void { - this.element.classList.toggle('expanded', this.isExpanded()); - this.element.classList.toggle('horizontal', this.orientation === Orientation.HORIZONTAL); - this.element.classList.toggle('vertical', this.orientation === Orientation.VERTICAL); - - this.header = $('.pane-header'); - append(this.element, this.header); - this.header.setAttribute('tabindex', '0'); - // Use role button so the aria-expanded state gets read https://github.com/microsoft/vscode/issues/95996 - this.header.setAttribute('role', 'button'); - this.header.setAttribute('aria-label', this.ariaHeaderLabel); - this.renderHeader(this.header); - - const focusTracker = trackFocus(this.header); - this._register(focusTracker); - this._register(focusTracker.onDidFocus(() => this.header.classList.add('focused'), null)); - this._register(focusTracker.onDidBlur(() => this.header.classList.remove('focused'), null)); - - this.updateHeader(); - - const eventDisposables = this._register(new DisposableStore()); - const onKeyDown = this._register(new DomEmitter(this.header, 'keydown')); - const onHeaderKeyDown = Event.map(onKeyDown.event, e => new StandardKeyboardEvent(e), eventDisposables); - - this._register(Event.filter(onHeaderKeyDown, e => e.keyCode === KeyCode.Enter || e.keyCode === KeyCode.Space, eventDisposables)(() => this.setExpanded(!this.isExpanded()), null)); - - this._register(Event.filter(onHeaderKeyDown, e => e.keyCode === KeyCode.LeftArrow, eventDisposables)(() => this.setExpanded(false), null)); - - this._register(Event.filter(onHeaderKeyDown, e => e.keyCode === KeyCode.RightArrow, eventDisposables)(() => this.setExpanded(true), null)); - - this._register(Gesture.addTarget(this.header)); - - [EventType.CLICK, TouchEventType.Tap].forEach(eventType => { - this._register(addDisposableListener(this.header, eventType, e => { - if (!e.defaultPrevented) { - this.setExpanded(!this.isExpanded()); - } - })); - }); - - this.body = append(this.element, $('.pane-body')); - - // Only render the body if it will be visible - // Otherwise, render it when the pane is expanded - if (!this._bodyRendered && this.isExpanded()) { - this.renderBody(this.body); - this._bodyRendered = true; - } - - if (!this.isExpanded()) { - this.body.remove(); - } - } - - layout(size: number): void { - const headerSize = this.headerVisible ? Pane.HEADER_SIZE : 0; - - const width = this._orientation === Orientation.VERTICAL ? this.orthogonalSize : size; - const height = this._orientation === Orientation.VERTICAL ? size - headerSize : this.orthogonalSize - headerSize; - - if (this.isExpanded()) { - this.body.classList.toggle('wide', width >= 600); - this.layoutBody(height, width); - this.expandedSize = size; - } - } - - style(styles: IPaneStyles): void { - this.styles = styles; - - if (!this.header) { - return; - } - - this.updateHeader(); - } - - protected updateHeader(): void { - const expanded = !this.headerVisible || this.isExpanded(); - - if (this.collapsible) { - this.header.setAttribute('tabindex', '0'); - this.header.setAttribute('role', 'button'); - } else { - this.header.removeAttribute('tabindex'); - this.header.removeAttribute('role'); - } - - this.header.style.lineHeight = `${this.headerSize}px`; - this.header.classList.toggle('hidden', !this.headerVisible); - this.header.classList.toggle('expanded', expanded); - this.header.classList.toggle('not-collapsible', !this.collapsible); - this.header.setAttribute('aria-expanded', String(expanded)); - - this.header.style.color = this.collapsible ? this.styles.headerForeground ?? '' : ''; - this.header.style.backgroundColor = (this.collapsible ? this.styles.headerBackground : 'transparent') ?? ''; - this.header.style.borderTop = this.styles.headerBorder && this.orientation === Orientation.VERTICAL ? `1px solid ${this.styles.headerBorder}` : ''; - this.element.style.borderLeft = this.styles.leftBorder && this.orientation === Orientation.HORIZONTAL ? `1px solid ${this.styles.leftBorder}` : ''; - } - - protected abstract renderHeader(container: HTMLElement): void; - protected abstract renderBody(container: HTMLElement): void; - protected abstract layoutBody(height: number, width: number): void; -} - -interface IDndContext { - draggable: PaneDraggable | null; -} - -class PaneDraggable extends Disposable { - - private static readonly DefaultDragOverBackgroundColor = new Color(new RGBA(128, 128, 128, 0.5)); - - private dragOverCounter = 0; // see https://github.com/microsoft/vscode/issues/14470 - - private _onDidDrop = this._register(new Emitter<{ from: Pane; to: Pane }>()); - readonly onDidDrop = this._onDidDrop.event; - - constructor(private pane: Pane, private dnd: IPaneDndController, private context: IDndContext) { - super(); - - pane.draggableElement.draggable = true; - this._register(addDisposableListener(pane.draggableElement, 'dragstart', e => this.onDragStart(e))); - this._register(addDisposableListener(pane.dropTargetElement, 'dragenter', e => this.onDragEnter(e))); - this._register(addDisposableListener(pane.dropTargetElement, 'dragleave', e => this.onDragLeave(e))); - this._register(addDisposableListener(pane.dropTargetElement, 'dragend', e => this.onDragEnd(e))); - this._register(addDisposableListener(pane.dropTargetElement, 'drop', e => this.onDrop(e))); - } - - private onDragStart(e: DragEvent): void { - if (!this.dnd.canDrag(this.pane) || !e.dataTransfer) { - e.preventDefault(); - e.stopPropagation(); - return; - } - - e.dataTransfer.effectAllowed = 'move'; - - if (isFirefox) { - // Firefox: requires to set a text data transfer to get going - e.dataTransfer?.setData(DataTransfers.TEXT, this.pane.draggableElement.textContent || ''); - } - - const dragImage = append(this.pane.element.ownerDocument.body, $('.monaco-drag-image', {}, this.pane.draggableElement.textContent || '')); - e.dataTransfer.setDragImage(dragImage, -10, -10); - setTimeout(() => dragImage.remove(), 0); - - this.context.draggable = this; - } - - private onDragEnter(e: DragEvent): void { - if (!this.context.draggable || this.context.draggable === this) { - return; - } - - if (!this.dnd.canDrop(this.context.draggable.pane, this.pane)) { - return; - } - - this.dragOverCounter++; - this.render(); - } - - private onDragLeave(e: DragEvent): void { - if (!this.context.draggable || this.context.draggable === this) { - return; - } - - if (!this.dnd.canDrop(this.context.draggable.pane, this.pane)) { - return; - } - - this.dragOverCounter--; - - if (this.dragOverCounter === 0) { - this.render(); - } - } - - private onDragEnd(e: DragEvent): void { - if (!this.context.draggable) { - return; - } - - this.dragOverCounter = 0; - this.render(); - this.context.draggable = null; - } - - private onDrop(e: DragEvent): void { - if (!this.context.draggable) { - return; - } - - EventHelper.stop(e); - - this.dragOverCounter = 0; - this.render(); - - if (this.dnd.canDrop(this.context.draggable.pane, this.pane) && this.context.draggable !== this) { - this._onDidDrop.fire({ from: this.context.draggable.pane, to: this.pane }); - } - - this.context.draggable = null; - } - - private render(): void { - let backgroundColor: string | null = null; - - if (this.dragOverCounter > 0) { - backgroundColor = this.pane.dropBackground ?? PaneDraggable.DefaultDragOverBackgroundColor.toString(); - } - - this.pane.dropTargetElement.style.backgroundColor = backgroundColor || ''; - } -} - -export interface IPaneDndController { - canDrag(pane: Pane): boolean; - canDrop(pane: Pane, overPane: Pane): boolean; -} - -export class DefaultPaneDndController implements IPaneDndController { - - canDrag(pane: Pane): boolean { - return true; - } - - canDrop(pane: Pane, overPane: Pane): boolean { - return true; - } -} - -export interface IPaneViewOptions { - dnd?: IPaneDndController; - orientation?: Orientation; -} - -interface IPaneItem { - pane: Pane; - disposable: IDisposable; -} - -export class PaneView extends Disposable { - - private dnd: IPaneDndController | undefined; - private dndContext: IDndContext = { draggable: null }; - readonly element: HTMLElement; - private paneItems: IPaneItem[] = []; - private orthogonalSize: number = 0; - private size: number = 0; - private splitview: SplitView; - private animationTimer: number | undefined = undefined; - - private _onDidDrop = this._register(new Emitter<{ from: Pane; to: Pane }>()); - readonly onDidDrop: Event<{ from: Pane; to: Pane }> = this._onDidDrop.event; - - orientation: Orientation; - private boundarySashes: IBoundarySashes | undefined; - readonly onDidSashChange: Event; - readonly onDidSashReset: Event; - readonly onDidScroll: Event; - - constructor(container: HTMLElement, options: IPaneViewOptions = {}) { - super(); - - this.dnd = options.dnd; - this.orientation = options.orientation ?? Orientation.VERTICAL; - this.element = append(container, $('.monaco-pane-view')); - this.splitview = this._register(new SplitView(this.element, { orientation: this.orientation })); - this.onDidSashReset = this.splitview.onDidSashReset; - this.onDidSashChange = this.splitview.onDidSashChange; - this.onDidScroll = this.splitview.onDidScroll; - - const eventDisposables = this._register(new DisposableStore()); - const onKeyDown = this._register(new DomEmitter(this.element, 'keydown')); - const onHeaderKeyDown = Event.map(Event.filter(onKeyDown.event, e => isHTMLElement(e.target) && e.target.classList.contains('pane-header'), eventDisposables), e => new StandardKeyboardEvent(e), eventDisposables); - - this._register(Event.filter(onHeaderKeyDown, e => e.keyCode === KeyCode.UpArrow, eventDisposables)(() => this.focusPrevious())); - this._register(Event.filter(onHeaderKeyDown, e => e.keyCode === KeyCode.DownArrow, eventDisposables)(() => this.focusNext())); - } - - addPane(pane: Pane, size: number, index = this.splitview.length): void { - const disposables = new DisposableStore(); - pane.onDidChangeExpansionState(this.setupAnimation, this, disposables); - - const paneItem = { pane: pane, disposable: disposables }; - this.paneItems.splice(index, 0, paneItem); - pane.orientation = this.orientation; - pane.orthogonalSize = this.orthogonalSize; - this.splitview.addView(pane, size, index); - - if (this.dnd) { - const draggable = new PaneDraggable(pane, this.dnd, this.dndContext); - disposables.add(draggable); - disposables.add(draggable.onDidDrop(this._onDidDrop.fire, this._onDidDrop)); - } - } - - removePane(pane: Pane): void { - const index = this.paneItems.findIndex(item => item.pane === pane); - - if (index === -1) { - return; - } - - this.splitview.removeView(index, pane.isExpanded() ? Sizing.Distribute : undefined); - const paneItem = this.paneItems.splice(index, 1)[0]; - paneItem.disposable.dispose(); - } - - movePane(from: Pane, to: Pane): void { - const fromIndex = this.paneItems.findIndex(item => item.pane === from); - const toIndex = this.paneItems.findIndex(item => item.pane === to); - - if (fromIndex === -1 || toIndex === -1) { - return; - } - - const [paneItem] = this.paneItems.splice(fromIndex, 1); - this.paneItems.splice(toIndex, 0, paneItem); - - this.splitview.moveView(fromIndex, toIndex); - } - - resizePane(pane: Pane, size: number): void { - const index = this.paneItems.findIndex(item => item.pane === pane); - - if (index === -1) { - return; - } - - this.splitview.resizeView(index, size); - } - - getPaneSize(pane: Pane): number { - const index = this.paneItems.findIndex(item => item.pane === pane); - - if (index === -1) { - return -1; - } - - return this.splitview.getViewSize(index); - } - - layout(height: number, width: number): void { - this.orthogonalSize = this.orientation === Orientation.VERTICAL ? width : height; - this.size = this.orientation === Orientation.HORIZONTAL ? width : height; - - for (const paneItem of this.paneItems) { - paneItem.pane.orthogonalSize = this.orthogonalSize; - } - - this.splitview.layout(this.size); - } - - setBoundarySashes(sashes: IBoundarySashes) { - this.boundarySashes = sashes; - this.updateSplitviewOrthogonalSashes(sashes); - } - - private updateSplitviewOrthogonalSashes(sashes: IBoundarySashes | undefined) { - if (this.orientation === Orientation.VERTICAL) { - this.splitview.orthogonalStartSash = sashes?.left; - this.splitview.orthogonalEndSash = sashes?.right; - } else { - this.splitview.orthogonalEndSash = sashes?.bottom; - } - } - - flipOrientation(height: number, width: number): void { - this.orientation = this.orientation === Orientation.VERTICAL ? Orientation.HORIZONTAL : Orientation.VERTICAL; - const paneSizes = this.paneItems.map(pane => this.getPaneSize(pane.pane)); - - this.splitview.dispose(); - clearNode(this.element); - - this.splitview = this._register(new SplitView(this.element, { orientation: this.orientation })); - this.updateSplitviewOrthogonalSashes(this.boundarySashes); - - const newOrthogonalSize = this.orientation === Orientation.VERTICAL ? width : height; - const newSize = this.orientation === Orientation.HORIZONTAL ? width : height; - - this.paneItems.forEach((pane, index) => { - pane.pane.orthogonalSize = newOrthogonalSize; - pane.pane.orientation = this.orientation; - - const viewSize = this.size === 0 ? 0 : (newSize * paneSizes[index]) / this.size; - this.splitview.addView(pane.pane, viewSize, index); - }); - - this.size = newSize; - this.orthogonalSize = newOrthogonalSize; - - this.splitview.layout(this.size); - } - - private setupAnimation(): void { - if (typeof this.animationTimer === 'number') { - getWindow(this.element).clearTimeout(this.animationTimer); - } - - this.element.classList.add('animated'); - - this.animationTimer = getWindow(this.element).setTimeout(() => { - this.animationTimer = undefined; - this.element.classList.remove('animated'); - }, 200); - } - - private getPaneHeaderElements(): HTMLElement[] { - return [...this.element.querySelectorAll('.pane-header')] as HTMLElement[]; - } - - private focusPrevious(): void { - const headers = this.getPaneHeaderElements(); - const index = headers.indexOf(this.element.ownerDocument.activeElement as HTMLElement); - - if (index === -1) { - return; - } - - headers[Math.max(index - 1, 0)].focus(); - } - - private focusNext(): void { - const headers = this.getPaneHeaderElements(); - const index = headers.indexOf(this.element.ownerDocument.activeElement as HTMLElement); - - if (index === -1) { - return; - } - - headers[Math.min(index + 1, headers.length - 1)].focus(); - } - - override dispose(): void { - super.dispose(); - - this.paneItems.forEach(i => i.disposable.dispose()); - } -} diff --git a/src/vs/base/browser/ui/splitview/splitview.css b/src/vs/base/browser/ui/splitview/splitview.css deleted file mode 100644 index 3af3e9062d..0000000000 --- a/src/vs/base/browser/ui/splitview/splitview.css +++ /dev/null @@ -1,70 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -.monaco-split-view2 { - position: relative; - width: 100%; - height: 100%; -} - -.monaco-split-view2 > .sash-container { - position: absolute; - width: 100%; - height: 100%; - pointer-events: none; -} - -.monaco-split-view2 > .sash-container > .monaco-sash { - pointer-events: initial; -} - -.monaco-split-view2 > .monaco-scrollable-element { - width: 100%; - height: 100%; -} - -.monaco-split-view2 > .monaco-scrollable-element > .split-view-container { - width: 100%; - height: 100%; - white-space: nowrap; - position: relative; -} - -.monaco-split-view2 > .monaco-scrollable-element > .split-view-container > .split-view-view { - white-space: initial; - position: absolute; -} - -.monaco-split-view2 > .monaco-scrollable-element > .split-view-container > .split-view-view:not(.visible) { - display: none; -} - -.monaco-split-view2.vertical > .monaco-scrollable-element > .split-view-container > .split-view-view { - width: 100%; -} - -.monaco-split-view2.horizontal > .monaco-scrollable-element > .split-view-container > .split-view-view { - height: 100%; -} - -.monaco-split-view2.separator-border > .monaco-scrollable-element > .split-view-container > .split-view-view:not(:first-child)::before { - content: ' '; - position: absolute; - top: 0; - left: 0; - z-index: 5; - pointer-events: none; - background-color: var(--separator-border); -} - -.monaco-split-view2.separator-border.horizontal > .monaco-scrollable-element > .split-view-container > .split-view-view:not(:first-child)::before { - height: 100%; - width: 1px; -} - -.monaco-split-view2.separator-border.vertical > .monaco-scrollable-element > .split-view-container > .split-view-view:not(:first-child)::before { - height: 1px; - width: 100%; -} diff --git a/src/vs/base/browser/ui/splitview/splitview.ts b/src/vs/base/browser/ui/splitview/splitview.ts deleted file mode 100644 index e07e76e776..0000000000 --- a/src/vs/base/browser/ui/splitview/splitview.ts +++ /dev/null @@ -1,1504 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { $, addDisposableListener, append, getWindow, scheduleAtNextAnimationFrame } from 'vs/base/browser/dom'; -import { DomEmitter } from 'vs/base/browser/event'; -import { ISashEvent as IBaseSashEvent, Orientation, Sash, SashState } from 'vs/base/browser/ui/sash/sash'; -import { SmoothScrollableElement } from 'vs/base/browser/ui/scrollbar/scrollableElement'; -import { pushToEnd, pushToStart, range } from 'vs/base/common/arrays'; -import { Color } from 'vs/base/common/color'; -import { Emitter, Event } from 'vs/base/common/event'; -import { combinedDisposable, Disposable, dispose, IDisposable, toDisposable } from 'vs/base/common/lifecycle'; -import { clamp } from 'vs/base/common/numbers'; -import { Scrollable, ScrollbarVisibility, ScrollEvent } from 'vs/base/common/scrollable'; -import * as types from 'vs/base/common/types'; -// import 'vs/css!./splitview'; -export { Orientation } from 'vs/base/browser/ui/sash/sash'; - -export interface ISplitViewStyles { - readonly separatorBorder: Color; -} - -const defaultStyles: ISplitViewStyles = { - separatorBorder: Color.transparent -}; - -export const enum LayoutPriority { - Normal, - Low, - High -} - -/** - * The interface to implement for views within a {@link SplitView}. - * - * An optional {@link TLayoutContext layout context type} may be used in order to - * pass along layout contextual data from the {@link SplitView.layout} method down - * to each view's {@link IView.layout} calls. - */ -export interface IView { - - /** - * The DOM element for this view. - */ - readonly element: HTMLElement; - - /** - * A minimum size for this view. - * - * @remarks If none, set it to `0`. - */ - readonly minimumSize: number; - - /** - * A maximum size for this view. - * - * @remarks If none, set it to `Number.POSITIVE_INFINITY`. - */ - readonly maximumSize: number; - - /** - * The priority of the view when the {@link SplitView.resize layout} algorithm - * runs. Views with higher priority will be resized first. - * - * @remarks Only used when `proportionalLayout` is false. - */ - readonly priority?: LayoutPriority; - - /** - * If the {@link SplitView} supports {@link ISplitViewOptions.proportionalLayout proportional layout}, - * this property allows for finer control over the proportional layout algorithm, per view. - * - * @defaultValue `true` - */ - readonly proportionalLayout?: boolean; - - /** - * Whether the view will snap whenever the user reaches its minimum size or - * attempts to grow it beyond the minimum size. - * - * @defaultValue `false` - */ - readonly snap?: boolean; - - /** - * View instances are supposed to fire the {@link IView.onDidChange} event whenever - * any of the constraint properties have changed: - * - * - {@link IView.minimumSize} - * - {@link IView.maximumSize} - * - {@link IView.priority} - * - {@link IView.snap} - * - * The SplitView will relayout whenever that happens. The event can optionally emit - * the view's preferred size for that relayout. - */ - readonly onDidChange: Event; - - /** - * This will be called by the {@link SplitView} during layout. A view meant to - * pass along the layout information down to its descendants. - * - * @param size The size of this view, in pixels. - * @param offset The offset of this view, relative to the start of the {@link SplitView}. - * @param context The optional {@link IView layout context} passed to {@link SplitView.layout}. - */ - layout(size: number, offset: number, context: TLayoutContext | undefined): void; - - /** - * This will be called by the {@link SplitView} whenever this view is made - * visible or hidden. - * - * @param visible Whether the view becomes visible. - */ - setVisible?(visible: boolean): void; -} - -/** - * A descriptor for a {@link SplitView} instance. - */ -export interface ISplitViewDescriptor = IView> { - - /** - * The layout size of the {@link SplitView}. - */ - readonly size: number; - - /** - * Descriptors for each {@link IView view}. - */ - readonly views: { - - /** - * Whether the {@link IView view} is visible. - * - * @defaultValue `true` - */ - readonly visible?: boolean; - - /** - * The size of the {@link IView view}. - * - * @defaultValue `true` - */ - readonly size: number; - - /** - * The size of the {@link IView view}. - * - * @defaultValue `true` - */ - readonly view: TView; - }[]; -} - -export interface ISplitViewOptions = IView> { - - /** - * Which axis the views align on. - * - * @defaultValue `Orientation.VERTICAL` - */ - readonly orientation?: Orientation; - - /** - * Styles overriding the {@link defaultStyles default ones}. - */ - readonly styles?: ISplitViewStyles; - - /** - * Make Alt-drag the default drag operation. - */ - readonly inverseAltBehavior?: boolean; - - /** - * Resize each view proportionally when resizing the SplitView. - * - * @defaultValue `true` - */ - readonly proportionalLayout?: boolean; - - /** - * An initial description of this {@link SplitView} instance, allowing - * to initialze all views within the ctor. - */ - readonly descriptor?: ISplitViewDescriptor; - - /** - * The scrollbar visibility setting for whenever the views within - * the {@link SplitView} overflow. - */ - readonly scrollbarVisibility?: ScrollbarVisibility; - - /** - * Override the orthogonal size of sashes. - */ - readonly getSashOrthogonalSize?: () => number; -} - -interface ISashEvent { - readonly sash: Sash; - readonly start: number; - readonly current: number; - readonly alt: boolean; -} - -type ViewItemSize = number | { cachedVisibleSize: number }; - -abstract class ViewItem> { - - private _size: number; - set size(size: number) { - this._size = size; - } - - get size(): number { - return this._size; - } - - private _cachedVisibleSize: number | undefined = undefined; - get cachedVisibleSize(): number | undefined { return this._cachedVisibleSize; } - - get visible(): boolean { - return typeof this._cachedVisibleSize === 'undefined'; - } - - setVisible(visible: boolean, size?: number): void { - if (visible === this.visible) { - return; - } - - if (visible) { - this.size = clamp(this._cachedVisibleSize!, this.viewMinimumSize, this.viewMaximumSize); - this._cachedVisibleSize = undefined; - } else { - this._cachedVisibleSize = typeof size === 'number' ? size : this.size; - this.size = 0; - } - - this.container.classList.toggle('visible', visible); - - try { - this.view.setVisible?.(visible); - } catch (e) { - console.error('Splitview: Failed to set visible view'); - console.error(e); - } - } - - get minimumSize(): number { return this.visible ? this.view.minimumSize : 0; } - get viewMinimumSize(): number { return this.view.minimumSize; } - - get maximumSize(): number { return this.visible ? this.view.maximumSize : 0; } - get viewMaximumSize(): number { return this.view.maximumSize; } - - get priority(): LayoutPriority | undefined { return this.view.priority; } - get proportionalLayout(): boolean { return this.view.proportionalLayout ?? true; } - get snap(): boolean { return !!this.view.snap; } - - set enabled(enabled: boolean) { - this.container.style.pointerEvents = enabled ? '' : 'none'; - } - - constructor( - protected container: HTMLElement, - readonly view: TView, - size: ViewItemSize, - private disposable: IDisposable - ) { - if (typeof size === 'number') { - this._size = size; - this._cachedVisibleSize = undefined; - container.classList.add('visible'); - } else { - this._size = 0; - this._cachedVisibleSize = size.cachedVisibleSize; - } - } - - layout(offset: number, layoutContext: TLayoutContext | undefined): void { - this.layoutContainer(offset); - - try { - this.view.layout(this.size, offset, layoutContext); - } catch (e) { - console.error('Splitview: Failed to layout view'); - console.error(e); - } - } - - abstract layoutContainer(offset: number): void; - - dispose(): void { - this.disposable.dispose(); - } -} - -class VerticalViewItem> extends ViewItem { - - layoutContainer(offset: number): void { - this.container.style.top = `${offset}px`; - this.container.style.height = `${this.size}px`; - } -} - -class HorizontalViewItem> extends ViewItem { - - layoutContainer(offset: number): void { - this.container.style.left = `${offset}px`; - this.container.style.width = `${this.size}px`; - } -} - -interface ISashItem { - sash: Sash; - disposable: IDisposable; -} - -interface ISashDragSnapState { - readonly index: number; - readonly limitDelta: number; - readonly size: number; -} - -interface ISashDragState { - index: number; - start: number; - current: number; - sizes: number[]; - minDelta: number; - maxDelta: number; - alt: boolean; - snapBefore: ISashDragSnapState | undefined; - snapAfter: ISashDragSnapState | undefined; - disposable: IDisposable; -} - -enum State { - Idle, - Busy -} - -/** - * When adding or removing views, uniformly distribute the entire split view space among - * all views. - */ -export type DistributeSizing = { type: 'distribute' }; - -/** - * When adding a view, make space for it by reducing the size of another view, - * indexed by the provided `index`. - */ -export type SplitSizing = { type: 'split'; index: number }; - -/** - * When adding a view, use DistributeSizing when all pre-existing views are - * distributed evenly, otherwise use SplitSizing. - */ -export type AutoSizing = { type: 'auto'; index: number }; - -/** - * When adding or removing views, assume the view is invisible. - */ -export type InvisibleSizing = { type: 'invisible'; cachedVisibleSize: number }; - -/** - * When adding or removing views, the sizing provides fine grained - * control over how other views get resized. - */ -export type Sizing = DistributeSizing | SplitSizing | AutoSizing | InvisibleSizing; - -export namespace Sizing { - - /** - * When adding or removing views, distribute the delta space among - * all other views. - */ - export const Distribute: DistributeSizing = { type: 'distribute' }; - - /** - * When adding or removing views, split the delta space with another - * specific view, indexed by the provided `index`. - */ - export function Split(index: number): SplitSizing { return { type: 'split', index }; } - - /** - * When adding a view, use DistributeSizing when all pre-existing views are - * distributed evenly, otherwise use SplitSizing. - */ - export function Auto(index: number): AutoSizing { return { type: 'auto', index }; } - - /** - * When adding or removing views, assume the view is invisible. - */ - export function Invisible(cachedVisibleSize: number): InvisibleSizing { return { type: 'invisible', cachedVisibleSize }; } -} - -/** - * The {@link SplitView} is the UI component which implements a one dimensional - * flex-like layout algorithm for a collection of {@link IView} instances, which - * are essentially HTMLElement instances with the following size constraints: - * - * - {@link IView.minimumSize} - * - {@link IView.maximumSize} - * - {@link IView.priority} - * - {@link IView.snap} - * - * In case the SplitView doesn't have enough size to fit all views, it will overflow - * its content with a scrollbar. - * - * In between each pair of views there will be a {@link Sash} allowing the user - * to resize the views, making sure the constraints are respected. - * - * An optional {@link TLayoutContext layout context type} may be used in order to - * pass along layout contextual data from the {@link SplitView.layout} method down - * to each view's {@link IView.layout} calls. - * - * Features: - * - Flex-like layout algorithm - * - Snap support - * - Orthogonal sash support, for corner sashes - * - View hide/show support - * - View swap/move support - * - Alt key modifier behavior, macOS style - */ -export class SplitView = IView> extends Disposable { - - /** - * This {@link SplitView}'s orientation. - */ - readonly orientation: Orientation; - - /** - * The DOM element representing this {@link SplitView}. - */ - readonly el: HTMLElement; - - private sashContainer: HTMLElement; - private viewContainer: HTMLElement; - private scrollable: Scrollable; - private scrollableElement: SmoothScrollableElement; - private size = 0; - private layoutContext: TLayoutContext | undefined; - private _contentSize = 0; - private proportions: (number | undefined)[] | undefined = undefined; - private viewItems: ViewItem[] = []; - sashItems: ISashItem[] = []; // used in tests - private sashDragState: ISashDragState | undefined; - private state: State = State.Idle; - private inverseAltBehavior: boolean; - private proportionalLayout: boolean; - private readonly getSashOrthogonalSize: { (): number } | undefined; - - private _onDidSashChange = this._register(new Emitter()); - private _onDidSashReset = this._register(new Emitter()); - private _orthogonalStartSash: Sash | undefined; - private _orthogonalEndSash: Sash | undefined; - private _startSnappingEnabled = true; - private _endSnappingEnabled = true; - - /** - * The sum of all views' sizes. - */ - get contentSize(): number { return this._contentSize; } - - /** - * Fires whenever the user resizes a {@link Sash sash}. - */ - readonly onDidSashChange = this._onDidSashChange.event; - - /** - * Fires whenever the user double clicks a {@link Sash sash}. - */ - readonly onDidSashReset = this._onDidSashReset.event; - - /** - * Fires whenever the split view is scrolled. - */ - readonly onDidScroll: Event; - - /** - * The amount of views in this {@link SplitView}. - */ - get length(): number { - return this.viewItems.length; - } - - /** - * The minimum size of this {@link SplitView}. - */ - get minimumSize(): number { - return this.viewItems.reduce((r, item) => r + item.minimumSize, 0); - } - - /** - * The maximum size of this {@link SplitView}. - */ - get maximumSize(): number { - return this.length === 0 ? Number.POSITIVE_INFINITY : this.viewItems.reduce((r, item) => r + item.maximumSize, 0); - } - - get orthogonalStartSash(): Sash | undefined { return this._orthogonalStartSash; } - get orthogonalEndSash(): Sash | undefined { return this._orthogonalEndSash; } - get startSnappingEnabled(): boolean { return this._startSnappingEnabled; } - get endSnappingEnabled(): boolean { return this._endSnappingEnabled; } - - /** - * A reference to a sash, perpendicular to all sashes in this {@link SplitView}, - * located at the left- or top-most side of the SplitView. - * Corner sashes will be created automatically at the intersections. - */ - set orthogonalStartSash(sash: Sash | undefined) { - for (const sashItem of this.sashItems) { - sashItem.sash.orthogonalStartSash = sash; - } - - this._orthogonalStartSash = sash; - } - - /** - * A reference to a sash, perpendicular to all sashes in this {@link SplitView}, - * located at the right- or bottom-most side of the SplitView. - * Corner sashes will be created automatically at the intersections. - */ - set orthogonalEndSash(sash: Sash | undefined) { - for (const sashItem of this.sashItems) { - sashItem.sash.orthogonalEndSash = sash; - } - - this._orthogonalEndSash = sash; - } - - /** - * The internal sashes within this {@link SplitView}. - */ - get sashes(): readonly Sash[] { - return this.sashItems.map(s => s.sash); - } - - /** - * Enable/disable snapping at the beginning of this {@link SplitView}. - */ - set startSnappingEnabled(startSnappingEnabled: boolean) { - if (this._startSnappingEnabled === startSnappingEnabled) { - return; - } - - this._startSnappingEnabled = startSnappingEnabled; - this.updateSashEnablement(); - } - - /** - * Enable/disable snapping at the end of this {@link SplitView}. - */ - set endSnappingEnabled(endSnappingEnabled: boolean) { - if (this._endSnappingEnabled === endSnappingEnabled) { - return; - } - - this._endSnappingEnabled = endSnappingEnabled; - this.updateSashEnablement(); - } - - /** - * Create a new {@link SplitView} instance. - */ - constructor(container: HTMLElement, options: ISplitViewOptions = {}) { - super(); - - this.orientation = options.orientation ?? Orientation.VERTICAL; - this.inverseAltBehavior = options.inverseAltBehavior ?? false; - this.proportionalLayout = options.proportionalLayout ?? true; - this.getSashOrthogonalSize = options.getSashOrthogonalSize; - - this.el = document.createElement('div'); - this.el.classList.add('monaco-split-view2'); - this.el.classList.add(this.orientation === Orientation.VERTICAL ? 'vertical' : 'horizontal'); - container.appendChild(this.el); - - this.sashContainer = append(this.el, $('.sash-container')); - this.viewContainer = $('.split-view-container'); - - this.scrollable = this._register(new Scrollable({ - forceIntegerValues: true, - smoothScrollDuration: 125, - scheduleAtNextAnimationFrame: callback => scheduleAtNextAnimationFrame(getWindow(this.el), callback), - })); - this.scrollableElement = this._register(new SmoothScrollableElement(this.viewContainer, { - vertical: this.orientation === Orientation.VERTICAL ? (options.scrollbarVisibility ?? ScrollbarVisibility.Auto) : ScrollbarVisibility.Hidden, - horizontal: this.orientation === Orientation.HORIZONTAL ? (options.scrollbarVisibility ?? ScrollbarVisibility.Auto) : ScrollbarVisibility.Hidden - }, this.scrollable)); - - // https://github.com/microsoft/vscode/issues/157737 - const onDidScrollViewContainer = this._register(new DomEmitter(this.viewContainer, 'scroll')).event; - this._register(onDidScrollViewContainer(_ => { - const position = this.scrollableElement.getScrollPosition(); - const scrollLeft = Math.abs(this.viewContainer.scrollLeft - position.scrollLeft) <= 1 ? undefined : this.viewContainer.scrollLeft; - const scrollTop = Math.abs(this.viewContainer.scrollTop - position.scrollTop) <= 1 ? undefined : this.viewContainer.scrollTop; - - if (scrollLeft !== undefined || scrollTop !== undefined) { - this.scrollableElement.setScrollPosition({ scrollLeft, scrollTop }); - } - })); - - this.onDidScroll = this.scrollableElement.onScroll; - this._register(this.onDidScroll(e => { - if (e.scrollTopChanged) { - this.viewContainer.scrollTop = e.scrollTop; - } - - if (e.scrollLeftChanged) { - this.viewContainer.scrollLeft = e.scrollLeft; - } - })); - - append(this.el, this.scrollableElement.getDomNode()); - - this.style(options.styles || defaultStyles); - - // We have an existing set of view, add them now - if (options.descriptor) { - this.size = options.descriptor.size; - options.descriptor.views.forEach((viewDescriptor, index) => { - const sizing = types.isUndefined(viewDescriptor.visible) || viewDescriptor.visible ? viewDescriptor.size : { type: 'invisible', cachedVisibleSize: viewDescriptor.size } satisfies InvisibleSizing; - - const view = viewDescriptor.view; - this.doAddView(view, sizing, index, true); - }); - - // Initialize content size and proportions for first layout - this._contentSize = this.viewItems.reduce((r, i) => r + i.size, 0); - this.saveProportions(); - } - } - - style(styles: ISplitViewStyles): void { - if (styles.separatorBorder.isTransparent()) { - this.el.classList.remove('separator-border'); - this.el.style.removeProperty('--separator-border'); - } else { - this.el.classList.add('separator-border'); - this.el.style.setProperty('--separator-border', styles.separatorBorder.toString()); - } - } - - /** - * Add a {@link IView view} to this {@link SplitView}. - * - * @param view The view to add. - * @param size Either a fixed size, or a dynamic {@link Sizing} strategy. - * @param index The index to insert the view on. - * @param skipLayout Whether layout should be skipped. - */ - addView(view: TView, size: number | Sizing, index = this.viewItems.length, skipLayout?: boolean): void { - this.doAddView(view, size, index, skipLayout); - } - - /** - * Remove a {@link IView view} from this {@link SplitView}. - * - * @param index The index where the {@link IView view} is located. - * @param sizing Whether to distribute other {@link IView view}'s sizes. - */ - removeView(index: number, sizing?: Sizing): TView { - if (index < 0 || index >= this.viewItems.length) { - throw new Error('Index out of bounds'); - } - - if (this.state !== State.Idle) { - throw new Error('Cant modify splitview'); - } - - this.state = State.Busy; - - try { - if (sizing?.type === 'auto') { - if (this.areViewsDistributed()) { - sizing = { type: 'distribute' }; - } else { - sizing = { type: 'split', index: sizing.index }; - } - } - - // Save referene view, in case of `split` sizing - const referenceViewItem = sizing?.type === 'split' ? this.viewItems[sizing.index] : undefined; - - // Remove view - const viewItemToRemove = this.viewItems.splice(index, 1)[0]; - - // Resize reference view, in case of `split` sizing - if (referenceViewItem) { - referenceViewItem.size += viewItemToRemove.size; - } - - // Remove sash - if (this.viewItems.length >= 1) { - const sashIndex = Math.max(index - 1, 0); - const sashItem = this.sashItems.splice(sashIndex, 1)[0]; - sashItem.disposable.dispose(); - } - - this.relayout(); - - if (sizing?.type === 'distribute') { - this.distributeViewSizes(); - } - - const result = viewItemToRemove.view; - viewItemToRemove.dispose(); - return result; - - } finally { - this.state = State.Idle; - } - } - - removeAllViews(): TView[] { - if (this.state !== State.Idle) { - throw new Error('Cant modify splitview'); - } - - this.state = State.Busy; - - try { - const viewItems = this.viewItems.splice(0, this.viewItems.length); - - for (const viewItem of viewItems) { - viewItem.dispose(); - } - - const sashItems = this.sashItems.splice(0, this.sashItems.length); - - for (const sashItem of sashItems) { - sashItem.disposable.dispose(); - } - - this.relayout(); - return viewItems.map(i => i.view); - - } finally { - this.state = State.Idle; - } - } - - /** - * Move a {@link IView view} to a different index. - * - * @param from The source index. - * @param to The target index. - */ - moveView(from: number, to: number): void { - if (this.state !== State.Idle) { - throw new Error('Cant modify splitview'); - } - - const cachedVisibleSize = this.getViewCachedVisibleSize(from); - const sizing = typeof cachedVisibleSize === 'undefined' ? this.getViewSize(from) : Sizing.Invisible(cachedVisibleSize); - const view = this.removeView(from); - this.addView(view, sizing, to); - } - - - /** - * Swap two {@link IView views}. - * - * @param from The source index. - * @param to The target index. - */ - swapViews(from: number, to: number): void { - if (this.state !== State.Idle) { - throw new Error('Cant modify splitview'); - } - - if (from > to) { - return this.swapViews(to, from); - } - - const fromSize = this.getViewSize(from); - const toSize = this.getViewSize(to); - const toView = this.removeView(to); - const fromView = this.removeView(from); - - this.addView(toView, fromSize, from); - this.addView(fromView, toSize, to); - } - - /** - * Returns whether the {@link IView view} is visible. - * - * @param index The {@link IView view} index. - */ - isViewVisible(index: number): boolean { - if (index < 0 || index >= this.viewItems.length) { - throw new Error('Index out of bounds'); - } - - const viewItem = this.viewItems[index]; - return viewItem.visible; - } - - /** - * Set a {@link IView view}'s visibility. - * - * @param index The {@link IView view} index. - * @param visible Whether the {@link IView view} should be visible. - */ - setViewVisible(index: number, visible: boolean): void { - if (index < 0 || index >= this.viewItems.length) { - throw new Error('Index out of bounds'); - } - - const viewItem = this.viewItems[index]; - viewItem.setVisible(visible); - - this.distributeEmptySpace(index); - this.layoutViews(); - this.saveProportions(); - } - - /** - * Returns the {@link IView view}'s size previously to being hidden. - * - * @param index The {@link IView view} index. - */ - getViewCachedVisibleSize(index: number): number | undefined { - if (index < 0 || index >= this.viewItems.length) { - throw new Error('Index out of bounds'); - } - - const viewItem = this.viewItems[index]; - return viewItem.cachedVisibleSize; - } - - /** - * Layout the {@link SplitView}. - * - * @param size The entire size of the {@link SplitView}. - * @param layoutContext An optional layout context to pass along to {@link IView views}. - */ - layout(size: number, layoutContext?: TLayoutContext): void { - const previousSize = Math.max(this.size, this._contentSize); - this.size = size; - this.layoutContext = layoutContext; - - if (!this.proportions) { - const indexes = range(this.viewItems.length); - const lowPriorityIndexes = indexes.filter(i => this.viewItems[i].priority === LayoutPriority.Low); - const highPriorityIndexes = indexes.filter(i => this.viewItems[i].priority === LayoutPriority.High); - - this.resize(this.viewItems.length - 1, size - previousSize, undefined, lowPriorityIndexes, highPriorityIndexes); - } else { - let total = 0; - - for (let i = 0; i < this.viewItems.length; i++) { - const item = this.viewItems[i]; - const proportion = this.proportions[i]; - - if (typeof proportion === 'number') { - total += proportion; - } else { - size -= item.size; - } - } - - for (let i = 0; i < this.viewItems.length; i++) { - const item = this.viewItems[i]; - const proportion = this.proportions[i]; - - if (typeof proportion === 'number' && total > 0) { - item.size = clamp(Math.round(proportion * size / total), item.minimumSize, item.maximumSize); - } - } - } - - this.distributeEmptySpace(); - this.layoutViews(); - } - - private saveProportions(): void { - if (this.proportionalLayout && this._contentSize > 0) { - this.proportions = this.viewItems.map(v => v.proportionalLayout && v.visible ? v.size / this._contentSize : undefined); - } - } - - private onSashStart({ sash, start, alt }: ISashEvent): void { - for (const item of this.viewItems) { - item.enabled = false; - } - - const index = this.sashItems.findIndex(item => item.sash === sash); - - // This way, we can press Alt while we resize a sash, macOS style! - const disposable = combinedDisposable( - addDisposableListener(this.el.ownerDocument.body, 'keydown', e => resetSashDragState(this.sashDragState!.current, e.altKey)), - addDisposableListener(this.el.ownerDocument.body, 'keyup', () => resetSashDragState(this.sashDragState!.current, false)) - ); - - const resetSashDragState = (start: number, alt: boolean) => { - const sizes = this.viewItems.map(i => i.size); - let minDelta = Number.NEGATIVE_INFINITY; - let maxDelta = Number.POSITIVE_INFINITY; - - if (this.inverseAltBehavior) { - alt = !alt; - } - - if (alt) { - // When we're using the last sash with Alt, we're resizing - // the view to the left/up, instead of right/down as usual - // Thus, we must do the inverse of the usual - const isLastSash = index === this.sashItems.length - 1; - - if (isLastSash) { - const viewItem = this.viewItems[index]; - minDelta = (viewItem.minimumSize - viewItem.size) / 2; - maxDelta = (viewItem.maximumSize - viewItem.size) / 2; - } else { - const viewItem = this.viewItems[index + 1]; - minDelta = (viewItem.size - viewItem.maximumSize) / 2; - maxDelta = (viewItem.size - viewItem.minimumSize) / 2; - } - } - - let snapBefore: ISashDragSnapState | undefined; - let snapAfter: ISashDragSnapState | undefined; - - if (!alt) { - const upIndexes = range(index, -1); - const downIndexes = range(index + 1, this.viewItems.length); - const minDeltaUp = upIndexes.reduce((r, i) => r + (this.viewItems[i].minimumSize - sizes[i]), 0); - const maxDeltaUp = upIndexes.reduce((r, i) => r + (this.viewItems[i].viewMaximumSize - sizes[i]), 0); - const maxDeltaDown = downIndexes.length === 0 ? Number.POSITIVE_INFINITY : downIndexes.reduce((r, i) => r + (sizes[i] - this.viewItems[i].minimumSize), 0); - const minDeltaDown = downIndexes.length === 0 ? Number.NEGATIVE_INFINITY : downIndexes.reduce((r, i) => r + (sizes[i] - this.viewItems[i].viewMaximumSize), 0); - const minDelta = Math.max(minDeltaUp, minDeltaDown); - const maxDelta = Math.min(maxDeltaDown, maxDeltaUp); - const snapBeforeIndex = this.findFirstSnapIndex(upIndexes); - const snapAfterIndex = this.findFirstSnapIndex(downIndexes); - - if (typeof snapBeforeIndex === 'number') { - const viewItem = this.viewItems[snapBeforeIndex]; - const halfSize = Math.floor(viewItem.viewMinimumSize / 2); - - snapBefore = { - index: snapBeforeIndex, - limitDelta: viewItem.visible ? minDelta - halfSize : minDelta + halfSize, - size: viewItem.size - }; - } - - if (typeof snapAfterIndex === 'number') { - const viewItem = this.viewItems[snapAfterIndex]; - const halfSize = Math.floor(viewItem.viewMinimumSize / 2); - - snapAfter = { - index: snapAfterIndex, - limitDelta: viewItem.visible ? maxDelta + halfSize : maxDelta - halfSize, - size: viewItem.size - }; - } - } - - this.sashDragState = { start, current: start, index, sizes, minDelta, maxDelta, alt, snapBefore, snapAfter, disposable }; - }; - - resetSashDragState(start, alt); - } - - private onSashChange({ current }: ISashEvent): void { - const { index, start, sizes, alt, minDelta, maxDelta, snapBefore, snapAfter } = this.sashDragState!; - this.sashDragState!.current = current; - - const delta = current - start; - const newDelta = this.resize(index, delta, sizes, undefined, undefined, minDelta, maxDelta, snapBefore, snapAfter); - - if (alt) { - const isLastSash = index === this.sashItems.length - 1; - const newSizes = this.viewItems.map(i => i.size); - const viewItemIndex = isLastSash ? index : index + 1; - const viewItem = this.viewItems[viewItemIndex]; - const newMinDelta = viewItem.size - viewItem.maximumSize; - const newMaxDelta = viewItem.size - viewItem.minimumSize; - const resizeIndex = isLastSash ? index - 1 : index + 1; - - this.resize(resizeIndex, -newDelta, newSizes, undefined, undefined, newMinDelta, newMaxDelta); - } - - this.distributeEmptySpace(); - this.layoutViews(); - } - - private onSashEnd(index: number): void { - this._onDidSashChange.fire(index); - this.sashDragState!.disposable.dispose(); - this.saveProportions(); - - for (const item of this.viewItems) { - item.enabled = true; - } - } - - private onViewChange(item: ViewItem, size: number | undefined): void { - const index = this.viewItems.indexOf(item); - - if (index < 0 || index >= this.viewItems.length) { - return; - } - - size = typeof size === 'number' ? size : item.size; - size = clamp(size, item.minimumSize, item.maximumSize); - - if (this.inverseAltBehavior && index > 0) { - // In this case, we want the view to grow or shrink both sides equally - // so we just resize the "left" side by half and let `resize` do the clamping magic - this.resize(index - 1, Math.floor((item.size - size) / 2)); - this.distributeEmptySpace(); - this.layoutViews(); - } else { - item.size = size; - this.relayout([index], undefined); - } - } - - /** - * Resize a {@link IView view} within the {@link SplitView}. - * - * @param index The {@link IView view} index. - * @param size The {@link IView view} size. - */ - resizeView(index: number, size: number): void { - if (index < 0 || index >= this.viewItems.length) { - return; - } - - if (this.state !== State.Idle) { - throw new Error('Cant modify splitview'); - } - - this.state = State.Busy; - - try { - const indexes = range(this.viewItems.length).filter(i => i !== index); - const lowPriorityIndexes = [...indexes.filter(i => this.viewItems[i].priority === LayoutPriority.Low), index]; - const highPriorityIndexes = indexes.filter(i => this.viewItems[i].priority === LayoutPriority.High); - - const item = this.viewItems[index]; - size = Math.round(size); - size = clamp(size, item.minimumSize, Math.min(item.maximumSize, this.size)); - - item.size = size; - this.relayout(lowPriorityIndexes, highPriorityIndexes); - } finally { - this.state = State.Idle; - } - } - - /** - * Returns whether all other {@link IView views} are at their minimum size. - */ - isViewExpanded(index: number): boolean { - if (index < 0 || index >= this.viewItems.length) { - return false; - } - - for (const item of this.viewItems) { - if (item !== this.viewItems[index] && item.size > item.minimumSize) { - return false; - } - } - - return true; - } - - /** - * Distribute the entire {@link SplitView} size among all {@link IView views}. - */ - distributeViewSizes(): void { - const flexibleViewItems: ViewItem[] = []; - let flexibleSize = 0; - - for (const item of this.viewItems) { - if (item.maximumSize - item.minimumSize > 0) { - flexibleViewItems.push(item); - flexibleSize += item.size; - } - } - - const size = Math.floor(flexibleSize / flexibleViewItems.length); - - for (const item of flexibleViewItems) { - item.size = clamp(size, item.minimumSize, item.maximumSize); - } - - const indexes = range(this.viewItems.length); - const lowPriorityIndexes = indexes.filter(i => this.viewItems[i].priority === LayoutPriority.Low); - const highPriorityIndexes = indexes.filter(i => this.viewItems[i].priority === LayoutPriority.High); - - this.relayout(lowPriorityIndexes, highPriorityIndexes); - } - - /** - * Returns the size of a {@link IView view}. - */ - getViewSize(index: number): number { - if (index < 0 || index >= this.viewItems.length) { - return -1; - } - - return this.viewItems[index].size; - } - - private doAddView(view: TView, size: number | Sizing, index = this.viewItems.length, skipLayout?: boolean): void { - if (this.state !== State.Idle) { - throw new Error('Cant modify splitview'); - } - - this.state = State.Busy; - - try { - // Add view - const container = $('.split-view-view'); - - if (index === this.viewItems.length) { - this.viewContainer.appendChild(container); - } else { - this.viewContainer.insertBefore(container, this.viewContainer.children.item(index)); - } - - const onChangeDisposable = view.onDidChange(size => this.onViewChange(item, size)); - const containerDisposable = toDisposable(() => container.remove()); - const disposable = combinedDisposable(onChangeDisposable, containerDisposable); - - let viewSize: ViewItemSize; - - if (typeof size === 'number') { - viewSize = size; - } else { - if (size.type === 'auto') { - if (this.areViewsDistributed()) { - size = { type: 'distribute' }; - } else { - size = { type: 'split', index: size.index }; - } - } - - if (size.type === 'split') { - viewSize = this.getViewSize(size.index) / 2; - } else if (size.type === 'invisible') { - viewSize = { cachedVisibleSize: size.cachedVisibleSize }; - } else { - viewSize = view.minimumSize; - } - } - - const item = this.orientation === Orientation.VERTICAL - ? new VerticalViewItem(container, view, viewSize, disposable) - : new HorizontalViewItem(container, view, viewSize, disposable); - - this.viewItems.splice(index, 0, item); - - // Add sash - if (this.viewItems.length > 1) { - const opts = { orthogonalStartSash: this.orthogonalStartSash, orthogonalEndSash: this.orthogonalEndSash }; - - const sash = this.orientation === Orientation.VERTICAL - ? new Sash(this.sashContainer, { getHorizontalSashTop: s => this.getSashPosition(s), getHorizontalSashWidth: this.getSashOrthogonalSize }, { ...opts, orientation: Orientation.HORIZONTAL }) - : new Sash(this.sashContainer, { getVerticalSashLeft: s => this.getSashPosition(s), getVerticalSashHeight: this.getSashOrthogonalSize }, { ...opts, orientation: Orientation.VERTICAL }); - - const sashEventMapper = this.orientation === Orientation.VERTICAL - ? (e: IBaseSashEvent) => ({ sash, start: e.startY, current: e.currentY, alt: e.altKey }) - : (e: IBaseSashEvent) => ({ sash, start: e.startX, current: e.currentX, alt: e.altKey }); - - const onStart = Event.map(sash.onDidStart, sashEventMapper); - const onStartDisposable = onStart(this.onSashStart, this); - const onChange = Event.map(sash.onDidChange, sashEventMapper); - const onChangeDisposable = onChange(this.onSashChange, this); - const onEnd = Event.map(sash.onDidEnd, () => this.sashItems.findIndex(item => item.sash === sash)); - const onEndDisposable = onEnd(this.onSashEnd, this); - - const onDidResetDisposable = sash.onDidReset(() => { - const index = this.sashItems.findIndex(item => item.sash === sash); - const upIndexes = range(index, -1); - const downIndexes = range(index + 1, this.viewItems.length); - const snapBeforeIndex = this.findFirstSnapIndex(upIndexes); - const snapAfterIndex = this.findFirstSnapIndex(downIndexes); - - if (typeof snapBeforeIndex === 'number' && !this.viewItems[snapBeforeIndex].visible) { - return; - } - - if (typeof snapAfterIndex === 'number' && !this.viewItems[snapAfterIndex].visible) { - return; - } - - this._onDidSashReset.fire(index); - }); - - const disposable = combinedDisposable(onStartDisposable, onChangeDisposable, onEndDisposable, onDidResetDisposable, sash); - const sashItem: ISashItem = { sash, disposable }; - - this.sashItems.splice(index - 1, 0, sashItem); - } - - container.appendChild(view.element); - - let highPriorityIndexes: number[] | undefined; - - if (typeof size !== 'number' && size.type === 'split') { - highPriorityIndexes = [size.index]; - } - - if (!skipLayout) { - this.relayout([index], highPriorityIndexes); - } - - - if (!skipLayout && typeof size !== 'number' && size.type === 'distribute') { - this.distributeViewSizes(); - } - - } finally { - this.state = State.Idle; - } - } - - private relayout(lowPriorityIndexes?: number[], highPriorityIndexes?: number[]): void { - const contentSize = this.viewItems.reduce((r, i) => r + i.size, 0); - - this.resize(this.viewItems.length - 1, this.size - contentSize, undefined, lowPriorityIndexes, highPriorityIndexes); - this.distributeEmptySpace(); - this.layoutViews(); - this.saveProportions(); - } - - private resize( - index: number, - delta: number, - sizes = this.viewItems.map(i => i.size), - lowPriorityIndexes?: number[], - highPriorityIndexes?: number[], - overloadMinDelta: number = Number.NEGATIVE_INFINITY, - overloadMaxDelta: number = Number.POSITIVE_INFINITY, - snapBefore?: ISashDragSnapState, - snapAfter?: ISashDragSnapState - ): number { - if (index < 0 || index >= this.viewItems.length) { - return 0; - } - - const upIndexes = range(index, -1); - const downIndexes = range(index + 1, this.viewItems.length); - - if (highPriorityIndexes) { - for (const index of highPriorityIndexes) { - pushToStart(upIndexes, index); - pushToStart(downIndexes, index); - } - } - - if (lowPriorityIndexes) { - for (const index of lowPriorityIndexes) { - pushToEnd(upIndexes, index); - pushToEnd(downIndexes, index); - } - } - - const upItems = upIndexes.map(i => this.viewItems[i]); - const upSizes = upIndexes.map(i => sizes[i]); - - const downItems = downIndexes.map(i => this.viewItems[i]); - const downSizes = downIndexes.map(i => sizes[i]); - - const minDeltaUp = upIndexes.reduce((r, i) => r + (this.viewItems[i].minimumSize - sizes[i]), 0); - const maxDeltaUp = upIndexes.reduce((r, i) => r + (this.viewItems[i].maximumSize - sizes[i]), 0); - const maxDeltaDown = downIndexes.length === 0 ? Number.POSITIVE_INFINITY : downIndexes.reduce((r, i) => r + (sizes[i] - this.viewItems[i].minimumSize), 0); - const minDeltaDown = downIndexes.length === 0 ? Number.NEGATIVE_INFINITY : downIndexes.reduce((r, i) => r + (sizes[i] - this.viewItems[i].maximumSize), 0); - const minDelta = Math.max(minDeltaUp, minDeltaDown, overloadMinDelta); - const maxDelta = Math.min(maxDeltaDown, maxDeltaUp, overloadMaxDelta); - - let snapped = false; - - if (snapBefore) { - const snapView = this.viewItems[snapBefore.index]; - const visible = delta >= snapBefore.limitDelta; - snapped = visible !== snapView.visible; - snapView.setVisible(visible, snapBefore.size); - } - - if (!snapped && snapAfter) { - const snapView = this.viewItems[snapAfter.index]; - const visible = delta < snapAfter.limitDelta; - snapped = visible !== snapView.visible; - snapView.setVisible(visible, snapAfter.size); - } - - if (snapped) { - return this.resize(index, delta, sizes, lowPriorityIndexes, highPriorityIndexes, overloadMinDelta, overloadMaxDelta); - } - - delta = clamp(delta, minDelta, maxDelta); - - for (let i = 0, deltaUp = delta; i < upItems.length; i++) { - const item = upItems[i]; - const size = clamp(upSizes[i] + deltaUp, item.minimumSize, item.maximumSize); - const viewDelta = size - upSizes[i]; - - deltaUp -= viewDelta; - item.size = size; - } - - for (let i = 0, deltaDown = delta; i < downItems.length; i++) { - const item = downItems[i]; - const size = clamp(downSizes[i] - deltaDown, item.minimumSize, item.maximumSize); - const viewDelta = size - downSizes[i]; - - deltaDown += viewDelta; - item.size = size; - } - - return delta; - } - - private distributeEmptySpace(lowPriorityIndex?: number): void { - const contentSize = this.viewItems.reduce((r, i) => r + i.size, 0); - let emptyDelta = this.size - contentSize; - - const indexes = range(this.viewItems.length - 1, -1); - const lowPriorityIndexes = indexes.filter(i => this.viewItems[i].priority === LayoutPriority.Low); - const highPriorityIndexes = indexes.filter(i => this.viewItems[i].priority === LayoutPriority.High); - - for (const index of highPriorityIndexes) { - pushToStart(indexes, index); - } - - for (const index of lowPriorityIndexes) { - pushToEnd(indexes, index); - } - - if (typeof lowPriorityIndex === 'number') { - pushToEnd(indexes, lowPriorityIndex); - } - - for (let i = 0; emptyDelta !== 0 && i < indexes.length; i++) { - const item = this.viewItems[indexes[i]]; - const size = clamp(item.size + emptyDelta, item.minimumSize, item.maximumSize); - const viewDelta = size - item.size; - - emptyDelta -= viewDelta; - item.size = size; - } - } - - private layoutViews(): void { - // Save new content size - this._contentSize = this.viewItems.reduce((r, i) => r + i.size, 0); - - // Layout views - let offset = 0; - - for (const viewItem of this.viewItems) { - viewItem.layout(offset, this.layoutContext); - offset += viewItem.size; - } - - // Layout sashes - this.sashItems.forEach(item => item.sash.layout()); - this.updateSashEnablement(); - this.updateScrollableElement(); - } - - private updateScrollableElement(): void { - if (this.orientation === Orientation.VERTICAL) { - this.scrollableElement.setScrollDimensions({ - height: this.size, - scrollHeight: this._contentSize - }); - } else { - this.scrollableElement.setScrollDimensions({ - width: this.size, - scrollWidth: this._contentSize - }); - } - } - - private updateSashEnablement(): void { - let previous = false; - const collapsesDown = this.viewItems.map(i => previous = (i.size - i.minimumSize > 0) || previous); - - previous = false; - const expandsDown = this.viewItems.map(i => previous = (i.maximumSize - i.size > 0) || previous); - - const reverseViews = [...this.viewItems].reverse(); - previous = false; - const collapsesUp = reverseViews.map(i => previous = (i.size - i.minimumSize > 0) || previous).reverse(); - - previous = false; - const expandsUp = reverseViews.map(i => previous = (i.maximumSize - i.size > 0) || previous).reverse(); - - let position = 0; - for (let index = 0; index < this.sashItems.length; index++) { - const { sash } = this.sashItems[index]; - const viewItem = this.viewItems[index]; - position += viewItem.size; - - const min = !(collapsesDown[index] && expandsUp[index + 1]); - const max = !(expandsDown[index] && collapsesUp[index + 1]); - - if (min && max) { - const upIndexes = range(index, -1); - const downIndexes = range(index + 1, this.viewItems.length); - const snapBeforeIndex = this.findFirstSnapIndex(upIndexes); - const snapAfterIndex = this.findFirstSnapIndex(downIndexes); - - const snappedBefore = typeof snapBeforeIndex === 'number' && !this.viewItems[snapBeforeIndex].visible; - const snappedAfter = typeof snapAfterIndex === 'number' && !this.viewItems[snapAfterIndex].visible; - - if (snappedBefore && collapsesUp[index] && (position > 0 || this.startSnappingEnabled)) { - sash.state = SashState.AtMinimum; - } else if (snappedAfter && collapsesDown[index] && (position < this._contentSize || this.endSnappingEnabled)) { - sash.state = SashState.AtMaximum; - } else { - sash.state = SashState.Disabled; - } - } else if (min && !max) { - sash.state = SashState.AtMinimum; - } else if (!min && max) { - sash.state = SashState.AtMaximum; - } else { - sash.state = SashState.Enabled; - } - } - } - - private getSashPosition(sash: Sash): number { - let position = 0; - - for (let i = 0; i < this.sashItems.length; i++) { - position += this.viewItems[i].size; - - if (this.sashItems[i].sash === sash) { - return position; - } - } - - return 0; - } - - private findFirstSnapIndex(indexes: number[]): number | undefined { - // visible views first - for (const index of indexes) { - const viewItem = this.viewItems[index]; - - if (!viewItem.visible) { - continue; - } - - if (viewItem.snap) { - return index; - } - } - - // then, hidden views - for (const index of indexes) { - const viewItem = this.viewItems[index]; - - if (viewItem.visible && viewItem.maximumSize - viewItem.minimumSize > 0) { - return undefined; - } - - if (!viewItem.visible && viewItem.snap) { - return index; - } - } - - return undefined; - } - - private areViewsDistributed() { - let min = undefined, max = undefined; - - for (const view of this.viewItems) { - min = min === undefined ? view.size : Math.min(min, view.size); - max = max === undefined ? view.size : Math.max(max, view.size); - - if (max - min > 2) { - return false; - } - } - - return true; - } - - override dispose(): void { - this.sashDragState?.disposable.dispose(); - - dispose(this.viewItems); - this.viewItems = []; - - this.sashItems.forEach(i => i.disposable.dispose()); - this.sashItems = []; - - super.dispose(); - } -} diff --git a/src/vs/base/common/actions.ts b/src/vs/base/common/actions.ts deleted file mode 100644 index a1fd3249a1..0000000000 --- a/src/vs/base/common/actions.ts +++ /dev/null @@ -1,271 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { Emitter, Event } from 'vs/base/common/event'; -import { Disposable, IDisposable } from 'vs/base/common/lifecycle'; -import * as nls from 'vs/nls'; - -export interface ITelemetryData { - readonly from?: string; - readonly target?: string; - [key: string]: unknown; -} - -export type WorkbenchActionExecutedClassification = { - id: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The identifier of the action that was run.' }; - from: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The name of the component the action was run from.' }; - detail?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Optional details about how the action was run, e.g which keybinding was used.' }; - owner: 'bpasero'; - comment: 'Provides insight into actions that are executed within the workbench.'; -}; - -export type WorkbenchActionExecutedEvent = { - id: string; - from: string; - detail?: string; -}; - -export interface IAction { - readonly id: string; - label: string; - tooltip: string; - class: string | undefined; - enabled: boolean; - checked?: boolean; - run(...args: unknown[]): unknown; -} - -export interface IActionRunner extends IDisposable { - readonly onDidRun: Event; - readonly onWillRun: Event; - - run(action: IAction, context?: unknown): unknown; -} - -export interface IActionChangeEvent { - readonly label?: string; - readonly tooltip?: string; - readonly class?: string; - readonly enabled?: boolean; - readonly checked?: boolean; -} - -export class Action extends Disposable implements IAction { - - protected _onDidChange = this._register(new Emitter()); - readonly onDidChange = this._onDidChange.event; - - protected readonly _id: string; - protected _label: string; - protected _tooltip: string | undefined; - protected _cssClass: string | undefined; - protected _enabled: boolean = true; - protected _checked?: boolean; - protected readonly _actionCallback?: (event?: unknown) => unknown; - - constructor(id: string, label: string = '', cssClass: string = '', enabled: boolean = true, actionCallback?: (event?: unknown) => unknown) { - super(); - this._id = id; - this._label = label; - this._cssClass = cssClass; - this._enabled = enabled; - this._actionCallback = actionCallback; - } - - get id(): string { - return this._id; - } - - get label(): string { - return this._label; - } - - set label(value: string) { - this._setLabel(value); - } - - private _setLabel(value: string): void { - if (this._label !== value) { - this._label = value; - this._onDidChange.fire({ label: value }); - } - } - - get tooltip(): string { - return this._tooltip || ''; - } - - set tooltip(value: string) { - this._setTooltip(value); - } - - protected _setTooltip(value: string): void { - if (this._tooltip !== value) { - this._tooltip = value; - this._onDidChange.fire({ tooltip: value }); - } - } - - get class(): string | undefined { - return this._cssClass; - } - - set class(value: string | undefined) { - this._setClass(value); - } - - protected _setClass(value: string | undefined): void { - if (this._cssClass !== value) { - this._cssClass = value; - this._onDidChange.fire({ class: value }); - } - } - - get enabled(): boolean { - return this._enabled; - } - - set enabled(value: boolean) { - this._setEnabled(value); - } - - protected _setEnabled(value: boolean): void { - if (this._enabled !== value) { - this._enabled = value; - this._onDidChange.fire({ enabled: value }); - } - } - - get checked(): boolean | undefined { - return this._checked; - } - - set checked(value: boolean | undefined) { - this._setChecked(value); - } - - protected _setChecked(value: boolean | undefined): void { - if (this._checked !== value) { - this._checked = value; - this._onDidChange.fire({ checked: value }); - } - } - - async run(event?: unknown, data?: ITelemetryData): Promise { - if (this._actionCallback) { - await this._actionCallback(event); - } - } -} - -export interface IRunEvent { - readonly action: IAction; - readonly error?: Error; -} - -export class ActionRunner extends Disposable implements IActionRunner { - - private readonly _onWillRun = this._register(new Emitter()); - readonly onWillRun = this._onWillRun.event; - - private readonly _onDidRun = this._register(new Emitter()); - readonly onDidRun = this._onDidRun.event; - - async run(action: IAction, context?: unknown): Promise { - if (!action.enabled) { - return; - } - - this._onWillRun.fire({ action }); - - let error: Error | undefined = undefined; - try { - await this.runAction(action, context); - } catch (e) { - error = e; - } - - this._onDidRun.fire({ action, error }); - } - - protected async runAction(action: IAction, context?: unknown): Promise { - await action.run(context); - } -} - -export class Separator implements IAction { - - /** - * Joins all non-empty lists of actions with separators. - */ - public static join(...actionLists: readonly IAction[][]) { - let out: IAction[] = []; - for (const list of actionLists) { - if (!list.length) { - // skip - } else if (out.length) { - out = [...out, new Separator(), ...list]; - } else { - out = list; - } - } - - return out; - } - - static readonly ID = 'vs.actions.separator'; - - readonly id: string = Separator.ID; - - readonly label: string = ''; - readonly tooltip: string = ''; - readonly class: string = 'separator'; - readonly enabled: boolean = false; - readonly checked: boolean = false; - async run() { } -} - -export class SubmenuAction implements IAction { - - readonly id: string; - readonly label: string; - readonly class: string | undefined; - readonly tooltip: string = ''; - readonly enabled: boolean = true; - readonly checked: undefined = undefined; - - private readonly _actions: readonly IAction[]; - get actions(): readonly IAction[] { return this._actions; } - - constructor(id: string, label: string, actions: readonly IAction[], cssClass?: string) { - this.id = id; - this.label = label; - this.class = cssClass; - this._actions = actions; - } - - async run(): Promise { } -} - -export class EmptySubmenuAction extends Action { - - static readonly ID = 'vs.actions.empty'; - - constructor() { - super(EmptySubmenuAction.ID, nls.localize('submenu.empty', '(empty)'), undefined, false); - } -} - -export function toAction(props: { id: string; label: string; tooltip?: string; enabled?: boolean; checked?: boolean; class?: string; run: Function }): IAction { - return { - id: props.id, - label: props.label, - tooltip: props.tooltip ?? props.label, - class: props.class, - enabled: props.enabled ?? true, - checked: props.checked, - run: async (...args: unknown[]) => props.run(...args), - }; -} diff --git a/src/vs/base/common/amd.ts b/src/vs/base/common/amd.ts deleted file mode 100644 index 6d22884033..0000000000 --- a/src/vs/base/common/amd.ts +++ /dev/null @@ -1,163 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -// ESM-comment-begin -export const isESM = false; -// ESM-comment-end -// ESM-uncomment-begin -// export const isESM = true; -// ESM-uncomment-end - -export const enum LoaderEventType { - LoaderAvailable = 1, - - BeginLoadingScript = 10, - EndLoadingScriptOK = 11, - EndLoadingScriptError = 12, - - BeginInvokeFactory = 21, - EndInvokeFactory = 22, - - NodeBeginEvaluatingScript = 31, - NodeEndEvaluatingScript = 32, - - NodeBeginNativeRequire = 33, - NodeEndNativeRequire = 34, - - CachedDataFound = 60, - CachedDataMissed = 61, - CachedDataRejected = 62, - CachedDataCreated = 63, -} - -export abstract class LoaderStats { - abstract get amdLoad(): [string, number][]; - abstract get amdInvoke(): [string, number][]; - abstract get nodeRequire(): [string, number][]; - abstract get nodeEval(): [string, number][]; - abstract get nodeRequireTotal(): number; - - static get(): LoaderStats { - const amdLoadScript = new Map(); - const amdInvokeFactory = new Map(); - const nodeRequire = new Map(); - const nodeEval = new Map(); - - function mark(map: Map, stat: LoaderEvent) { - if (map.has(stat.detail)) { - // console.warn('BAD events, DOUBLE start', stat); - // map.delete(stat.detail); - return; - } - map.set(stat.detail, -stat.timestamp); - } - - function diff(map: Map, stat: LoaderEvent) { - const duration = map.get(stat.detail); - if (!duration) { - // console.warn('BAD events, end WITHOUT start', stat); - // map.delete(stat.detail); - return; - } - if (duration >= 0) { - // console.warn('BAD events, DOUBLE end', stat); - // map.delete(stat.detail); - return; - } - map.set(stat.detail, duration + stat.timestamp); - } - - let stats: readonly LoaderEvent[] = []; - if (typeof require === 'function' && typeof require.getStats === 'function') { - stats = require.getStats().slice(0).sort((a, b) => a.timestamp - b.timestamp); - } - - for (const stat of stats) { - switch (stat.type) { - case LoaderEventType.BeginLoadingScript: - mark(amdLoadScript, stat); - break; - case LoaderEventType.EndLoadingScriptOK: - case LoaderEventType.EndLoadingScriptError: - diff(amdLoadScript, stat); - break; - - case LoaderEventType.BeginInvokeFactory: - mark(amdInvokeFactory, stat); - break; - case LoaderEventType.EndInvokeFactory: - diff(amdInvokeFactory, stat); - break; - - case LoaderEventType.NodeBeginNativeRequire: - mark(nodeRequire, stat); - break; - case LoaderEventType.NodeEndNativeRequire: - diff(nodeRequire, stat); - break; - - case LoaderEventType.NodeBeginEvaluatingScript: - mark(nodeEval, stat); - break; - case LoaderEventType.NodeEndEvaluatingScript: - diff(nodeEval, stat); - break; - } - } - - let nodeRequireTotal = 0; - nodeRequire.forEach(value => nodeRequireTotal += value); - - function to2dArray(map: Map): [string, number][] { - const res: [string, number][] = []; - map.forEach((value, index) => res.push([index, value])); - return res; - } - - return { - amdLoad: to2dArray(amdLoadScript), - amdInvoke: to2dArray(amdInvokeFactory), - nodeRequire: to2dArray(nodeRequire), - nodeEval: to2dArray(nodeEval), - nodeRequireTotal - }; - } - - static toMarkdownTable(header: string[], rows: Array>): string { - let result = ''; - - const lengths: number[] = []; - header.forEach((cell, ci) => { - lengths[ci] = cell.length; - }); - rows.forEach(row => { - row.forEach((cell, ci) => { - if (typeof cell === 'undefined') { - cell = row[ci] = '-'; - } - const len = cell.toString().length; - lengths[ci] = Math.max(len, lengths[ci]); - }); - }); - - // header - header.forEach((cell, ci) => { result += `| ${cell + ' '.repeat(lengths[ci] - cell.toString().length)} `; }); - result += '|\n'; - header.forEach((_cell, ci) => { result += `| ${'-'.repeat(lengths[ci])} `; }); - result += '|\n'; - - // cells - rows.forEach(row => { - row.forEach((cell, ci) => { - if (typeof cell !== 'undefined') { - result += `| ${cell + ' '.repeat(lengths[ci] - cell.toString().length)} `; - } - }); - result += '|\n'; - }); - - return result; - } -} diff --git a/src/vs/base/common/buffer.ts b/src/vs/base/common/buffer.ts deleted file mode 100644 index 08736ab8c0..0000000000 --- a/src/vs/base/common/buffer.ts +++ /dev/null @@ -1,441 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { Lazy } from 'vs/base/common/lazy'; -import * as streams from 'vs/base/common/stream'; - -declare const Buffer: any; - -const hasBuffer = (typeof Buffer !== 'undefined'); -const indexOfTable = new Lazy(() => new Uint8Array(256)); - -let textEncoder: TextEncoder | null; -let textDecoder: TextDecoder | null; - -export class VSBuffer { - - /** - * When running in a nodejs context, the backing store for the returned `VSBuffer` instance - * might use a nodejs Buffer allocated from node's Buffer pool, which is not transferrable. - */ - static alloc(byteLength: number): VSBuffer { - if (hasBuffer) { - return new VSBuffer(Buffer.allocUnsafe(byteLength)); - } else { - return new VSBuffer(new Uint8Array(byteLength)); - } - } - - /** - * When running in a nodejs context, if `actual` is not a nodejs Buffer, the backing store for - * the returned `VSBuffer` instance might use a nodejs Buffer allocated from node's Buffer pool, - * which is not transferrable. - */ - static wrap(actual: Uint8Array): VSBuffer { - if (hasBuffer && !(Buffer.isBuffer(actual))) { - // https://nodejs.org/dist/latest-v10.x/docs/api/buffer.html#buffer_class_method_buffer_from_arraybuffer_byteoffset_length - // Create a zero-copy Buffer wrapper around the ArrayBuffer pointed to by the Uint8Array - actual = Buffer.from(actual.buffer, actual.byteOffset, actual.byteLength); - } - return new VSBuffer(actual); - } - - /** - * When running in a nodejs context, the backing store for the returned `VSBuffer` instance - * might use a nodejs Buffer allocated from node's Buffer pool, which is not transferrable. - */ - static fromString(source: string, options?: { dontUseNodeBuffer?: boolean }): VSBuffer { - const dontUseNodeBuffer = options?.dontUseNodeBuffer || false; - if (!dontUseNodeBuffer && hasBuffer) { - return new VSBuffer(Buffer.from(source)); - } else { - if (!textEncoder) { - textEncoder = new TextEncoder(); - } - return new VSBuffer(textEncoder.encode(source)); - } - } - - /** - * When running in a nodejs context, the backing store for the returned `VSBuffer` instance - * might use a nodejs Buffer allocated from node's Buffer pool, which is not transferrable. - */ - static fromByteArray(source: number[]): VSBuffer { - const result = VSBuffer.alloc(source.length); - for (let i = 0, len = source.length; i < len; i++) { - result.buffer[i] = source[i]; - } - return result; - } - - /** - * When running in a nodejs context, the backing store for the returned `VSBuffer` instance - * might use a nodejs Buffer allocated from node's Buffer pool, which is not transferrable. - */ - static concat(buffers: VSBuffer[], totalLength?: number): VSBuffer { - if (typeof totalLength === 'undefined') { - totalLength = 0; - for (let i = 0, len = buffers.length; i < len; i++) { - totalLength += buffers[i].byteLength; - } - } - - const ret = VSBuffer.alloc(totalLength); - let offset = 0; - for (let i = 0, len = buffers.length; i < len; i++) { - const element = buffers[i]; - ret.set(element, offset); - offset += element.byteLength; - } - - return ret; - } - - readonly buffer: Uint8Array; - readonly byteLength: number; - - private constructor(buffer: Uint8Array) { - this.buffer = buffer; - this.byteLength = this.buffer.byteLength; - } - - /** - * When running in a nodejs context, the backing store for the returned `VSBuffer` instance - * might use a nodejs Buffer allocated from node's Buffer pool, which is not transferrable. - */ - clone(): VSBuffer { - const result = VSBuffer.alloc(this.byteLength); - result.set(this); - return result; - } - - toString(): string { - if (hasBuffer) { - return this.buffer.toString(); - } else { - if (!textDecoder) { - textDecoder = new TextDecoder(); - } - return textDecoder.decode(this.buffer); - } - } - - slice(start?: number, end?: number): VSBuffer { - // IMPORTANT: use subarray instead of slice because TypedArray#slice - // creates shallow copy and NodeBuffer#slice doesn't. The use of subarray - // ensures the same, performance, behaviour. - return new VSBuffer(this.buffer.subarray(start, end)); - } - - set(array: VSBuffer, offset?: number): void; - set(array: Uint8Array, offset?: number): void; - set(array: ArrayBuffer, offset?: number): void; - set(array: ArrayBufferView, offset?: number): void; - set(array: VSBuffer | Uint8Array | ArrayBuffer | ArrayBufferView, offset?: number): void; - set(array: VSBuffer | Uint8Array | ArrayBuffer | ArrayBufferView, offset?: number): void { - if (array instanceof VSBuffer) { - this.buffer.set(array.buffer, offset); - } else if (array instanceof Uint8Array) { - this.buffer.set(array, offset); - } else if (array instanceof ArrayBuffer) { - this.buffer.set(new Uint8Array(array), offset); - } else if (ArrayBuffer.isView(array)) { - this.buffer.set(new Uint8Array(array.buffer, array.byteOffset, array.byteLength), offset); - } else { - throw new Error(`Unknown argument 'array'`); - } - } - - readUInt32BE(offset: number): number { - return readUInt32BE(this.buffer, offset); - } - - writeUInt32BE(value: number, offset: number): void { - writeUInt32BE(this.buffer, value, offset); - } - - readUInt32LE(offset: number): number { - return readUInt32LE(this.buffer, offset); - } - - writeUInt32LE(value: number, offset: number): void { - writeUInt32LE(this.buffer, value, offset); - } - - readUInt8(offset: number): number { - return readUInt8(this.buffer, offset); - } - - writeUInt8(value: number, offset: number): void { - writeUInt8(this.buffer, value, offset); - } - - indexOf(subarray: VSBuffer | Uint8Array, offset = 0) { - return binaryIndexOf(this.buffer, subarray instanceof VSBuffer ? subarray.buffer : subarray, offset); - } -} - -/** - * Like String.indexOf, but works on Uint8Arrays. - * Uses the boyer-moore-horspool algorithm to be reasonably speedy. - */ -export function binaryIndexOf(haystack: Uint8Array, needle: Uint8Array, offset = 0): number { - const needleLen = needle.byteLength; - const haystackLen = haystack.byteLength; - - if (needleLen === 0) { - return 0; - } - - if (needleLen === 1) { - return haystack.indexOf(needle[0]); - } - - if (needleLen > haystackLen - offset) { - return -1; - } - - // find index of the subarray using boyer-moore-horspool algorithm - const table = indexOfTable.value; - table.fill(needle.length); - for (let i = 0; i < needle.length; i++) { - table[needle[i]] = needle.length - i - 1; - } - - let i = offset + needle.length - 1; - let j = i; - let result = -1; - while (i < haystackLen) { - if (haystack[i] === needle[j]) { - if (j === 0) { - result = i; - break; - } - - i--; - j--; - } else { - i += Math.max(needle.length - j, table[haystack[i]]); - j = needle.length - 1; - } - } - - return result; -} - -export function readUInt16LE(source: Uint8Array, offset: number): number { - return ( - ((source[offset + 0] << 0) >>> 0) | - ((source[offset + 1] << 8) >>> 0) - ); -} - -export function writeUInt16LE(destination: Uint8Array, value: number, offset: number): void { - destination[offset + 0] = (value & 0b11111111); - value = value >>> 8; - destination[offset + 1] = (value & 0b11111111); -} - -export function readUInt32BE(source: Uint8Array, offset: number): number { - return ( - source[offset] * 2 ** 24 - + source[offset + 1] * 2 ** 16 - + source[offset + 2] * 2 ** 8 - + source[offset + 3] - ); -} - -export function writeUInt32BE(destination: Uint8Array, value: number, offset: number): void { - destination[offset + 3] = value; - value = value >>> 8; - destination[offset + 2] = value; - value = value >>> 8; - destination[offset + 1] = value; - value = value >>> 8; - destination[offset] = value; -} - -export function readUInt32LE(source: Uint8Array, offset: number): number { - return ( - ((source[offset + 0] << 0) >>> 0) | - ((source[offset + 1] << 8) >>> 0) | - ((source[offset + 2] << 16) >>> 0) | - ((source[offset + 3] << 24) >>> 0) - ); -} - -export function writeUInt32LE(destination: Uint8Array, value: number, offset: number): void { - destination[offset + 0] = (value & 0b11111111); - value = value >>> 8; - destination[offset + 1] = (value & 0b11111111); - value = value >>> 8; - destination[offset + 2] = (value & 0b11111111); - value = value >>> 8; - destination[offset + 3] = (value & 0b11111111); -} - -export function readUInt8(source: Uint8Array, offset: number): number { - return source[offset]; -} - -export function writeUInt8(destination: Uint8Array, value: number, offset: number): void { - destination[offset] = value; -} - -export interface VSBufferReadable extends streams.Readable { } - -export interface VSBufferReadableStream extends streams.ReadableStream { } - -export interface VSBufferWriteableStream extends streams.WriteableStream { } - -export interface VSBufferReadableBufferedStream extends streams.ReadableBufferedStream { } - -export function readableToBuffer(readable: VSBufferReadable): VSBuffer { - return streams.consumeReadable(readable, chunks => VSBuffer.concat(chunks)); -} - -export function bufferToReadable(buffer: VSBuffer): VSBufferReadable { - return streams.toReadable(buffer); -} - -export function streamToBuffer(stream: streams.ReadableStream): Promise { - return streams.consumeStream(stream, chunks => VSBuffer.concat(chunks)); -} - -export async function bufferedStreamToBuffer(bufferedStream: streams.ReadableBufferedStream): Promise { - if (bufferedStream.ended) { - return VSBuffer.concat(bufferedStream.buffer); - } - - return VSBuffer.concat([ - - // Include already read chunks... - ...bufferedStream.buffer, - - // ...and all additional chunks - await streamToBuffer(bufferedStream.stream) - ]); -} - -export function bufferToStream(buffer: VSBuffer): streams.ReadableStream { - return streams.toStream(buffer, chunks => VSBuffer.concat(chunks)); -} - -export function streamToBufferReadableStream(stream: streams.ReadableStreamEvents): streams.ReadableStream { - return streams.transform(stream, { data: data => typeof data === 'string' ? VSBuffer.fromString(data) : VSBuffer.wrap(data) }, chunks => VSBuffer.concat(chunks)); -} - -export function newWriteableBufferStream(options?: streams.WriteableStreamOptions): streams.WriteableStream { - return streams.newWriteableStream(chunks => VSBuffer.concat(chunks), options); -} - -export function prefixedBufferReadable(prefix: VSBuffer, readable: VSBufferReadable): VSBufferReadable { - return streams.prefixedReadable(prefix, readable, chunks => VSBuffer.concat(chunks)); -} - -export function prefixedBufferStream(prefix: VSBuffer, stream: VSBufferReadableStream): VSBufferReadableStream { - return streams.prefixedStream(prefix, stream, chunks => VSBuffer.concat(chunks)); -} - -/** Decodes base64 to a uint8 array. URL-encoded and unpadded base64 is allowed. */ -export function decodeBase64(encoded: string) { - let building = 0; - let remainder = 0; - let bufi = 0; - - // The simpler way to do this is `Uint8Array.from(atob(str), c => c.charCodeAt(0))`, - // but that's about 10-20x slower than this function in current Chromium versions. - - const buffer = new Uint8Array(Math.floor(encoded.length / 4 * 3)); - const append = (value: number) => { - switch (remainder) { - case 3: - buffer[bufi++] = building | value; - remainder = 0; - break; - case 2: - buffer[bufi++] = building | (value >>> 2); - building = value << 6; - remainder = 3; - break; - case 1: - buffer[bufi++] = building | (value >>> 4); - building = value << 4; - remainder = 2; - break; - default: - building = value << 2; - remainder = 1; - } - }; - - for (let i = 0; i < encoded.length; i++) { - const code = encoded.charCodeAt(i); - // See https://datatracker.ietf.org/doc/html/rfc4648#section-4 - // This branchy code is about 3x faster than an indexOf on a base64 char string. - if (code >= 65 && code <= 90) { - append(code - 65); // A-Z starts ranges from char code 65 to 90 - } else if (code >= 97 && code <= 122) { - append(code - 97 + 26); // a-z starts ranges from char code 97 to 122, starting at byte 26 - } else if (code >= 48 && code <= 57) { - append(code - 48 + 52); // 0-9 starts ranges from char code 48 to 58, starting at byte 52 - } else if (code === 43 || code === 45) { - append(62); // "+" or "-" for URLS - } else if (code === 47 || code === 95) { - append(63); // "/" or "_" for URLS - } else if (code === 61) { - break; // "=" - } else { - throw new SyntaxError(`Unexpected base64 character ${encoded[i]}`); - } - } - - const unpadded = bufi; - while (remainder > 0) { - append(0); - } - - // slice is needed to account for overestimation due to padding - return VSBuffer.wrap(buffer).slice(0, unpadded); -} - -const base64Alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; -const base64UrlSafeAlphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'; - -/** Encodes a buffer to a base64 string. */ -export function encodeBase64({ buffer }: VSBuffer, padded = true, urlSafe = false) { - const dictionary = urlSafe ? base64UrlSafeAlphabet : base64Alphabet; - let output = ''; - - const remainder = buffer.byteLength % 3; - - let i = 0; - for (; i < buffer.byteLength - remainder; i += 3) { - const a = buffer[i + 0]; - const b = buffer[i + 1]; - const c = buffer[i + 2]; - - output += dictionary[a >>> 2]; - output += dictionary[(a << 4 | b >>> 4) & 0b111111]; - output += dictionary[(b << 2 | c >>> 6) & 0b111111]; - output += dictionary[c & 0b111111]; - } - - if (remainder === 1) { - const a = buffer[i + 0]; - output += dictionary[a >>> 2]; - output += dictionary[(a << 4) & 0b111111]; - if (padded) { output += '=='; } - } else if (remainder === 2) { - const a = buffer[i + 0]; - const b = buffer[i + 1]; - output += dictionary[a >>> 2]; - output += dictionary[(a << 4 | b >>> 4) & 0b111111]; - output += dictionary[(b << 2) & 0b111111]; - if (padded) { output += '='; } - } - - return output; -} diff --git a/src/vs/base/common/cache.ts b/src/vs/base/common/cache.ts deleted file mode 100644 index 03abbaf8eb..0000000000 --- a/src/vs/base/common/cache.ts +++ /dev/null @@ -1,120 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { CancellationToken, CancellationTokenSource } from 'vs/base/common/cancellation'; -import { IDisposable } from 'vs/base/common/lifecycle'; - -export interface CacheResult extends IDisposable { - promise: Promise; -} - -export class Cache { - - private result: CacheResult | null = null; - constructor(private task: (ct: CancellationToken) => Promise) { } - - get(): CacheResult { - if (this.result) { - return this.result; - } - - const cts = new CancellationTokenSource(); - const promise = this.task(cts.token); - - this.result = { - promise, - dispose: () => { - this.result = null; - cts.cancel(); - cts.dispose(); - } - }; - - return this.result; - } -} - -export function identity(t: T): T { - return t; -} - -interface ICacheOptions { - /** - * The cache key is used to identify the cache entry. - * Strict equality is used to compare cache keys. - */ - getCacheKey: (arg: TArg) => unknown; -} - -/** - * Uses a LRU cache to make a given parametrized function cached. - * Caches just the last key/value. -*/ -export class LRUCachedFunction { - private lastCache: TComputed | undefined = undefined; - private lastArgKey: unknown | undefined = undefined; - - private readonly _fn: (arg: TArg) => TComputed; - private readonly _computeKey: (arg: TArg) => unknown; - - constructor(fn: (arg: TArg) => TComputed); - constructor(options: ICacheOptions, fn: (arg: TArg) => TComputed); - constructor(arg1: ICacheOptions | ((arg: TArg) => TComputed), arg2?: (arg: TArg) => TComputed) { - if (typeof arg1 === 'function') { - this._fn = arg1; - this._computeKey = identity; - } else { - this._fn = arg2!; - this._computeKey = arg1.getCacheKey; - } - } - - public get(arg: TArg): TComputed { - const key = this._computeKey(arg); - if (this.lastArgKey !== key) { - this.lastArgKey = key; - this.lastCache = this._fn(arg); - } - return this.lastCache!; - } -} - -/** - * Uses an unbounded cache to memoize the results of the given function. -*/ -export class CachedFunction { - private readonly _map = new Map(); - private readonly _map2 = new Map(); - public get cachedValues(): ReadonlyMap { - return this._map; - } - - private readonly _fn: (arg: TArg) => TComputed; - private readonly _computeKey: (arg: TArg) => unknown; - - constructor(fn: (arg: TArg) => TComputed); - constructor(options: ICacheOptions, fn: (arg: TArg) => TComputed); - constructor(arg1: ICacheOptions | ((arg: TArg) => TComputed), arg2?: (arg: TArg) => TComputed) { - if (typeof arg1 === 'function') { - this._fn = arg1; - this._computeKey = identity; - } else { - this._fn = arg2!; - this._computeKey = arg1.getCacheKey; - } - } - - public get(arg: TArg): TComputed { - const key = this._computeKey(arg); - if (this._map2.has(key)) { - return this._map2.get(key)!; - } - - const value = this._fn(arg); - this._map.set(arg, value); - this._map2.set(key, value); - return value; - } -} diff --git a/src/vs/base/common/codiconsUtil.ts b/src/vs/base/common/codiconsUtil.ts deleted file mode 100644 index ce7f9b2daf..0000000000 --- a/src/vs/base/common/codiconsUtil.ts +++ /dev/null @@ -1,28 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ -import { ThemeIcon } from 'vs/base/common/themables'; -import { isString } from 'vs/base/common/types'; - - -const _codiconFontCharacters: { [id: string]: number } = Object.create(null); - -export function register(id: string, fontCharacter: number | string): ThemeIcon { - if (isString(fontCharacter)) { - const val = _codiconFontCharacters[fontCharacter]; - if (val === undefined) { - throw new Error(`${id} references an unknown codicon: ${fontCharacter}`); - } - fontCharacter = val; - } - _codiconFontCharacters[id] = fontCharacter; - return { id }; -} - -/** - * Only to be used by the iconRegistry. - */ -export function getCodiconFontCharacters(): { [id: string]: number } { - return _codiconFontCharacters; -} diff --git a/src/vs/base/common/color.ts b/src/vs/base/common/color.ts deleted file mode 100644 index 750cfe7dc7..0000000000 --- a/src/vs/base/common/color.ts +++ /dev/null @@ -1,633 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { CharCode } from 'vs/base/common/charCode'; - -function roundFloat(number: number, decimalPoints: number): number { - const decimal = Math.pow(10, decimalPoints); - return Math.round(number * decimal) / decimal; -} - -export class RGBA { - _rgbaBrand: void = undefined; - - /** - * Red: integer in [0-255] - */ - readonly r: number; - - /** - * Green: integer in [0-255] - */ - readonly g: number; - - /** - * Blue: integer in [0-255] - */ - readonly b: number; - - /** - * Alpha: float in [0-1] - */ - readonly a: number; - - constructor(r: number, g: number, b: number, a: number = 1) { - this.r = Math.min(255, Math.max(0, r)) | 0; - this.g = Math.min(255, Math.max(0, g)) | 0; - this.b = Math.min(255, Math.max(0, b)) | 0; - this.a = roundFloat(Math.max(Math.min(1, a), 0), 3); - } - - static equals(a: RGBA, b: RGBA): boolean { - return a.r === b.r && a.g === b.g && a.b === b.b && a.a === b.a; - } -} - -export class HSLA { - - _hslaBrand: void = undefined; - - /** - * Hue: integer in [0, 360] - */ - readonly h: number; - - /** - * Saturation: float in [0, 1] - */ - readonly s: number; - - /** - * Luminosity: float in [0, 1] - */ - readonly l: number; - - /** - * Alpha: float in [0, 1] - */ - readonly a: number; - - constructor(h: number, s: number, l: number, a: number) { - this.h = Math.max(Math.min(360, h), 0) | 0; - this.s = roundFloat(Math.max(Math.min(1, s), 0), 3); - this.l = roundFloat(Math.max(Math.min(1, l), 0), 3); - this.a = roundFloat(Math.max(Math.min(1, a), 0), 3); - } - - static equals(a: HSLA, b: HSLA): boolean { - return a.h === b.h && a.s === b.s && a.l === b.l && a.a === b.a; - } - - /** - * Converts an RGB color value to HSL. Conversion formula - * adapted from http://en.wikipedia.org/wiki/HSL_color_space. - * Assumes r, g, and b are contained in the set [0, 255] and - * returns h in the set [0, 360], s, and l in the set [0, 1]. - */ - static fromRGBA(rgba: RGBA): HSLA { - const r = rgba.r / 255; - const g = rgba.g / 255; - const b = rgba.b / 255; - const a = rgba.a; - - const max = Math.max(r, g, b); - const min = Math.min(r, g, b); - let h = 0; - let s = 0; - const l = (min + max) / 2; - const chroma = max - min; - - if (chroma > 0) { - s = Math.min((l <= 0.5 ? chroma / (2 * l) : chroma / (2 - (2 * l))), 1); - - switch (max) { - case r: h = (g - b) / chroma + (g < b ? 6 : 0); break; - case g: h = (b - r) / chroma + 2; break; - case b: h = (r - g) / chroma + 4; break; - } - - h *= 60; - h = Math.round(h); - } - return new HSLA(h, s, l, a); - } - - private static _hue2rgb(p: number, q: number, t: number): number { - if (t < 0) { - t += 1; - } - if (t > 1) { - t -= 1; - } - if (t < 1 / 6) { - return p + (q - p) * 6 * t; - } - if (t < 1 / 2) { - return q; - } - if (t < 2 / 3) { - return p + (q - p) * (2 / 3 - t) * 6; - } - return p; - } - - /** - * Converts an HSL color value to RGB. Conversion formula - * adapted from http://en.wikipedia.org/wiki/HSL_color_space. - * Assumes h in the set [0, 360] s, and l are contained in the set [0, 1] and - * returns r, g, and b in the set [0, 255]. - */ - static toRGBA(hsla: HSLA): RGBA { - const h = hsla.h / 360; - const { s, l, a } = hsla; - let r: number, g: number, b: number; - - if (s === 0) { - r = g = b = l; // achromatic - } else { - const q = l < 0.5 ? l * (1 + s) : l + s - l * s; - const p = 2 * l - q; - r = HSLA._hue2rgb(p, q, h + 1 / 3); - g = HSLA._hue2rgb(p, q, h); - b = HSLA._hue2rgb(p, q, h - 1 / 3); - } - - return new RGBA(Math.round(r * 255), Math.round(g * 255), Math.round(b * 255), a); - } -} - -export class HSVA { - - _hsvaBrand: void = undefined; - - /** - * Hue: integer in [0, 360] - */ - readonly h: number; - - /** - * Saturation: float in [0, 1] - */ - readonly s: number; - - /** - * Value: float in [0, 1] - */ - readonly v: number; - - /** - * Alpha: float in [0, 1] - */ - readonly a: number; - - constructor(h: number, s: number, v: number, a: number) { - this.h = Math.max(Math.min(360, h), 0) | 0; - this.s = roundFloat(Math.max(Math.min(1, s), 0), 3); - this.v = roundFloat(Math.max(Math.min(1, v), 0), 3); - this.a = roundFloat(Math.max(Math.min(1, a), 0), 3); - } - - static equals(a: HSVA, b: HSVA): boolean { - return a.h === b.h && a.s === b.s && a.v === b.v && a.a === b.a; - } - - // from http://www.rapidtables.com/convert/color/rgb-to-hsv.htm - static fromRGBA(rgba: RGBA): HSVA { - const r = rgba.r / 255; - const g = rgba.g / 255; - const b = rgba.b / 255; - const cmax = Math.max(r, g, b); - const cmin = Math.min(r, g, b); - const delta = cmax - cmin; - const s = cmax === 0 ? 0 : (delta / cmax); - let m: number; - - if (delta === 0) { - m = 0; - } else if (cmax === r) { - m = ((((g - b) / delta) % 6) + 6) % 6; - } else if (cmax === g) { - m = ((b - r) / delta) + 2; - } else { - m = ((r - g) / delta) + 4; - } - - return new HSVA(Math.round(m * 60), s, cmax, rgba.a); - } - - // from http://www.rapidtables.com/convert/color/hsv-to-rgb.htm - static toRGBA(hsva: HSVA): RGBA { - const { h, s, v, a } = hsva; - const c = v * s; - const x = c * (1 - Math.abs((h / 60) % 2 - 1)); - const m = v - c; - let [r, g, b] = [0, 0, 0]; - - if (h < 60) { - r = c; - g = x; - } else if (h < 120) { - r = x; - g = c; - } else if (h < 180) { - g = c; - b = x; - } else if (h < 240) { - g = x; - b = c; - } else if (h < 300) { - r = x; - b = c; - } else if (h <= 360) { - r = c; - b = x; - } - - r = Math.round((r + m) * 255); - g = Math.round((g + m) * 255); - b = Math.round((b + m) * 255); - - return new RGBA(r, g, b, a); - } -} - -export class Color { - - static fromHex(hex: string): Color { - return Color.Format.CSS.parseHex(hex) || Color.red; - } - - static equals(a: Color | null, b: Color | null): boolean { - if (!a && !b) { - return true; - } - if (!a || !b) { - return false; - } - return a.equals(b); - } - - readonly rgba: RGBA; - private _hsla?: HSLA; - get hsla(): HSLA { - if (this._hsla) { - return this._hsla; - } else { - return HSLA.fromRGBA(this.rgba); - } - } - - private _hsva?: HSVA; - get hsva(): HSVA { - if (this._hsva) { - return this._hsva; - } - return HSVA.fromRGBA(this.rgba); - } - - constructor(arg: RGBA | HSLA | HSVA) { - if (!arg) { - throw new Error('Color needs a value'); - } else if (arg instanceof RGBA) { - this.rgba = arg; - } else if (arg instanceof HSLA) { - this._hsla = arg; - this.rgba = HSLA.toRGBA(arg); - } else if (arg instanceof HSVA) { - this._hsva = arg; - this.rgba = HSVA.toRGBA(arg); - } else { - throw new Error('Invalid color ctor argument'); - } - } - - equals(other: Color | null): boolean { - return !!other && RGBA.equals(this.rgba, other.rgba) && HSLA.equals(this.hsla, other.hsla) && HSVA.equals(this.hsva, other.hsva); - } - - /** - * http://www.w3.org/TR/WCAG20/#relativeluminancedef - * Returns the number in the set [0, 1]. O => Darkest Black. 1 => Lightest white. - */ - getRelativeLuminance(): number { - const R = Color._relativeLuminanceForComponent(this.rgba.r); - const G = Color._relativeLuminanceForComponent(this.rgba.g); - const B = Color._relativeLuminanceForComponent(this.rgba.b); - const luminance = 0.2126 * R + 0.7152 * G + 0.0722 * B; - - return roundFloat(luminance, 4); - } - - private static _relativeLuminanceForComponent(color: number): number { - const c = color / 255; - return (c <= 0.03928) ? c / 12.92 : Math.pow(((c + 0.055) / 1.055), 2.4); - } - - /** - * http://www.w3.org/TR/WCAG20/#contrast-ratiodef - * Returns the contrast ration number in the set [1, 21]. - */ - getContrastRatio(another: Color): number { - const lum1 = this.getRelativeLuminance(); - const lum2 = another.getRelativeLuminance(); - return lum1 > lum2 ? (lum1 + 0.05) / (lum2 + 0.05) : (lum2 + 0.05) / (lum1 + 0.05); - } - - /** - * http://24ways.org/2010/calculating-color-contrast - * Return 'true' if darker color otherwise 'false' - */ - isDarker(): boolean { - const yiq = (this.rgba.r * 299 + this.rgba.g * 587 + this.rgba.b * 114) / 1000; - return yiq < 128; - } - - /** - * http://24ways.org/2010/calculating-color-contrast - * Return 'true' if lighter color otherwise 'false' - */ - isLighter(): boolean { - const yiq = (this.rgba.r * 299 + this.rgba.g * 587 + this.rgba.b * 114) / 1000; - return yiq >= 128; - } - - isLighterThan(another: Color): boolean { - const lum1 = this.getRelativeLuminance(); - const lum2 = another.getRelativeLuminance(); - return lum1 > lum2; - } - - isDarkerThan(another: Color): boolean { - const lum1 = this.getRelativeLuminance(); - const lum2 = another.getRelativeLuminance(); - return lum1 < lum2; - } - - lighten(factor: number): Color { - return new Color(new HSLA(this.hsla.h, this.hsla.s, this.hsla.l + this.hsla.l * factor, this.hsla.a)); - } - - darken(factor: number): Color { - return new Color(new HSLA(this.hsla.h, this.hsla.s, this.hsla.l - this.hsla.l * factor, this.hsla.a)); - } - - transparent(factor: number): Color { - const { r, g, b, a } = this.rgba; - return new Color(new RGBA(r, g, b, a * factor)); - } - - isTransparent(): boolean { - return this.rgba.a === 0; - } - - isOpaque(): boolean { - return this.rgba.a === 1; - } - - opposite(): Color { - return new Color(new RGBA(255 - this.rgba.r, 255 - this.rgba.g, 255 - this.rgba.b, this.rgba.a)); - } - - blend(c: Color): Color { - const rgba = c.rgba; - - // Convert to 0..1 opacity - const thisA = this.rgba.a; - const colorA = rgba.a; - - const a = thisA + colorA * (1 - thisA); - if (a < 1e-6) { - return Color.transparent; - } - - const r = this.rgba.r * thisA / a + rgba.r * colorA * (1 - thisA) / a; - const g = this.rgba.g * thisA / a + rgba.g * colorA * (1 - thisA) / a; - const b = this.rgba.b * thisA / a + rgba.b * colorA * (1 - thisA) / a; - - return new Color(new RGBA(r, g, b, a)); - } - - makeOpaque(opaqueBackground: Color): Color { - if (this.isOpaque() || opaqueBackground.rgba.a !== 1) { - // only allow to blend onto a non-opaque color onto a opaque color - return this; - } - - const { r, g, b, a } = this.rgba; - - // https://stackoverflow.com/questions/12228548/finding-equivalent-color-with-opacity - return new Color(new RGBA( - opaqueBackground.rgba.r - a * (opaqueBackground.rgba.r - r), - opaqueBackground.rgba.g - a * (opaqueBackground.rgba.g - g), - opaqueBackground.rgba.b - a * (opaqueBackground.rgba.b - b), - 1 - )); - } - - flatten(...backgrounds: Color[]): Color { - const background = backgrounds.reduceRight((accumulator, color) => { - return Color._flatten(color, accumulator); - }); - return Color._flatten(this, background); - } - - private static _flatten(foreground: Color, background: Color) { - const backgroundAlpha = 1 - foreground.rgba.a; - return new Color(new RGBA( - backgroundAlpha * background.rgba.r + foreground.rgba.a * foreground.rgba.r, - backgroundAlpha * background.rgba.g + foreground.rgba.a * foreground.rgba.g, - backgroundAlpha * background.rgba.b + foreground.rgba.a * foreground.rgba.b - )); - } - - private _toString?: string; - toString(): string { - if (!this._toString) { - this._toString = Color.Format.CSS.format(this); - } - return this._toString; - } - - static getLighterColor(of: Color, relative: Color, factor?: number): Color { - if (of.isLighterThan(relative)) { - return of; - } - factor = factor ? factor : 0.5; - const lum1 = of.getRelativeLuminance(); - const lum2 = relative.getRelativeLuminance(); - factor = factor * (lum2 - lum1) / lum2; - return of.lighten(factor); - } - - static getDarkerColor(of: Color, relative: Color, factor?: number): Color { - if (of.isDarkerThan(relative)) { - return of; - } - factor = factor ? factor : 0.5; - const lum1 = of.getRelativeLuminance(); - const lum2 = relative.getRelativeLuminance(); - factor = factor * (lum1 - lum2) / lum1; - return of.darken(factor); - } - - static readonly white = new Color(new RGBA(255, 255, 255, 1)); - static readonly black = new Color(new RGBA(0, 0, 0, 1)); - static readonly red = new Color(new RGBA(255, 0, 0, 1)); - static readonly blue = new Color(new RGBA(0, 0, 255, 1)); - static readonly green = new Color(new RGBA(0, 255, 0, 1)); - static readonly cyan = new Color(new RGBA(0, 255, 255, 1)); - static readonly lightgrey = new Color(new RGBA(211, 211, 211, 1)); - static readonly transparent = new Color(new RGBA(0, 0, 0, 0)); -} - -export namespace Color { - export namespace Format { - export namespace CSS { - - export function formatRGB(color: Color): string { - if (color.rgba.a === 1) { - return `rgb(${color.rgba.r}, ${color.rgba.g}, ${color.rgba.b})`; - } - - return Color.Format.CSS.formatRGBA(color); - } - - export function formatRGBA(color: Color): string { - return `rgba(${color.rgba.r}, ${color.rgba.g}, ${color.rgba.b}, ${+(color.rgba.a).toFixed(2)})`; - } - - export function formatHSL(color: Color): string { - if (color.hsla.a === 1) { - return `hsl(${color.hsla.h}, ${(color.hsla.s * 100).toFixed(2)}%, ${(color.hsla.l * 100).toFixed(2)}%)`; - } - - return Color.Format.CSS.formatHSLA(color); - } - - export function formatHSLA(color: Color): string { - return `hsla(${color.hsla.h}, ${(color.hsla.s * 100).toFixed(2)}%, ${(color.hsla.l * 100).toFixed(2)}%, ${color.hsla.a.toFixed(2)})`; - } - - function _toTwoDigitHex(n: number): string { - const r = n.toString(16); - return r.length !== 2 ? '0' + r : r; - } - - /** - * Formats the color as #RRGGBB - */ - export function formatHex(color: Color): string { - return `#${_toTwoDigitHex(color.rgba.r)}${_toTwoDigitHex(color.rgba.g)}${_toTwoDigitHex(color.rgba.b)}`; - } - - /** - * Formats the color as #RRGGBBAA - * If 'compact' is set, colors without transparancy will be printed as #RRGGBB - */ - export function formatHexA(color: Color, compact = false): string { - if (compact && color.rgba.a === 1) { - return Color.Format.CSS.formatHex(color); - } - - return `#${_toTwoDigitHex(color.rgba.r)}${_toTwoDigitHex(color.rgba.g)}${_toTwoDigitHex(color.rgba.b)}${_toTwoDigitHex(Math.round(color.rgba.a * 255))}`; - } - - /** - * The default format will use HEX if opaque and RGBA otherwise. - */ - export function format(color: Color): string { - if (color.isOpaque()) { - return Color.Format.CSS.formatHex(color); - } - - return Color.Format.CSS.formatRGBA(color); - } - - /** - * Converts an Hex color value to a Color. - * returns r, g, and b are contained in the set [0, 255] - * @param hex string (#RGB, #RGBA, #RRGGBB or #RRGGBBAA). - */ - export function parseHex(hex: string): Color | null { - const length = hex.length; - - if (length === 0) { - // Invalid color - return null; - } - - if (hex.charCodeAt(0) !== CharCode.Hash) { - // Does not begin with a # - return null; - } - - if (length === 7) { - // #RRGGBB format - const r = 16 * _parseHexDigit(hex.charCodeAt(1)) + _parseHexDigit(hex.charCodeAt(2)); - const g = 16 * _parseHexDigit(hex.charCodeAt(3)) + _parseHexDigit(hex.charCodeAt(4)); - const b = 16 * _parseHexDigit(hex.charCodeAt(5)) + _parseHexDigit(hex.charCodeAt(6)); - return new Color(new RGBA(r, g, b, 1)); - } - - if (length === 9) { - // #RRGGBBAA format - const r = 16 * _parseHexDigit(hex.charCodeAt(1)) + _parseHexDigit(hex.charCodeAt(2)); - const g = 16 * _parseHexDigit(hex.charCodeAt(3)) + _parseHexDigit(hex.charCodeAt(4)); - const b = 16 * _parseHexDigit(hex.charCodeAt(5)) + _parseHexDigit(hex.charCodeAt(6)); - const a = 16 * _parseHexDigit(hex.charCodeAt(7)) + _parseHexDigit(hex.charCodeAt(8)); - return new Color(new RGBA(r, g, b, a / 255)); - } - - if (length === 4) { - // #RGB format - const r = _parseHexDigit(hex.charCodeAt(1)); - const g = _parseHexDigit(hex.charCodeAt(2)); - const b = _parseHexDigit(hex.charCodeAt(3)); - return new Color(new RGBA(16 * r + r, 16 * g + g, 16 * b + b)); - } - - if (length === 5) { - // #RGBA format - const r = _parseHexDigit(hex.charCodeAt(1)); - const g = _parseHexDigit(hex.charCodeAt(2)); - const b = _parseHexDigit(hex.charCodeAt(3)); - const a = _parseHexDigit(hex.charCodeAt(4)); - return new Color(new RGBA(16 * r + r, 16 * g + g, 16 * b + b, (16 * a + a) / 255)); - } - - // Invalid color - return null; - } - - function _parseHexDigit(charCode: CharCode): number { - switch (charCode) { - case CharCode.Digit0: return 0; - case CharCode.Digit1: return 1; - case CharCode.Digit2: return 2; - case CharCode.Digit3: return 3; - case CharCode.Digit4: return 4; - case CharCode.Digit5: return 5; - case CharCode.Digit6: return 6; - case CharCode.Digit7: return 7; - case CharCode.Digit8: return 8; - case CharCode.Digit9: return 9; - case CharCode.a: return 10; - case CharCode.A: return 10; - case CharCode.b: return 11; - case CharCode.B: return 11; - case CharCode.c: return 12; - case CharCode.C: return 12; - case CharCode.d: return 13; - case CharCode.D: return 13; - case CharCode.e: return 14; - case CharCode.E: return 14; - case CharCode.f: return 15; - case CharCode.F: return 15; - } - return 0; - } - } - } -} diff --git a/src/vs/base/common/comparers.ts b/src/vs/base/common/comparers.ts deleted file mode 100644 index e515abd612..0000000000 --- a/src/vs/base/common/comparers.ts +++ /dev/null @@ -1,355 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { Lazy } from 'vs/base/common/lazy'; -import { sep } from 'vs/base/common/path'; - -// When comparing large numbers of strings it's better for performance to create an -// Intl.Collator object and use the function provided by its compare property -// than it is to use String.prototype.localeCompare() - -// A collator with numeric sorting enabled, and no sensitivity to case, accents or diacritics. -const intlFileNameCollatorBaseNumeric: Lazy<{ collator: Intl.Collator; collatorIsNumeric: boolean }> = new Lazy(() => { - const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }); - return { - collator, - collatorIsNumeric: collator.resolvedOptions().numeric - }; -}); - -// A collator with numeric sorting enabled. -const intlFileNameCollatorNumeric: Lazy<{ collator: Intl.Collator }> = new Lazy(() => { - const collator = new Intl.Collator(undefined, { numeric: true }); - return { - collator - }; -}); - -// A collator with numeric sorting enabled, and sensitivity to accents and diacritics but not case. -const intlFileNameCollatorNumericCaseInsensitive: Lazy<{ collator: Intl.Collator }> = new Lazy(() => { - const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'accent' }); - return { - collator - }; -}); - -/** Compares filenames without distinguishing the name from the extension. Disambiguates by unicode comparison. */ -export function compareFileNames(one: string | null, other: string | null, caseSensitive = false): number { - const a = one || ''; - const b = other || ''; - const result = intlFileNameCollatorBaseNumeric.value.collator.compare(a, b); - - // Using the numeric option will make compare(`foo1`, `foo01`) === 0. Disambiguate. - if (intlFileNameCollatorBaseNumeric.value.collatorIsNumeric && result === 0 && a !== b) { - return a < b ? -1 : 1; - } - - return result; -} - -/** Compares full filenames without grouping by case. */ -export function compareFileNamesDefault(one: string | null, other: string | null): number { - const collatorNumeric = intlFileNameCollatorNumeric.value.collator; - one = one || ''; - other = other || ''; - - return compareAndDisambiguateByLength(collatorNumeric, one, other); -} - -/** Compares full filenames grouping uppercase names before lowercase. */ -export function compareFileNamesUpper(one: string | null, other: string | null) { - const collatorNumeric = intlFileNameCollatorNumeric.value.collator; - one = one || ''; - other = other || ''; - - return compareCaseUpperFirst(one, other) || compareAndDisambiguateByLength(collatorNumeric, one, other); -} - -/** Compares full filenames grouping lowercase names before uppercase. */ -export function compareFileNamesLower(one: string | null, other: string | null) { - const collatorNumeric = intlFileNameCollatorNumeric.value.collator; - one = one || ''; - other = other || ''; - - return compareCaseLowerFirst(one, other) || compareAndDisambiguateByLength(collatorNumeric, one, other); -} - -/** Compares full filenames by unicode value. */ -export function compareFileNamesUnicode(one: string | null, other: string | null) { - one = one || ''; - other = other || ''; - - if (one === other) { - return 0; - } - - return one < other ? -1 : 1; -} - -/** Compares filenames by extension, then by name. Disambiguates by unicode comparison. */ -export function compareFileExtensions(one: string | null, other: string | null): number { - const [oneName, oneExtension] = extractNameAndExtension(one); - const [otherName, otherExtension] = extractNameAndExtension(other); - - let result = intlFileNameCollatorBaseNumeric.value.collator.compare(oneExtension, otherExtension); - - if (result === 0) { - // Using the numeric option will make compare(`foo1`, `foo01`) === 0. Disambiguate. - if (intlFileNameCollatorBaseNumeric.value.collatorIsNumeric && oneExtension !== otherExtension) { - return oneExtension < otherExtension ? -1 : 1; - } - - // Extensions are equal, compare filenames - result = intlFileNameCollatorBaseNumeric.value.collator.compare(oneName, otherName); - - if (intlFileNameCollatorBaseNumeric.value.collatorIsNumeric && result === 0 && oneName !== otherName) { - return oneName < otherName ? -1 : 1; - } - } - - return result; -} - -/** Compares filenames by extension, then by full filename. Mixes uppercase and lowercase names together. */ -export function compareFileExtensionsDefault(one: string | null, other: string | null): number { - one = one || ''; - other = other || ''; - const oneExtension = extractExtension(one); - const otherExtension = extractExtension(other); - const collatorNumeric = intlFileNameCollatorNumeric.value.collator; - const collatorNumericCaseInsensitive = intlFileNameCollatorNumericCaseInsensitive.value.collator; - - return compareAndDisambiguateByLength(collatorNumericCaseInsensitive, oneExtension, otherExtension) || - compareAndDisambiguateByLength(collatorNumeric, one, other); -} - -/** Compares filenames by extension, then case, then full filename. Groups uppercase names before lowercase. */ -export function compareFileExtensionsUpper(one: string | null, other: string | null): number { - one = one || ''; - other = other || ''; - const oneExtension = extractExtension(one); - const otherExtension = extractExtension(other); - const collatorNumeric = intlFileNameCollatorNumeric.value.collator; - const collatorNumericCaseInsensitive = intlFileNameCollatorNumericCaseInsensitive.value.collator; - - return compareAndDisambiguateByLength(collatorNumericCaseInsensitive, oneExtension, otherExtension) || - compareCaseUpperFirst(one, other) || - compareAndDisambiguateByLength(collatorNumeric, one, other); -} - -/** Compares filenames by extension, then case, then full filename. Groups lowercase names before uppercase. */ -export function compareFileExtensionsLower(one: string | null, other: string | null): number { - one = one || ''; - other = other || ''; - const oneExtension = extractExtension(one); - const otherExtension = extractExtension(other); - const collatorNumeric = intlFileNameCollatorNumeric.value.collator; - const collatorNumericCaseInsensitive = intlFileNameCollatorNumericCaseInsensitive.value.collator; - - return compareAndDisambiguateByLength(collatorNumericCaseInsensitive, oneExtension, otherExtension) || - compareCaseLowerFirst(one, other) || - compareAndDisambiguateByLength(collatorNumeric, one, other); -} - -/** Compares filenames by case-insensitive extension unicode value, then by full filename unicode value. */ -export function compareFileExtensionsUnicode(one: string | null, other: string | null) { - one = one || ''; - other = other || ''; - const oneExtension = extractExtension(one).toLowerCase(); - const otherExtension = extractExtension(other).toLowerCase(); - - // Check for extension differences - if (oneExtension !== otherExtension) { - return oneExtension < otherExtension ? -1 : 1; - } - - // Check for full filename differences. - if (one !== other) { - return one < other ? -1 : 1; - } - - return 0; -} - -const FileNameMatch = /^(.*?)(\.([^.]*))?$/; - -/** Extracts the name and extension from a full filename, with optional special handling for dotfiles */ -function extractNameAndExtension(str?: string | null, dotfilesAsNames = false): [string, string] { - const match = str ? FileNameMatch.exec(str) as Array : ([] as Array); - - let result: [string, string] = [(match && match[1]) || '', (match && match[3]) || '']; - - // if the dotfilesAsNames option is selected, treat an empty filename with an extension - // or a filename that starts with a dot, as a dotfile name - if (dotfilesAsNames && (!result[0] && result[1] || result[0] && result[0].charAt(0) === '.')) { - result = [result[0] + '.' + result[1], '']; - } - - return result; -} - -/** Extracts the extension from a full filename. Treats dotfiles as names, not extensions. */ -function extractExtension(str?: string | null): string { - const match = str ? FileNameMatch.exec(str) as Array : ([] as Array); - - return (match && match[1] && match[1].charAt(0) !== '.' && match[3]) || ''; -} - -function compareAndDisambiguateByLength(collator: Intl.Collator, one: string, other: string) { - // Check for differences - const result = collator.compare(one, other); - if (result !== 0) { - return result; - } - - // In a numeric comparison, `foo1` and `foo01` will compare as equivalent. - // Disambiguate by sorting the shorter string first. - if (one.length !== other.length) { - return one.length < other.length ? -1 : 1; - } - - return 0; -} - -/** @returns `true` if the string is starts with a lowercase letter. Otherwise, `false`. */ -function startsWithLower(string: string) { - const character = string.charAt(0); - - return (character.toLocaleUpperCase() !== character) ? true : false; -} - -/** @returns `true` if the string starts with an uppercase letter. Otherwise, `false`. */ -function startsWithUpper(string: string) { - const character = string.charAt(0); - - return (character.toLocaleLowerCase() !== character) ? true : false; -} - -/** - * Compares the case of the provided strings - lowercase before uppercase - * - * @returns - * ```text - * -1 if one is lowercase and other is uppercase - * 1 if one is uppercase and other is lowercase - * 0 otherwise - * ``` - */ -function compareCaseLowerFirst(one: string, other: string): number { - if (startsWithLower(one) && startsWithUpper(other)) { - return -1; - } - return (startsWithUpper(one) && startsWithLower(other)) ? 1 : 0; -} - -/** - * Compares the case of the provided strings - uppercase before lowercase - * - * @returns - * ```text - * -1 if one is uppercase and other is lowercase - * 1 if one is lowercase and other is uppercase - * 0 otherwise - * ``` - */ -function compareCaseUpperFirst(one: string, other: string): number { - if (startsWithUpper(one) && startsWithLower(other)) { - return -1; - } - return (startsWithLower(one) && startsWithUpper(other)) ? 1 : 0; -} - -function comparePathComponents(one: string, other: string, caseSensitive = false): number { - if (!caseSensitive) { - one = one && one.toLowerCase(); - other = other && other.toLowerCase(); - } - - if (one === other) { - return 0; - } - - return one < other ? -1 : 1; -} - -export function comparePaths(one: string, other: string, caseSensitive = false): number { - const oneParts = one.split(sep); - const otherParts = other.split(sep); - - const lastOne = oneParts.length - 1; - const lastOther = otherParts.length - 1; - let endOne: boolean, endOther: boolean; - - for (let i = 0; ; i++) { - endOne = lastOne === i; - endOther = lastOther === i; - - if (endOne && endOther) { - return compareFileNames(oneParts[i], otherParts[i], caseSensitive); - } else if (endOne) { - return -1; - } else if (endOther) { - return 1; - } - - const result = comparePathComponents(oneParts[i], otherParts[i], caseSensitive); - - if (result !== 0) { - return result; - } - } -} - -export function compareAnything(one: string, other: string, lookFor: string): number { - const elementAName = one.toLowerCase(); - const elementBName = other.toLowerCase(); - - // Sort prefix matches over non prefix matches - const prefixCompare = compareByPrefix(one, other, lookFor); - if (prefixCompare) { - return prefixCompare; - } - - // Sort suffix matches over non suffix matches - const elementASuffixMatch = elementAName.endsWith(lookFor); - const elementBSuffixMatch = elementBName.endsWith(lookFor); - if (elementASuffixMatch !== elementBSuffixMatch) { - return elementASuffixMatch ? -1 : 1; - } - - // Understand file names - const r = compareFileNames(elementAName, elementBName); - if (r !== 0) { - return r; - } - - // Compare by name - return elementAName.localeCompare(elementBName); -} - -export function compareByPrefix(one: string, other: string, lookFor: string): number { - const elementAName = one.toLowerCase(); - const elementBName = other.toLowerCase(); - - // Sort prefix matches over non prefix matches - const elementAPrefixMatch = elementAName.startsWith(lookFor); - const elementBPrefixMatch = elementBName.startsWith(lookFor); - if (elementAPrefixMatch !== elementBPrefixMatch) { - return elementAPrefixMatch ? -1 : 1; - } - - // Same prefix: Sort shorter matches to the top to have those on top that match more precisely - else if (elementAPrefixMatch && elementBPrefixMatch) { - if (elementAName.length < elementBName.length) { - return -1; - } - - if (elementAName.length > elementBName.length) { - return 1; - } - } - - return 0; -} diff --git a/src/vs/base/common/controlFlow.ts b/src/vs/base/common/controlFlow.ts deleted file mode 100644 index 2c4d020dd9..0000000000 --- a/src/vs/base/common/controlFlow.ts +++ /dev/null @@ -1,69 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ -import { BugIndicatingError } from 'vs/base/common/errors'; - -/* - * This file contains helper classes to manage control flow. -*/ - -/** - * Prevents code from being re-entrant. -*/ -export class ReentrancyBarrier { - private _isOccupied = false; - - /** - * Calls `runner` if the barrier is not occupied. - * During the call, the barrier becomes occupied. - */ - public runExclusivelyOrSkip(runner: () => void): void { - if (this._isOccupied) { - return; - } - this._isOccupied = true; - try { - runner(); - } finally { - this._isOccupied = false; - } - } - - /** - * Calls `runner`. If the barrier is occupied, throws an error. - * During the call, the barrier becomes active. - */ - public runExclusivelyOrThrow(runner: () => void): void { - if (this._isOccupied) { - throw new BugIndicatingError(`ReentrancyBarrier: reentrant call detected!`); - } - this._isOccupied = true; - try { - runner(); - } finally { - this._isOccupied = false; - } - } - - /** - * Indicates if some runner occupies this barrier. - */ - public get isOccupied() { - return this._isOccupied; - } - - public makeExclusiveOrSkip(fn: TFunction): TFunction { - return ((...args: any[]) => { - if (this._isOccupied) { - return; - } - this._isOccupied = true; - try { - return fn(...args); - } finally { - this._isOccupied = false; - } - }) as any; - } -} diff --git a/src/vs/base/common/date.ts b/src/vs/base/common/date.ts deleted file mode 100644 index 2ae03a711a..0000000000 --- a/src/vs/base/common/date.ts +++ /dev/null @@ -1,242 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { localize } from 'vs/nls'; - -const minute = 60; -const hour = minute * 60; -const day = hour * 24; -const week = day * 7; -const month = day * 30; -const year = day * 365; - -/** - * Create a localized difference of the time between now and the specified date. - * @param date The date to generate the difference from. - * @param appendAgoLabel Whether to append the " ago" to the end. - * @param useFullTimeWords Whether to use full words (eg. seconds) instead of - * shortened (eg. secs). - * @param disallowNow Whether to disallow the string "now" when the difference - * is less than 30 seconds. - */ -export function fromNow(date: number | Date, appendAgoLabel?: boolean, useFullTimeWords?: boolean, disallowNow?: boolean): string { - if (typeof date !== 'number') { - date = date.getTime(); - } - - const seconds = Math.round((new Date().getTime() - date) / 1000); - if (seconds < -30) { - return localize('date.fromNow.in', 'in {0}', fromNow(new Date().getTime() + seconds * 1000, false)); - } - - if (!disallowNow && seconds < 30) { - return localize('date.fromNow.now', 'now'); - } - - let value: number; - if (seconds < minute) { - value = seconds; - - if (appendAgoLabel) { - if (value === 1) { - return useFullTimeWords - ? localize('date.fromNow.seconds.singular.ago.fullWord', '{0} second ago', value) - : localize('date.fromNow.seconds.singular.ago', '{0} sec ago', value); - } else { - return useFullTimeWords - ? localize('date.fromNow.seconds.plural.ago.fullWord', '{0} seconds ago', value) - : localize('date.fromNow.seconds.plural.ago', '{0} secs ago', value); - } - } else { - if (value === 1) { - return useFullTimeWords - ? localize('date.fromNow.seconds.singular.fullWord', '{0} second', value) - : localize('date.fromNow.seconds.singular', '{0} sec', value); - } else { - return useFullTimeWords - ? localize('date.fromNow.seconds.plural.fullWord', '{0} seconds', value) - : localize('date.fromNow.seconds.plural', '{0} secs', value); - } - } - } - - if (seconds < hour) { - value = Math.floor(seconds / minute); - if (appendAgoLabel) { - if (value === 1) { - return useFullTimeWords - ? localize('date.fromNow.minutes.singular.ago.fullWord', '{0} minute ago', value) - : localize('date.fromNow.minutes.singular.ago', '{0} min ago', value); - } else { - return useFullTimeWords - ? localize('date.fromNow.minutes.plural.ago.fullWord', '{0} minutes ago', value) - : localize('date.fromNow.minutes.plural.ago', '{0} mins ago', value); - } - } else { - if (value === 1) { - return useFullTimeWords - ? localize('date.fromNow.minutes.singular.fullWord', '{0} minute', value) - : localize('date.fromNow.minutes.singular', '{0} min', value); - } else { - return useFullTimeWords - ? localize('date.fromNow.minutes.plural.fullWord', '{0} minutes', value) - : localize('date.fromNow.minutes.plural', '{0} mins', value); - } - } - } - - if (seconds < day) { - value = Math.floor(seconds / hour); - if (appendAgoLabel) { - if (value === 1) { - return useFullTimeWords - ? localize('date.fromNow.hours.singular.ago.fullWord', '{0} hour ago', value) - : localize('date.fromNow.hours.singular.ago', '{0} hr ago', value); - } else { - return useFullTimeWords - ? localize('date.fromNow.hours.plural.ago.fullWord', '{0} hours ago', value) - : localize('date.fromNow.hours.plural.ago', '{0} hrs ago', value); - } - } else { - if (value === 1) { - return useFullTimeWords - ? localize('date.fromNow.hours.singular.fullWord', '{0} hour', value) - : localize('date.fromNow.hours.singular', '{0} hr', value); - } else { - return useFullTimeWords - ? localize('date.fromNow.hours.plural.fullWord', '{0} hours', value) - : localize('date.fromNow.hours.plural', '{0} hrs', value); - } - } - } - - if (seconds < week) { - value = Math.floor(seconds / day); - if (appendAgoLabel) { - return value === 1 - ? localize('date.fromNow.days.singular.ago', '{0} day ago', value) - : localize('date.fromNow.days.plural.ago', '{0} days ago', value); - } else { - return value === 1 - ? localize('date.fromNow.days.singular', '{0} day', value) - : localize('date.fromNow.days.plural', '{0} days', value); - } - } - - if (seconds < month) { - value = Math.floor(seconds / week); - if (appendAgoLabel) { - if (value === 1) { - return useFullTimeWords - ? localize('date.fromNow.weeks.singular.ago.fullWord', '{0} week ago', value) - : localize('date.fromNow.weeks.singular.ago', '{0} wk ago', value); - } else { - return useFullTimeWords - ? localize('date.fromNow.weeks.plural.ago.fullWord', '{0} weeks ago', value) - : localize('date.fromNow.weeks.plural.ago', '{0} wks ago', value); - } - } else { - if (value === 1) { - return useFullTimeWords - ? localize('date.fromNow.weeks.singular.fullWord', '{0} week', value) - : localize('date.fromNow.weeks.singular', '{0} wk', value); - } else { - return useFullTimeWords - ? localize('date.fromNow.weeks.plural.fullWord', '{0} weeks', value) - : localize('date.fromNow.weeks.plural', '{0} wks', value); - } - } - } - - if (seconds < year) { - value = Math.floor(seconds / month); - if (appendAgoLabel) { - if (value === 1) { - return useFullTimeWords - ? localize('date.fromNow.months.singular.ago.fullWord', '{0} month ago', value) - : localize('date.fromNow.months.singular.ago', '{0} mo ago', value); - } else { - return useFullTimeWords - ? localize('date.fromNow.months.plural.ago.fullWord', '{0} months ago', value) - : localize('date.fromNow.months.plural.ago', '{0} mos ago', value); - } - } else { - if (value === 1) { - return useFullTimeWords - ? localize('date.fromNow.months.singular.fullWord', '{0} month', value) - : localize('date.fromNow.months.singular', '{0} mo', value); - } else { - return useFullTimeWords - ? localize('date.fromNow.months.plural.fullWord', '{0} months', value) - : localize('date.fromNow.months.plural', '{0} mos', value); - } - } - } - - value = Math.floor(seconds / year); - if (appendAgoLabel) { - if (value === 1) { - return useFullTimeWords - ? localize('date.fromNow.years.singular.ago.fullWord', '{0} year ago', value) - : localize('date.fromNow.years.singular.ago', '{0} yr ago', value); - } else { - return useFullTimeWords - ? localize('date.fromNow.years.plural.ago.fullWord', '{0} years ago', value) - : localize('date.fromNow.years.plural.ago', '{0} yrs ago', value); - } - } else { - if (value === 1) { - return useFullTimeWords - ? localize('date.fromNow.years.singular.fullWord', '{0} year', value) - : localize('date.fromNow.years.singular', '{0} yr', value); - } else { - return useFullTimeWords - ? localize('date.fromNow.years.plural.fullWord', '{0} years', value) - : localize('date.fromNow.years.plural', '{0} yrs', value); - } - } -} - -/** - * Gets a readable duration with intelligent/lossy precision. For example "40ms" or "3.040s") - * @param ms The duration to get in milliseconds. - * @param useFullTimeWords Whether to use full words (eg. seconds) instead of - * shortened (eg. secs). - */ -export function getDurationString(ms: number, useFullTimeWords?: boolean) { - const seconds = Math.abs(ms / 1000); - if (seconds < 1) { - return useFullTimeWords - ? localize('duration.ms.full', '{0} milliseconds', ms) - : localize('duration.ms', '{0}ms', ms); - } - if (seconds < minute) { - return useFullTimeWords - ? localize('duration.s.full', '{0} seconds', Math.round(ms) / 1000) - : localize('duration.s', '{0}s', Math.round(ms) / 1000); - } - if (seconds < hour) { - return useFullTimeWords - ? localize('duration.m.full', '{0} minutes', Math.round(ms / (1000 * minute))) - : localize('duration.m', '{0} mins', Math.round(ms / (1000 * minute))); - } - if (seconds < day) { - return useFullTimeWords - ? localize('duration.h.full', '{0} hours', Math.round(ms / (1000 * hour))) - : localize('duration.h', '{0} hrs', Math.round(ms / (1000 * hour))); - } - return localize('duration.d', '{0} days', Math.round(ms / (1000 * day))); -} - -export function toLocalISOString(date: Date): string { - return date.getFullYear() + - '-' + String(date.getMonth() + 1).padStart(2, '0') + - '-' + String(date.getDate()).padStart(2, '0') + - 'T' + String(date.getHours()).padStart(2, '0') + - ':' + String(date.getMinutes()).padStart(2, '0') + - ':' + String(date.getSeconds()).padStart(2, '0') + - '.' + (date.getMilliseconds() / 1000).toFixed(3).slice(2, 5) + - 'Z'; -} diff --git a/src/vs/base/common/desktopEnvironmentInfo.ts b/src/vs/base/common/desktopEnvironmentInfo.ts deleted file mode 100644 index b6e4c107db..0000000000 --- a/src/vs/base/common/desktopEnvironmentInfo.ts +++ /dev/null @@ -1,101 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { env } from 'vs/base/common/process'; - -// Define the enumeration for Desktop Environments -enum DesktopEnvironment { - UNKNOWN = 'UNKNOWN', - CINNAMON = 'CINNAMON', - DEEPIN = 'DEEPIN', - GNOME = 'GNOME', - KDE3 = 'KDE3', - KDE4 = 'KDE4', - KDE5 = 'KDE5', - KDE6 = 'KDE6', - PANTHEON = 'PANTHEON', - UNITY = 'UNITY', - XFCE = 'XFCE', - UKUI = 'UKUI', - LXQT = 'LXQT', -} - -const kXdgCurrentDesktopEnvVar = 'XDG_CURRENT_DESKTOP'; -const kKDESessionEnvVar = 'KDE_SESSION_VERSION'; - -export function getDesktopEnvironment(): DesktopEnvironment { - const xdgCurrentDesktop = env[kXdgCurrentDesktopEnvVar]; - if (xdgCurrentDesktop) { - const values = xdgCurrentDesktop.split(':').map(value => value.trim()).filter(value => value.length > 0); - for (const value of values) { - switch (value) { - case 'Unity': { - const desktopSessionUnity = env['DESKTOP_SESSION']; - if (desktopSessionUnity && desktopSessionUnity.includes('gnome-fallback')) { - return DesktopEnvironment.GNOME; - } - - return DesktopEnvironment.UNITY; - } - case 'Deepin': - return DesktopEnvironment.DEEPIN; - case 'GNOME': - return DesktopEnvironment.GNOME; - case 'X-Cinnamon': - return DesktopEnvironment.CINNAMON; - case 'KDE': { - const kdeSession = env[kKDESessionEnvVar]; - if (kdeSession === '5') { return DesktopEnvironment.KDE5; } - if (kdeSession === '6') { return DesktopEnvironment.KDE6; } - return DesktopEnvironment.KDE4; - } - case 'Pantheon': - return DesktopEnvironment.PANTHEON; - case 'XFCE': - return DesktopEnvironment.XFCE; - case 'UKUI': - return DesktopEnvironment.UKUI; - case 'LXQt': - return DesktopEnvironment.LXQT; - } - } - } - - const desktopSession = env['DESKTOP_SESSION']; - if (desktopSession) { - switch (desktopSession) { - case 'deepin': - return DesktopEnvironment.DEEPIN; - case 'gnome': - case 'mate': - return DesktopEnvironment.GNOME; - case 'kde4': - case 'kde-plasma': - return DesktopEnvironment.KDE4; - case 'kde': - if (kKDESessionEnvVar in env) { - return DesktopEnvironment.KDE4; - } - return DesktopEnvironment.KDE3; - case 'xfce': - case 'xubuntu': - return DesktopEnvironment.XFCE; - case 'ukui': - return DesktopEnvironment.UKUI; - } - } - - if ('GNOME_DESKTOP_SESSION_ID' in env) { - return DesktopEnvironment.GNOME; - } - if ('KDE_FULL_SESSION' in env) { - if (kKDESessionEnvVar in env) { - return DesktopEnvironment.KDE4; - } - return DesktopEnvironment.KDE3; - } - - return DesktopEnvironment.UNKNOWN; -} diff --git a/src/vs/base/common/errorMessage.ts b/src/vs/base/common/errorMessage.ts deleted file mode 100644 index f16616da43..0000000000 --- a/src/vs/base/common/errorMessage.ts +++ /dev/null @@ -1,113 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import * as arrays from 'vs/base/common/arrays'; -import * as types from 'vs/base/common/types'; -import * as nls from 'vs/nls'; -import { IAction } from 'vs/base/common/actions'; - -function exceptionToErrorMessage(exception: any, verbose: boolean): string { - if (verbose && (exception.stack || exception.stacktrace)) { - return nls.localize('stackTrace.format', "{0}: {1}", detectSystemErrorMessage(exception), stackToString(exception.stack) || stackToString(exception.stacktrace)); - } - - return detectSystemErrorMessage(exception); -} - -function stackToString(stack: string[] | string | undefined): string | undefined { - if (Array.isArray(stack)) { - return stack.join('\n'); - } - - return stack; -} - -function detectSystemErrorMessage(exception: any): string { - - // Custom node.js error from us - if (exception.code === 'ERR_UNC_HOST_NOT_ALLOWED') { - return `${exception.message}. Please update the 'security.allowedUNCHosts' setting if you want to allow this host.`; - } - - // See https://nodejs.org/api/errors.html#errors_class_system_error - if (typeof exception.code === 'string' && typeof exception.errno === 'number' && typeof exception.syscall === 'string') { - return nls.localize('nodeExceptionMessage', "A system error occurred ({0})", exception.message); - } - - return exception.message || nls.localize('error.defaultMessage', "An unknown error occurred. Please consult the log for more details."); -} - -/** - * Tries to generate a human readable error message out of the error. If the verbose parameter - * is set to true, the error message will include stacktrace details if provided. - * - * @returns A string containing the error message. - */ -export function toErrorMessage(error: any = null, verbose: boolean = false): string { - if (!error) { - return nls.localize('error.defaultMessage', "An unknown error occurred. Please consult the log for more details."); - } - - if (Array.isArray(error)) { - const errors: any[] = arrays.coalesce(error); - const msg = toErrorMessage(errors[0], verbose); - - if (errors.length > 1) { - return nls.localize('error.moreErrors', "{0} ({1} errors in total)", msg, errors.length); - } - - return msg; - } - - if (types.isString(error)) { - return error; - } - - if (error.detail) { - const detail = error.detail; - - if (detail.error) { - return exceptionToErrorMessage(detail.error, verbose); - } - - if (detail.exception) { - return exceptionToErrorMessage(detail.exception, verbose); - } - } - - if (error.stack) { - return exceptionToErrorMessage(error, verbose); - } - - if (error.message) { - return error.message; - } - - return nls.localize('error.defaultMessage', "An unknown error occurred. Please consult the log for more details."); -} - - -export interface IErrorWithActions extends Error { - actions: IAction[]; -} - -export function isErrorWithActions(obj: unknown): obj is IErrorWithActions { - const candidate = obj as IErrorWithActions | undefined; - - return candidate instanceof Error && Array.isArray(candidate.actions); -} - -export function createErrorWithActions(messageOrError: string | Error, actions: IAction[]): IErrorWithActions { - let error: IErrorWithActions; - if (typeof messageOrError === 'string') { - error = new Error(messageOrError) as IErrorWithActions; - } else { - error = messageOrError as IErrorWithActions; - } - - error.actions = actions; - - return error; -} diff --git a/src/vs/base/common/extpath.ts b/src/vs/base/common/extpath.ts deleted file mode 100644 index e0ee6968dc..0000000000 --- a/src/vs/base/common/extpath.ts +++ /dev/null @@ -1,423 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { CharCode } from 'vs/base/common/charCode'; -import { isAbsolute, join, normalize, posix, sep } from 'vs/base/common/path'; -import { isWindows } from 'vs/base/common/platform'; -import { equalsIgnoreCase, rtrim, startsWithIgnoreCase } from 'vs/base/common/strings'; -import { isNumber } from 'vs/base/common/types'; - -export function isPathSeparator(code: number) { - return code === CharCode.Slash || code === CharCode.Backslash; -} - -/** - * Takes a Windows OS path and changes backward slashes to forward slashes. - * This should only be done for OS paths from Windows (or user provided paths potentially from Windows). - * Using it on a Linux or MaxOS path might change it. - */ -export function toSlashes(osPath: string) { - return osPath.replace(/[\\/]/g, posix.sep); -} - -/** - * Takes a Windows OS path (using backward or forward slashes) and turns it into a posix path: - * - turns backward slashes into forward slashes - * - makes it absolute if it starts with a drive letter - * This should only be done for OS paths from Windows (or user provided paths potentially from Windows). - * Using it on a Linux or MaxOS path might change it. - */ -export function toPosixPath(osPath: string) { - if (osPath.indexOf('/') === -1) { - osPath = toSlashes(osPath); - } - if (/^[a-zA-Z]:(\/|$)/.test(osPath)) { // starts with a drive letter - osPath = '/' + osPath; - } - return osPath; -} - -/** - * Computes the _root_ this path, like `getRoot('c:\files') === c:\`, - * `getRoot('files:///files/path') === files:///`, - * or `getRoot('\\server\shares\path') === \\server\shares\` - */ -export function getRoot(path: string, sep: string = posix.sep): string { - if (!path) { - return ''; - } - - const len = path.length; - const firstLetter = path.charCodeAt(0); - if (isPathSeparator(firstLetter)) { - if (isPathSeparator(path.charCodeAt(1))) { - // UNC candidate \\localhost\shares\ddd - // ^^^^^^^^^^^^^^^^^^^ - if (!isPathSeparator(path.charCodeAt(2))) { - let pos = 3; - const start = pos; - for (; pos < len; pos++) { - if (isPathSeparator(path.charCodeAt(pos))) { - break; - } - } - if (start !== pos && !isPathSeparator(path.charCodeAt(pos + 1))) { - pos += 1; - for (; pos < len; pos++) { - if (isPathSeparator(path.charCodeAt(pos))) { - return path.slice(0, pos + 1) // consume this separator - .replace(/[\\/]/g, sep); - } - } - } - } - } - - // /user/far - // ^ - return sep; - - } else if (isWindowsDriveLetter(firstLetter)) { - // check for windows drive letter c:\ or c: - - if (path.charCodeAt(1) === CharCode.Colon) { - if (isPathSeparator(path.charCodeAt(2))) { - // C:\fff - // ^^^ - return path.slice(0, 2) + sep; - } else { - // C: - // ^^ - return path.slice(0, 2); - } - } - } - - // check for URI - // scheme://authority/path - // ^^^^^^^^^^^^^^^^^^^ - let pos = path.indexOf('://'); - if (pos !== -1) { - pos += 3; // 3 -> "://".length - for (; pos < len; pos++) { - if (isPathSeparator(path.charCodeAt(pos))) { - return path.slice(0, pos + 1); // consume this separator - } - } - } - - return ''; -} - -/** - * Check if the path follows this pattern: `\\hostname\sharename`. - * - * @see https://msdn.microsoft.com/en-us/library/gg465305.aspx - * @return A boolean indication if the path is a UNC path, on none-windows - * always false. - */ -export function isUNC(path: string): boolean { - if (!isWindows) { - // UNC is a windows concept - return false; - } - - if (!path || path.length < 5) { - // at least \\a\b - return false; - } - - let code = path.charCodeAt(0); - if (code !== CharCode.Backslash) { - return false; - } - - code = path.charCodeAt(1); - - if (code !== CharCode.Backslash) { - return false; - } - - let pos = 2; - const start = pos; - for (; pos < path.length; pos++) { - code = path.charCodeAt(pos); - if (code === CharCode.Backslash) { - break; - } - } - - if (start === pos) { - return false; - } - - code = path.charCodeAt(pos + 1); - - if (isNaN(code) || code === CharCode.Backslash) { - return false; - } - - return true; -} - -// Reference: https://en.wikipedia.org/wiki/Filename -const WINDOWS_INVALID_FILE_CHARS = /[\\/:\*\?"<>\|]/g; -const UNIX_INVALID_FILE_CHARS = /[/]/g; -const WINDOWS_FORBIDDEN_NAMES = /^(con|prn|aux|clock\$|nul|lpt[0-9]|com[0-9])(\.(.*?))?$/i; -export function isValidBasename(name: string | null | undefined, isWindowsOS: boolean = isWindows): boolean { - const invalidFileChars = isWindowsOS ? WINDOWS_INVALID_FILE_CHARS : UNIX_INVALID_FILE_CHARS; - - if (!name || name.length === 0 || /^\s+$/.test(name)) { - return false; // require a name that is not just whitespace - } - - invalidFileChars.lastIndex = 0; // the holy grail of software development - if (invalidFileChars.test(name)) { - return false; // check for certain invalid file characters - } - - if (isWindowsOS && WINDOWS_FORBIDDEN_NAMES.test(name)) { - return false; // check for certain invalid file names - } - - if (name === '.' || name === '..') { - return false; // check for reserved values - } - - if (isWindowsOS && name[name.length - 1] === '.') { - return false; // Windows: file cannot end with a "." - } - - if (isWindowsOS && name.length !== name.trim().length) { - return false; // Windows: file cannot end with a whitespace - } - - if (name.length > 255) { - return false; // most file systems do not allow files > 255 length - } - - return true; -} - -/** - * @deprecated please use `IUriIdentityService.extUri.isEqual` instead. If you are - * in a context without services, consider to pass down the `extUri` from the outside - * or use `extUriBiasedIgnorePathCase` if you know what you are doing. - */ -export function isEqual(pathA: string, pathB: string, ignoreCase?: boolean): boolean { - const identityEquals = (pathA === pathB); - if (!ignoreCase || identityEquals) { - return identityEquals; - } - - if (!pathA || !pathB) { - return false; - } - - return equalsIgnoreCase(pathA, pathB); -} - -/** - * @deprecated please use `IUriIdentityService.extUri.isEqualOrParent` instead. If - * you are in a context without services, consider to pass down the `extUri` from the - * outside, or use `extUriBiasedIgnorePathCase` if you know what you are doing. - */ -export function isEqualOrParent(base: string, parentCandidate: string, ignoreCase?: boolean, separator = sep): boolean { - if (base === parentCandidate) { - return true; - } - - if (!base || !parentCandidate) { - return false; - } - - if (parentCandidate.length > base.length) { - return false; - } - - if (ignoreCase) { - const beginsWith = startsWithIgnoreCase(base, parentCandidate); - if (!beginsWith) { - return false; - } - - if (parentCandidate.length === base.length) { - return true; // same path, different casing - } - - let sepOffset = parentCandidate.length; - if (parentCandidate.charAt(parentCandidate.length - 1) === separator) { - sepOffset--; // adjust the expected sep offset in case our candidate already ends in separator character - } - - return base.charAt(sepOffset) === separator; - } - - if (parentCandidate.charAt(parentCandidate.length - 1) !== separator) { - parentCandidate += separator; - } - - return base.indexOf(parentCandidate) === 0; -} - -export function isWindowsDriveLetter(char0: number): boolean { - return char0 >= CharCode.A && char0 <= CharCode.Z || char0 >= CharCode.a && char0 <= CharCode.z; -} - -export function sanitizeFilePath(candidate: string, cwd: string): string { - - // Special case: allow to open a drive letter without trailing backslash - if (isWindows && candidate.endsWith(':')) { - candidate += sep; - } - - // Ensure absolute - if (!isAbsolute(candidate)) { - candidate = join(cwd, candidate); - } - - // Ensure normalized - candidate = normalize(candidate); - - // Ensure no trailing slash/backslash - return removeTrailingPathSeparator(candidate); -} - -export function removeTrailingPathSeparator(candidate: string): string { - if (isWindows) { - candidate = rtrim(candidate, sep); - - // Special case: allow to open drive root ('C:\') - if (candidate.endsWith(':')) { - candidate += sep; - } - - } else { - candidate = rtrim(candidate, sep); - - // Special case: allow to open root ('/') - if (!candidate) { - candidate = sep; - } - } - - return candidate; -} - -export function isRootOrDriveLetter(path: string): boolean { - const pathNormalized = normalize(path); - - if (isWindows) { - if (path.length > 3) { - return false; - } - - return hasDriveLetter(pathNormalized) && - (path.length === 2 || pathNormalized.charCodeAt(2) === CharCode.Backslash); - } - - return pathNormalized === posix.sep; -} - -export function hasDriveLetter(path: string, isWindowsOS: boolean = isWindows): boolean { - if (isWindowsOS) { - return isWindowsDriveLetter(path.charCodeAt(0)) && path.charCodeAt(1) === CharCode.Colon; - } - - return false; -} - -export function getDriveLetter(path: string, isWindowsOS: boolean = isWindows): string | undefined { - return hasDriveLetter(path, isWindowsOS) ? path[0] : undefined; -} - -export function indexOfPath(path: string, candidate: string, ignoreCase?: boolean): number { - if (candidate.length > path.length) { - return -1; - } - - if (path === candidate) { - return 0; - } - - if (ignoreCase) { - path = path.toLowerCase(); - candidate = candidate.toLowerCase(); - } - - return path.indexOf(candidate); -} - -export interface IPathWithLineAndColumn { - path: string; - line?: number; - column?: number; -} - -export function parseLineAndColumnAware(rawPath: string): IPathWithLineAndColumn { - const segments = rawPath.split(':'); // C:\file.txt:: - - let path: string | undefined = undefined; - let line: number | undefined = undefined; - let column: number | undefined = undefined; - - for (const segment of segments) { - const segmentAsNumber = Number(segment); - if (!isNumber(segmentAsNumber)) { - path = !!path ? [path, segment].join(':') : segment; // a colon can well be part of a path (e.g. C:\...) - } else if (line === undefined) { - line = segmentAsNumber; - } else if (column === undefined) { - column = segmentAsNumber; - } - } - - if (!path) { - throw new Error('Format for `--goto` should be: `FILE:LINE(:COLUMN)`'); - } - - return { - path, - line: line !== undefined ? line : undefined, - column: column !== undefined ? column : line !== undefined ? 1 : undefined // if we have a line, make sure column is also set - }; -} - -const pathChars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; -const windowsSafePathFirstChars = 'BDEFGHIJKMOQRSTUVWXYZbdefghijkmoqrstuvwxyz0123456789'; - -export function randomPath(parent?: string, prefix?: string, randomLength = 8): string { - let suffix = ''; - for (let i = 0; i < randomLength; i++) { - let pathCharsTouse: string; - if (i === 0 && isWindows && !prefix && (randomLength === 3 || randomLength === 4)) { - - // Windows has certain reserved file names that cannot be used, such - // as AUX, CON, PRN, etc. We want to avoid generating a random name - // that matches that pattern, so we use a different set of characters - // for the first character of the name that does not include any of - // the reserved names first characters. - - pathCharsTouse = windowsSafePathFirstChars; - } else { - pathCharsTouse = pathChars; - } - - suffix += pathCharsTouse.charAt(Math.floor(Math.random() * pathCharsTouse.length)); - } - - let randomFileName: string; - if (prefix) { - randomFileName = `${prefix}-${suffix}`; - } else { - randomFileName = suffix; - } - - if (parent) { - return join(parent, randomFileName); - } - - return randomFileName; -} diff --git a/src/vs/base/common/glob.ts b/src/vs/base/common/glob.ts deleted file mode 100644 index 2f24135eb9..0000000000 --- a/src/vs/base/common/glob.ts +++ /dev/null @@ -1,737 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { equals } from 'vs/base/common/arrays'; -import { isThenable } from 'vs/base/common/async'; -import { CharCode } from 'vs/base/common/charCode'; -import { isEqualOrParent } from 'vs/base/common/extpath'; -import { basename, extname, posix, sep } from 'vs/base/common/path'; -import { isLinux } from 'vs/base/common/platform'; -import { escapeRegExpCharacters, ltrim } from 'vs/base/common/strings'; - -export interface IRelativePattern { - - /** - * A base file path to which this pattern will be matched against relatively. - */ - readonly base: string; - - /** - * A file glob pattern like `*.{ts,js}` that will be matched on file paths - * relative to the base path. - * - * Example: Given a base of `/home/work/folder` and a file path of `/home/work/folder/index.js`, - * the file glob pattern will match on `index.js`. - */ - readonly pattern: string; -} - -export interface IExpression { - [pattern: string]: boolean | SiblingClause; -} - -export function getEmptyExpression(): IExpression { - return Object.create(null); -} - -interface SiblingClause { - when: string; -} - -export const GLOBSTAR = '**'; -export const GLOB_SPLIT = '/'; - -const PATH_REGEX = '[/\\\\]'; // any slash or backslash -const NO_PATH_REGEX = '[^/\\\\]'; // any non-slash and non-backslash -const ALL_FORWARD_SLASHES = /\//g; - -function starsToRegExp(starCount: number, isLastPattern?: boolean): string { - switch (starCount) { - case 0: - return ''; - case 1: - return `${NO_PATH_REGEX}*?`; // 1 star matches any number of characters except path separator (/ and \) - non greedy (?) - default: - // Matches: (Path Sep OR Path Val followed by Path Sep) 0-many times except when it's the last pattern - // in which case also matches (Path Sep followed by Path Val) - // Group is non capturing because we don't need to capture at all (?:...) - // Overall we use non-greedy matching because it could be that we match too much - return `(?:${PATH_REGEX}|${NO_PATH_REGEX}+${PATH_REGEX}${isLastPattern ? `|${PATH_REGEX}${NO_PATH_REGEX}+` : ''})*?`; - } -} - -export function splitGlobAware(pattern: string, splitChar: string): string[] { - if (!pattern) { - return []; - } - - const segments: string[] = []; - - let inBraces = false; - let inBrackets = false; - - let curVal = ''; - for (const char of pattern) { - switch (char) { - case splitChar: - if (!inBraces && !inBrackets) { - segments.push(curVal); - curVal = ''; - - continue; - } - break; - case '{': - inBraces = true; - break; - case '}': - inBraces = false; - break; - case '[': - inBrackets = true; - break; - case ']': - inBrackets = false; - break; - } - - curVal += char; - } - - // Tail - if (curVal) { - segments.push(curVal); - } - - return segments; -} - -function parseRegExp(pattern: string): string { - if (!pattern) { - return ''; - } - - let regEx = ''; - - // Split up into segments for each slash found - const segments = splitGlobAware(pattern, GLOB_SPLIT); - - // Special case where we only have globstars - if (segments.every(segment => segment === GLOBSTAR)) { - regEx = '.*'; - } - - // Build regex over segments - else { - let previousSegmentWasGlobStar = false; - segments.forEach((segment, index) => { - - // Treat globstar specially - if (segment === GLOBSTAR) { - - // if we have more than one globstar after another, just ignore it - if (previousSegmentWasGlobStar) { - return; - } - - regEx += starsToRegExp(2, index === segments.length - 1); - } - - // Anything else, not globstar - else { - - // States - let inBraces = false; - let braceVal = ''; - - let inBrackets = false; - let bracketVal = ''; - - for (const char of segment) { - - // Support brace expansion - if (char !== '}' && inBraces) { - braceVal += char; - continue; - } - - // Support brackets - if (inBrackets && (char !== ']' || !bracketVal) /* ] is literally only allowed as first character in brackets to match it */) { - let res: string; - - // range operator - if (char === '-') { - res = char; - } - - // negation operator (only valid on first index in bracket) - else if ((char === '^' || char === '!') && !bracketVal) { - res = '^'; - } - - // glob split matching is not allowed within character ranges - // see http://man7.org/linux/man-pages/man7/glob.7.html - else if (char === GLOB_SPLIT) { - res = ''; - } - - // anything else gets escaped - else { - res = escapeRegExpCharacters(char); - } - - bracketVal += res; - continue; - } - - switch (char) { - case '{': - inBraces = true; - continue; - - case '[': - inBrackets = true; - continue; - - case '}': { - const choices = splitGlobAware(braceVal, ','); - - // Converts {foo,bar} => [foo|bar] - const braceRegExp = `(?:${choices.map(choice => parseRegExp(choice)).join('|')})`; - - regEx += braceRegExp; - - inBraces = false; - braceVal = ''; - - break; - } - - case ']': { - regEx += ('[' + bracketVal + ']'); - - inBrackets = false; - bracketVal = ''; - - break; - } - - case '?': - regEx += NO_PATH_REGEX; // 1 ? matches any single character except path separator (/ and \) - continue; - - case '*': - regEx += starsToRegExp(1); - continue; - - default: - regEx += escapeRegExpCharacters(char); - } - } - - // Tail: Add the slash we had split on if there is more to - // come and the remaining pattern is not a globstar - // For example if pattern: some/**/*.js we want the "/" after - // some to be included in the RegEx to prevent a folder called - // "something" to match as well. - if ( - index < segments.length - 1 && // more segments to come after this - ( - segments[index + 1] !== GLOBSTAR || // next segment is not **, or... - index + 2 < segments.length // ...next segment is ** but there is more segments after that - ) - ) { - regEx += PATH_REGEX; - } - } - - // update globstar state - previousSegmentWasGlobStar = (segment === GLOBSTAR); - }); - } - - return regEx; -} - -// regexes to check for trivial glob patterns that just check for String#endsWith -const T1 = /^\*\*\/\*\.[\w\.-]+$/; // **/*.something -const T2 = /^\*\*\/([\w\.-]+)\/?$/; // **/something -const T3 = /^{\*\*\/\*?[\w\.-]+\/?(,\*\*\/\*?[\w\.-]+\/?)*}$/; // {**/*.something,**/*.else} or {**/package.json,**/project.json} -const T3_2 = /^{\*\*\/\*?[\w\.-]+(\/(\*\*)?)?(,\*\*\/\*?[\w\.-]+(\/(\*\*)?)?)*}$/; // Like T3, with optional trailing /** -const T4 = /^\*\*((\/[\w\.-]+)+)\/?$/; // **/something/else -const T5 = /^([\w\.-]+(\/[\w\.-]+)*)\/?$/; // something/else - -export type ParsedPattern = (path: string, basename?: string) => boolean; - -// The `ParsedExpression` returns a `Promise` -// iff `hasSibling` returns a `Promise`. -export type ParsedExpression = (path: string, basename?: string, hasSibling?: (name: string) => boolean | Promise) => string | null | Promise /* the matching pattern */; - -interface IGlobOptions { - - /** - * Simplify patterns for use as exclusion filters during - * tree traversal to skip entire subtrees. Cannot be used - * outside of a tree traversal. - */ - trimForExclusions?: boolean; -} - -interface ParsedStringPattern { - (path: string, basename?: string): string | null | Promise /* the matching pattern */; - basenames?: string[]; - patterns?: string[]; - allBasenames?: string[]; - allPaths?: string[]; -} - -interface ParsedExpressionPattern { - (path: string, basename?: string, name?: string, hasSibling?: (name: string) => boolean | Promise): string | null | Promise /* the matching pattern */; - requiresSiblings?: boolean; - allBasenames?: string[]; - allPaths?: string[]; -} - -const FALSE = function () { - return false; -}; - -const NULL = function (): string | null { - return null; -}; - -function trimForExclusions(pattern: string, options: IGlobOptions): string { - return options.trimForExclusions && pattern.endsWith('/**') ? pattern.substr(0, pattern.length - 2) : pattern; // dropping **, tailing / is dropped later -} - -// common pattern: **/*.txt just need endsWith check -function trivia1(base: string, pattern: string): ParsedStringPattern { - return function (path: string, basename?: string) { - return typeof path === 'string' && path.endsWith(base) ? pattern : null; - }; -} - -// common pattern: **/some.txt just need basename check -function trivia2(base: string, pattern: string): ParsedStringPattern { - const slashBase = `/${base}`; - const backslashBase = `\\${base}`; - - const parsedPattern: ParsedStringPattern = function (path: string, basename?: string) { - if (typeof path !== 'string') { - return null; - } - - if (basename) { - return basename === base ? pattern : null; - } - - return path === base || path.endsWith(slashBase) || path.endsWith(backslashBase) ? pattern : null; - }; - - const basenames = [base]; - parsedPattern.basenames = basenames; - parsedPattern.patterns = [pattern]; - parsedPattern.allBasenames = basenames; - - return parsedPattern; -} - -// repetition of common patterns (see above) {**/*.txt,**/*.png} -function trivia3(pattern: string, options: IGlobOptions): ParsedStringPattern { - const parsedPatterns = aggregateBasenameMatches(pattern.slice(1, -1) - .split(',') - .map(pattern => parsePattern(pattern, options)) - .filter(pattern => pattern !== NULL), pattern); - - const patternsLength = parsedPatterns.length; - if (!patternsLength) { - return NULL; - } - - if (patternsLength === 1) { - return parsedPatterns[0]; - } - - const parsedPattern: ParsedStringPattern = function (path: string, basename?: string) { - for (let i = 0, n = parsedPatterns.length; i < n; i++) { - if (parsedPatterns[i](path, basename)) { - return pattern; - } - } - - return null; - }; - - const withBasenames = parsedPatterns.find(pattern => !!pattern.allBasenames); - if (withBasenames) { - parsedPattern.allBasenames = withBasenames.allBasenames; - } - - const allPaths = parsedPatterns.reduce((all, current) => current.allPaths ? all.concat(current.allPaths) : all, [] as string[]); - if (allPaths.length) { - parsedPattern.allPaths = allPaths; - } - - return parsedPattern; -} - -// common patterns: **/something/else just need endsWith check, something/else just needs and equals check -function trivia4and5(targetPath: string, pattern: string, matchPathEnds: boolean): ParsedStringPattern { - const usingPosixSep = sep === posix.sep; - const nativePath = usingPosixSep ? targetPath : targetPath.replace(ALL_FORWARD_SLASHES, sep); - const nativePathEnd = sep + nativePath; - const targetPathEnd = posix.sep + targetPath; - - let parsedPattern: ParsedStringPattern; - if (matchPathEnds) { - parsedPattern = function (path: string, basename?: string) { - return typeof path === 'string' && ((path === nativePath || path.endsWith(nativePathEnd)) || !usingPosixSep && (path === targetPath || path.endsWith(targetPathEnd))) ? pattern : null; - }; - } else { - parsedPattern = function (path: string, basename?: string) { - return typeof path === 'string' && (path === nativePath || (!usingPosixSep && path === targetPath)) ? pattern : null; - }; - } - - parsedPattern.allPaths = [(matchPathEnds ? '*/' : './') + targetPath]; - - return parsedPattern; -} - -function toRegExp(pattern: string): ParsedStringPattern { - try { - const regExp = new RegExp(`^${parseRegExp(pattern)}$`); - return function (path: string) { - regExp.lastIndex = 0; // reset RegExp to its initial state to reuse it! - - return typeof path === 'string' && regExp.test(path) ? pattern : null; - }; - } catch (error) { - return NULL; - } -} - -/** - * Simplified glob matching. Supports a subset of glob patterns: - * * `*` to match zero or more characters in a path segment - * * `?` to match on one character in a path segment - * * `**` to match any number of path segments, including none - * * `{}` to group conditions (e.g. *.{ts,js} matches all TypeScript and JavaScript files) - * * `[]` to declare a range of characters to match in a path segment (e.g., `example.[0-9]` to match on `example.0`, `example.1`, …) - * * `[!...]` to negate a range of characters to match in a path segment (e.g., `example.[!0-9]` to match on `example.a`, `example.b`, but not `example.0`) - */ -export function match(pattern: string | IRelativePattern, path: string): boolean; -export function match(expression: IExpression, path: string, hasSibling?: (name: string) => boolean): string /* the matching pattern */; -export function match(arg1: string | IExpression | IRelativePattern, path: string, hasSibling?: (name: string) => boolean): boolean | string | null | Promise { - if (!arg1 || typeof path !== 'string') { - return false; - } - - return parse(arg1)(path, undefined, hasSibling); -} - -/** - * Simplified glob matching. Supports a subset of glob patterns: - * * `*` to match zero or more characters in a path segment - * * `?` to match on one character in a path segment - * * `**` to match any number of path segments, including none - * * `{}` to group conditions (e.g. *.{ts,js} matches all TypeScript and JavaScript files) - * * `[]` to declare a range of characters to match in a path segment (e.g., `example.[0-9]` to match on `example.0`, `example.1`, …) - * * `[!...]` to negate a range of characters to match in a path segment (e.g., `example.[!0-9]` to match on `example.a`, `example.b`, but not `example.0`) - */ -export function parse(pattern: string | IRelativePattern, options?: IGlobOptions): ParsedPattern; -export function parse(expression: IExpression, options?: IGlobOptions): ParsedExpression; -export function parse(arg1: string | IExpression | IRelativePattern, options?: IGlobOptions): ParsedPattern | ParsedExpression; -export function parse(arg1: string | IExpression | IRelativePattern, options: IGlobOptions = {}): ParsedPattern | ParsedExpression { - if (!arg1) { - return FALSE; - } - - // Glob with String - if (typeof arg1 === 'string' || isRelativePattern(arg1)) { - const parsedPattern = parsePattern(arg1, options); - if (parsedPattern === NULL) { - return FALSE; - } - - const resultPattern: ParsedPattern & { allBasenames?: string[]; allPaths?: string[] } = function (path: string, basename?: string) { - return !!parsedPattern(path, basename); - }; - - if (parsedPattern.allBasenames) { - resultPattern.allBasenames = parsedPattern.allBasenames; - } - - if (parsedPattern.allPaths) { - resultPattern.allPaths = parsedPattern.allPaths; - } - - return resultPattern; - } - - // Glob with Expression - return parsedExpression(arg1, options); -} - -export function isRelativePattern(obj: unknown): obj is IRelativePattern { - const rp = obj as IRelativePattern | undefined | null; - if (!rp) { - return false; - } - - return typeof rp.base === 'string' && typeof rp.pattern === 'string'; -} - -export function getBasenameTerms(patternOrExpression: ParsedPattern | ParsedExpression): string[] { - return (patternOrExpression).allBasenames || []; -} - -export function getPathTerms(patternOrExpression: ParsedPattern | ParsedExpression): string[] { - return (patternOrExpression).allPaths || []; -} - -function parsedExpression(expression: IExpression, options: IGlobOptions): ParsedExpression { - const parsedPatterns = aggregateBasenameMatches(Object.getOwnPropertyNames(expression) - .map(pattern => parseExpressionPattern(pattern, expression[pattern], options)) - .filter(pattern => pattern !== NULL)); - - const patternsLength = parsedPatterns.length; - if (!patternsLength) { - return NULL; - } - - if (!parsedPatterns.some(parsedPattern => !!(parsedPattern).requiresSiblings)) { - if (patternsLength === 1) { - return parsedPatterns[0] as ParsedStringPattern; - } - - const resultExpression: ParsedStringPattern = function (path: string, basename?: string) { - let resultPromises: Promise[] | undefined = undefined; - - for (let i = 0, n = parsedPatterns.length; i < n; i++) { - const result = parsedPatterns[i](path, basename); - if (typeof result === 'string') { - return result; // immediately return as soon as the first expression matches - } - - // If the result is a promise, we have to keep it for - // later processing and await the result properly. - if (isThenable(result)) { - if (!resultPromises) { - resultPromises = []; - } - - resultPromises.push(result); - } - } - - // With result promises, we have to loop over each and - // await the result before we can return any result. - if (resultPromises) { - return (async () => { - for (const resultPromise of resultPromises) { - const result = await resultPromise; - if (typeof result === 'string') { - return result; - } - } - - return null; - })(); - } - - return null; - }; - - const withBasenames = parsedPatterns.find(pattern => !!pattern.allBasenames); - if (withBasenames) { - resultExpression.allBasenames = withBasenames.allBasenames; - } - - const allPaths = parsedPatterns.reduce((all, current) => current.allPaths ? all.concat(current.allPaths) : all, [] as string[]); - if (allPaths.length) { - resultExpression.allPaths = allPaths; - } - - return resultExpression; - } - - const resultExpression: ParsedStringPattern = function (path: string, base?: string, hasSibling?: (name: string) => boolean | Promise) { - let name: string | undefined = undefined; - let resultPromises: Promise[] | undefined = undefined; - - for (let i = 0, n = parsedPatterns.length; i < n; i++) { - - // Pattern matches path - const parsedPattern = (parsedPatterns[i]); - if (parsedPattern.requiresSiblings && hasSibling) { - if (!base) { - base = basename(path); - } - - if (!name) { - name = base.substr(0, base.length - extname(path).length); - } - } - - const result = parsedPattern(path, base, name, hasSibling); - if (typeof result === 'string') { - return result; // immediately return as soon as the first expression matches - } - - // If the result is a promise, we have to keep it for - // later processing and await the result properly. - if (isThenable(result)) { - if (!resultPromises) { - resultPromises = []; - } - - resultPromises.push(result); - } - } - - // With result promises, we have to loop over each and - // await the result before we can return any result. - if (resultPromises) { - return (async () => { - for (const resultPromise of resultPromises) { - const result = await resultPromise; - if (typeof result === 'string') { - return result; - } - } - - return null; - })(); - } - - return null; - }; - - const withBasenames = parsedPatterns.find(pattern => !!pattern.allBasenames); - if (withBasenames) { - resultExpression.allBasenames = withBasenames.allBasenames; - } - - const allPaths = parsedPatterns.reduce((all, current) => current.allPaths ? all.concat(current.allPaths) : all, [] as string[]); - if (allPaths.length) { - resultExpression.allPaths = allPaths; - } - - return resultExpression; -} - -function parseExpressionPattern(pattern: string, value: boolean | SiblingClause, options: IGlobOptions): (ParsedStringPattern | ParsedExpressionPattern) { - if (value === false) { - return NULL; // pattern is disabled - } - - const parsedPattern = parsePattern(pattern, options); - if (parsedPattern === NULL) { - return NULL; - } - - // Expression Pattern is - if (typeof value === 'boolean') { - return parsedPattern; - } - - // Expression Pattern is - if (value) { - const when = value.when; - if (typeof when === 'string') { - const result: ParsedExpressionPattern = (path: string, basename?: string, name?: string, hasSibling?: (name: string) => boolean | Promise) => { - if (!hasSibling || !parsedPattern(path, basename)) { - return null; - } - - const clausePattern = when.replace('$(basename)', () => name!); - const matched = hasSibling(clausePattern); - return isThenable(matched) ? - matched.then(match => match ? pattern : null) : - matched ? pattern : null; - }; - - result.requiresSiblings = true; - - return result; - } - } - - // Expression is anything - return parsedPattern; -} - -function aggregateBasenameMatches(parsedPatterns: Array, result?: string): Array { - const basenamePatterns = parsedPatterns.filter(parsedPattern => !!(parsedPattern).basenames); - if (basenamePatterns.length < 2) { - return parsedPatterns; - } - - const basenames = basenamePatterns.reduce((all, current) => { - const basenames = (current).basenames; - - return basenames ? all.concat(basenames) : all; - }, [] as string[]); - - let patterns: string[]; - if (result) { - patterns = []; - - for (let i = 0, n = basenames.length; i < n; i++) { - patterns.push(result); - } - } else { - patterns = basenamePatterns.reduce((all, current) => { - const patterns = (current).patterns; - - return patterns ? all.concat(patterns) : all; - }, [] as string[]); - } - - const aggregate: ParsedStringPattern = function (path: string, basename?: string) { - if (typeof path !== 'string') { - return null; - } - - if (!basename) { - let i: number; - for (i = path.length; i > 0; i--) { - const ch = path.charCodeAt(i - 1); - if (ch === CharCode.Slash || ch === CharCode.Backslash) { - break; - } - } - - basename = path.substr(i); - } - - const index = basenames.indexOf(basename); - return index !== -1 ? patterns[index] : null; - }; - - aggregate.basenames = basenames; - aggregate.patterns = patterns; - aggregate.allBasenames = basenames; - - const aggregatedPatterns = parsedPatterns.filter(parsedPattern => !(parsedPattern).basenames); - aggregatedPatterns.push(aggregate); - - return aggregatedPatterns; -} - -export function patternsEquals(patternsA: Array | undefined, patternsB: Array | undefined): boolean { - return equals(patternsA, patternsB, (a, b) => { - if (typeof a === 'string' && typeof b === 'string') { - return a === b; - } - - if (typeof a !== 'string' && typeof b !== 'string') { - return a.base === b.base && a.pattern === b.pattern; - } - - return false; - }); -} diff --git a/src/vs/base/common/hierarchicalKind.ts b/src/vs/base/common/hierarchicalKind.ts deleted file mode 100644 index a2edd61437..0000000000 --- a/src/vs/base/common/hierarchicalKind.ts +++ /dev/null @@ -1,31 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -export class HierarchicalKind { - public static readonly sep = '.'; - - public static readonly None = new HierarchicalKind('@@none@@'); // Special kind that matches nothing - public static readonly Empty = new HierarchicalKind(''); - - constructor( - public readonly value: string - ) { } - - public equals(other: HierarchicalKind): boolean { - return this.value === other.value; - } - - public contains(other: HierarchicalKind): boolean { - return this.equals(other) || this.value === '' || other.value.startsWith(this.value + HierarchicalKind.sep); - } - - public intersects(other: HierarchicalKind): boolean { - return this.contains(other) || other.contains(this); - } - - public append(...parts: string[]): HierarchicalKind { - return new HierarchicalKind((this.value ? [this.value, ...parts] : parts).join(HierarchicalKind.sep)); - } -} diff --git a/src/vs/base/common/history.ts b/src/vs/base/common/history.ts deleted file mode 100644 index 9d644a851c..0000000000 --- a/src/vs/base/common/history.ts +++ /dev/null @@ -1,277 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { SetWithKey } from 'vs/base/common/collections'; -import { ArrayNavigator, INavigator } from 'vs/base/common/navigator'; - -export class HistoryNavigator implements INavigator { - - private _history!: Set; - private _limit: number; - private _navigator!: ArrayNavigator; - - constructor(history: readonly T[] = [], limit: number = 10) { - this._initialize(history); - this._limit = limit; - this._onChange(); - } - - public getHistory(): T[] { - return this._elements; - } - - public add(t: T) { - this._history.delete(t); - this._history.add(t); - this._onChange(); - } - - public next(): T | null { - // This will navigate past the end of the last element, and in that case the input should be cleared - return this._navigator.next(); - } - - public previous(): T | null { - if (this._currentPosition() !== 0) { - return this._navigator.previous(); - } - return null; - } - - public current(): T | null { - return this._navigator.current(); - } - - public first(): T | null { - return this._navigator.first(); - } - - public last(): T | null { - return this._navigator.last(); - } - - public isFirst(): boolean { - return this._currentPosition() === 0; - } - - public isLast(): boolean { - return this._currentPosition() >= this._elements.length - 1; - } - - public isNowhere(): boolean { - return this._navigator.current() === null; - } - - public has(t: T): boolean { - return this._history.has(t); - } - - public clear(): void { - this._initialize([]); - this._onChange(); - } - - private _onChange() { - this._reduceToLimit(); - const elements = this._elements; - this._navigator = new ArrayNavigator(elements, 0, elements.length, elements.length); - } - - private _reduceToLimit() { - const data = this._elements; - if (data.length > this._limit) { - this._initialize(data.slice(data.length - this._limit)); - } - } - - private _currentPosition(): number { - const currentElement = this._navigator.current(); - if (!currentElement) { - return -1; - } - - return this._elements.indexOf(currentElement); - } - - private _initialize(history: readonly T[]): void { - this._history = new Set(); - for (const entry of history) { - this._history.add(entry); - } - } - - private get _elements(): T[] { - const elements: T[] = []; - this._history.forEach(e => elements.push(e)); - return elements; - } -} - -interface HistoryNode { - value: T; - previous: HistoryNode | undefined; - next: HistoryNode | undefined; -} - -/** - * The right way to use HistoryNavigator2 is for the last item in the list to be the user's uncommitted current text. eg empty string, or whatever has been typed. Then - * the user can navigate away from the last item through the list, and back to it. When updating the last item, call replaceLast. - */ -export class HistoryNavigator2 { - - private valueSet: Set; - private head: HistoryNode; - private tail: HistoryNode; - private cursor: HistoryNode; - private _size: number; - get size(): number { return this._size; } - - constructor(history: readonly T[], private capacity: number = 10, private identityFn: (t: T) => any = t => t) { - if (history.length < 1) { - throw new Error('not supported'); - } - - this._size = 1; - this.head = this.tail = this.cursor = { - value: history[0], - previous: undefined, - next: undefined - }; - - this.valueSet = new SetWithKey([history[0]], identityFn); - for (let i = 1; i < history.length; i++) { - this.add(history[i]); - } - } - - add(value: T): void { - const node: HistoryNode = { - value, - previous: this.tail, - next: undefined - }; - - this.tail.next = node; - this.tail = node; - this.cursor = this.tail; - this._size++; - - if (this.valueSet.has(value)) { - this._deleteFromList(value); - } else { - this.valueSet.add(value); - } - - while (this._size > this.capacity) { - this.valueSet.delete(this.head.value); - - this.head = this.head.next!; - this.head.previous = undefined; - this._size--; - } - } - - /** - * @returns old last value - */ - replaceLast(value: T): T { - if (this.identityFn(this.tail.value) === this.identityFn(value)) { - return value; - } - - const oldValue = this.tail.value; - this.valueSet.delete(oldValue); - this.tail.value = value; - - if (this.valueSet.has(value)) { - this._deleteFromList(value); - } else { - this.valueSet.add(value); - } - - return oldValue; - } - - prepend(value: T): void { - if (this._size === this.capacity || this.valueSet.has(value)) { - return; - } - - const node: HistoryNode = { - value, - previous: undefined, - next: this.head - }; - - this.head.previous = node; - this.head = node; - this._size++; - - this.valueSet.add(value); - } - - isAtEnd(): boolean { - return this.cursor === this.tail; - } - - current(): T { - return this.cursor.value; - } - - previous(): T { - if (this.cursor.previous) { - this.cursor = this.cursor.previous; - } - - return this.cursor.value; - } - - next(): T { - if (this.cursor.next) { - this.cursor = this.cursor.next; - } - - return this.cursor.value; - } - - has(t: T): boolean { - return this.valueSet.has(t); - } - - resetCursor(): T { - this.cursor = this.tail; - return this.cursor.value; - } - - *[Symbol.iterator](): Iterator { - let node: HistoryNode | undefined = this.head; - - while (node) { - yield node.value; - node = node.next; - } - } - - private _deleteFromList(value: T): void { - let temp = this.head; - - const valueKey = this.identityFn(value); - while (temp !== this.tail) { - if (this.identityFn(temp.value) === valueKey) { - if (temp === this.head) { - this.head = this.head.next!; - this.head.previous = undefined; - } else { - temp.previous!.next = temp.next; - temp.next!.previous = temp.previous; - } - - this._size--; - } - - temp = temp.next!; - } - } -} diff --git a/src/vs/base/common/hotReload.ts b/src/vs/base/common/hotReload.ts deleted file mode 100644 index 609fd9d8ef..0000000000 --- a/src/vs/base/common/hotReload.ts +++ /dev/null @@ -1,112 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { IDisposable } from 'vs/base/common/lifecycle'; -import { env } from 'vs/base/common/process'; - -export function isHotReloadEnabled(): boolean { - return env && !!env['VSCODE_DEV']; -} -export function registerHotReloadHandler(handler: HotReloadHandler): IDisposable { - if (!isHotReloadEnabled()) { - return { dispose() { } }; - } else { - const handlers = registerGlobalHotReloadHandler(); - handlers.add(handler); - return { - dispose() { handlers.delete(handler); } - }; - } -} - -/** - * Takes the old exports of the module to reload and returns a function to apply the new exports. - * If `undefined` is returned, this handler is not able to handle the module. - * - * If no handler can apply the new exports, the module will not be reloaded. - */ -export type HotReloadHandler = (args: { oldExports: Record; newSrc: string; config: IHotReloadConfig }) => AcceptNewExportsHandler | undefined; -export type AcceptNewExportsHandler = (newExports: Record) => boolean; -export type IHotReloadConfig = HotReloadConfig; - -function registerGlobalHotReloadHandler() { - if (!hotReloadHandlers) { - hotReloadHandlers = new Set(); - } - - const g = globalThis as unknown as GlobalThisAddition; - if (!g.$hotReload_applyNewExports) { - g.$hotReload_applyNewExports = args => { - const args2 = { config: { mode: undefined }, ...args }; - - const results: AcceptNewExportsHandler[] = []; - for (const h of hotReloadHandlers!) { - const result = h(args2); - if (result) { - results.push(result); - } - } - if (results.length > 0) { - return newExports => { - let result = false; - for (const r of results) { - if (r(newExports)) { - result = true; - } - } - return result; - }; - } - return undefined; - }; - } - - return hotReloadHandlers; -} - -let hotReloadHandlers: Set<(args: { oldExports: Record; newSrc: string; config: HotReloadConfig }) => AcceptNewExportsFn | undefined> | undefined = undefined; - -interface HotReloadConfig { - mode?: 'patch-prototype' | undefined; -} - -interface GlobalThisAddition { - $hotReload_applyNewExports?(args: { oldExports: Record; newSrc: string; config?: HotReloadConfig }): AcceptNewExportsFn | undefined; -} - -type AcceptNewExportsFn = (newExports: Record) => boolean; - -if (isHotReloadEnabled()) { - // This code does not run in production. - registerHotReloadHandler(({ oldExports, newSrc, config }) => { - if (config.mode !== 'patch-prototype') { - return undefined; - } - - return newExports => { - for (const key in newExports) { - const exportedItem = newExports[key]; - console.log(`[hot-reload] Patching prototype methods of '${key}'`, { exportedItem }); - if (typeof exportedItem === 'function' && exportedItem.prototype) { - const oldExportedItem = oldExports[key]; - if (oldExportedItem) { - for (const prop of Object.getOwnPropertyNames(exportedItem.prototype)) { - const descriptor = Object.getOwnPropertyDescriptor(exportedItem.prototype, prop)!; - const oldDescriptor = Object.getOwnPropertyDescriptor((oldExportedItem as any).prototype, prop); - - if (descriptor?.value?.toString() !== oldDescriptor?.value?.toString()) { - console.log(`[hot-reload] Patching prototype method '${key}.${prop}'`); - } - - Object.defineProperty((oldExportedItem as any).prototype, prop, descriptor); - } - newExports[key] = oldExportedItem; - } - } - } - return true; - }; - }); -} diff --git a/src/vs/base/common/hotReloadHelpers.ts b/src/vs/base/common/hotReloadHelpers.ts deleted file mode 100644 index 174b1adcbc..0000000000 --- a/src/vs/base/common/hotReloadHelpers.ts +++ /dev/null @@ -1,30 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { isHotReloadEnabled, registerHotReloadHandler } from 'vs/base/common/hotReload'; -import { IReader, observableSignalFromEvent } from 'vs/base/common/observable'; - -export function readHotReloadableExport(value: T, reader: IReader | undefined): T { - observeHotReloadableExports([value], reader); - return value; -} - -export function observeHotReloadableExports(values: any[], reader: IReader | undefined): void { - if (isHotReloadEnabled()) { - const o = observableSignalFromEvent( - 'reload', - event => registerHotReloadHandler(({ oldExports }) => { - if (![...Object.values(oldExports)].some(v => values.includes(v))) { - return undefined; - } - return (_newExports) => { - event(undefined); - return true; - }; - }) - ); - o.read(reader); - } -} diff --git a/src/vs/base/common/idGenerator.ts b/src/vs/base/common/idGenerator.ts deleted file mode 100644 index 0a66cfec7b..0000000000 --- a/src/vs/base/common/idGenerator.ts +++ /dev/null @@ -1,21 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -export class IdGenerator { - - private _prefix: string; - private _lastId: number; - - constructor(prefix: string) { - this._prefix = prefix; - this._lastId = 0; - } - - public nextId(): string { - return this._prefix + (++this._lastId); - } -} - -export const defaultGenerator = new IdGenerator('id#'); diff --git a/src/vs/base/common/ime.ts b/src/vs/base/common/ime.ts deleted file mode 100644 index ce80099b28..0000000000 --- a/src/vs/base/common/ime.ts +++ /dev/null @@ -1,36 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { Emitter } from 'vs/base/common/event'; - -export class IMEImpl { - - private readonly _onDidChange = new Emitter(); - public readonly onDidChange = this._onDidChange.event; - - private _enabled = true; - - public get enabled() { - return this._enabled; - } - - /** - * Enable IME - */ - public enable(): void { - this._enabled = true; - this._onDidChange.fire(); - } - - /** - * Disable IME - */ - public disable(): void { - this._enabled = false; - this._onDidChange.fire(); - } -} - -export const IME = new IMEImpl(); diff --git a/src/vs/base/common/json.ts b/src/vs/base/common/json.ts deleted file mode 100644 index e4adc59003..0000000000 --- a/src/vs/base/common/json.ts +++ /dev/null @@ -1,1326 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -export const enum ScanError { - None = 0, - UnexpectedEndOfComment = 1, - UnexpectedEndOfString = 2, - UnexpectedEndOfNumber = 3, - InvalidUnicode = 4, - InvalidEscapeCharacter = 5, - InvalidCharacter = 6 -} - -export const enum SyntaxKind { - OpenBraceToken = 1, - CloseBraceToken = 2, - OpenBracketToken = 3, - CloseBracketToken = 4, - CommaToken = 5, - ColonToken = 6, - NullKeyword = 7, - TrueKeyword = 8, - FalseKeyword = 9, - StringLiteral = 10, - NumericLiteral = 11, - LineCommentTrivia = 12, - BlockCommentTrivia = 13, - LineBreakTrivia = 14, - Trivia = 15, - Unknown = 16, - EOF = 17 -} - -/** - * The scanner object, representing a JSON scanner at a position in the input string. - */ -export interface JSONScanner { - /** - * Sets the scan position to a new offset. A call to 'scan' is needed to get the first token. - */ - setPosition(pos: number): void; - /** - * Read the next token. Returns the token code. - */ - scan(): SyntaxKind; - /** - * Returns the current scan position, which is after the last read token. - */ - getPosition(): number; - /** - * Returns the last read token. - */ - getToken(): SyntaxKind; - /** - * Returns the last read token value. The value for strings is the decoded string content. For numbers its of type number, for boolean it's true or false. - */ - getTokenValue(): string; - /** - * The start offset of the last read token. - */ - getTokenOffset(): number; - /** - * The length of the last read token. - */ - getTokenLength(): number; - /** - * An error code of the last scan. - */ - getTokenError(): ScanError; -} - - - -export interface ParseError { - error: ParseErrorCode; - offset: number; - length: number; -} - -export const enum ParseErrorCode { - InvalidSymbol = 1, - InvalidNumberFormat = 2, - PropertyNameExpected = 3, - ValueExpected = 4, - ColonExpected = 5, - CommaExpected = 6, - CloseBraceExpected = 7, - CloseBracketExpected = 8, - EndOfFileExpected = 9, - InvalidCommentToken = 10, - UnexpectedEndOfComment = 11, - UnexpectedEndOfString = 12, - UnexpectedEndOfNumber = 13, - InvalidUnicode = 14, - InvalidEscapeCharacter = 15, - InvalidCharacter = 16 -} - -export type NodeType = 'object' | 'array' | 'property' | 'string' | 'number' | 'boolean' | 'null'; - -export interface Node { - readonly type: NodeType; - readonly value?: any; - readonly offset: number; - readonly length: number; - readonly colonOffset?: number; - readonly parent?: Node; - readonly children?: Node[]; -} - -export type Segment = string | number; -export type JSONPath = Segment[]; - -export interface Location { - /** - * The previous property key or literal value (string, number, boolean or null) or undefined. - */ - previousNode?: Node; - /** - * The path describing the location in the JSON document. The path consists of a sequence strings - * representing an object property or numbers for array indices. - */ - path: JSONPath; - /** - * Matches the locations path against a pattern consisting of strings (for properties) and numbers (for array indices). - * '*' will match a single segment, of any property name or index. - * '**' will match a sequence of segments or no segment, of any property name or index. - */ - matches: (patterns: JSONPath) => boolean; - /** - * If set, the location's offset is at a property key. - */ - isAtPropertyKey: boolean; -} - -export interface ParseOptions { - disallowComments?: boolean; - allowTrailingComma?: boolean; - allowEmptyContent?: boolean; -} - -export namespace ParseOptions { - export const DEFAULT = { - allowTrailingComma: true - }; -} - -export interface JSONVisitor { - /** - * Invoked when an open brace is encountered and an object is started. The offset and length represent the location of the open brace. - */ - onObjectBegin?: (offset: number, length: number) => void; - - /** - * Invoked when a property is encountered. The offset and length represent the location of the property name. - */ - onObjectProperty?: (property: string, offset: number, length: number) => void; - - /** - * Invoked when a closing brace is encountered and an object is completed. The offset and length represent the location of the closing brace. - */ - onObjectEnd?: (offset: number, length: number) => void; - - /** - * Invoked when an open bracket is encountered. The offset and length represent the location of the open bracket. - */ - onArrayBegin?: (offset: number, length: number) => void; - - /** - * Invoked when a closing bracket is encountered. The offset and length represent the location of the closing bracket. - */ - onArrayEnd?: (offset: number, length: number) => void; - - /** - * Invoked when a literal value is encountered. The offset and length represent the location of the literal value. - */ - onLiteralValue?: (value: any, offset: number, length: number) => void; - - /** - * Invoked when a comma or colon separator is encountered. The offset and length represent the location of the separator. - */ - onSeparator?: (character: string, offset: number, length: number) => void; - - /** - * When comments are allowed, invoked when a line or block comment is encountered. The offset and length represent the location of the comment. - */ - onComment?: (offset: number, length: number) => void; - - /** - * Invoked on an error. - */ - onError?: (error: ParseErrorCode, offset: number, length: number) => void; -} - -/** - * Creates a JSON scanner on the given text. - * If ignoreTrivia is set, whitespaces or comments are ignored. - */ -export function createScanner(text: string, ignoreTrivia: boolean = false): JSONScanner { - - let pos = 0; - const len = text.length; - let value: string = ''; - let tokenOffset = 0; - let token: SyntaxKind = SyntaxKind.Unknown; - let scanError: ScanError = ScanError.None; - - function scanHexDigits(count: number): number { - let digits = 0; - let hexValue = 0; - while (digits < count) { - const ch = text.charCodeAt(pos); - if (ch >= CharacterCodes._0 && ch <= CharacterCodes._9) { - hexValue = hexValue * 16 + ch - CharacterCodes._0; - } - else if (ch >= CharacterCodes.A && ch <= CharacterCodes.F) { - hexValue = hexValue * 16 + ch - CharacterCodes.A + 10; - } - else if (ch >= CharacterCodes.a && ch <= CharacterCodes.f) { - hexValue = hexValue * 16 + ch - CharacterCodes.a + 10; - } - else { - break; - } - pos++; - digits++; - } - if (digits < count) { - hexValue = -1; - } - return hexValue; - } - - function setPosition(newPosition: number) { - pos = newPosition; - value = ''; - tokenOffset = 0; - token = SyntaxKind.Unknown; - scanError = ScanError.None; - } - - function scanNumber(): string { - const start = pos; - if (text.charCodeAt(pos) === CharacterCodes._0) { - pos++; - } else { - pos++; - while (pos < text.length && isDigit(text.charCodeAt(pos))) { - pos++; - } - } - if (pos < text.length && text.charCodeAt(pos) === CharacterCodes.dot) { - pos++; - if (pos < text.length && isDigit(text.charCodeAt(pos))) { - pos++; - while (pos < text.length && isDigit(text.charCodeAt(pos))) { - pos++; - } - } else { - scanError = ScanError.UnexpectedEndOfNumber; - return text.substring(start, pos); - } - } - let end = pos; - if (pos < text.length && (text.charCodeAt(pos) === CharacterCodes.E || text.charCodeAt(pos) === CharacterCodes.e)) { - pos++; - if (pos < text.length && text.charCodeAt(pos) === CharacterCodes.plus || text.charCodeAt(pos) === CharacterCodes.minus) { - pos++; - } - if (pos < text.length && isDigit(text.charCodeAt(pos))) { - pos++; - while (pos < text.length && isDigit(text.charCodeAt(pos))) { - pos++; - } - end = pos; - } else { - scanError = ScanError.UnexpectedEndOfNumber; - } - } - return text.substring(start, end); - } - - function scanString(): string { - - let result = '', - start = pos; - - while (true) { - if (pos >= len) { - result += text.substring(start, pos); - scanError = ScanError.UnexpectedEndOfString; - break; - } - const ch = text.charCodeAt(pos); - if (ch === CharacterCodes.doubleQuote) { - result += text.substring(start, pos); - pos++; - break; - } - if (ch === CharacterCodes.backslash) { - result += text.substring(start, pos); - pos++; - if (pos >= len) { - scanError = ScanError.UnexpectedEndOfString; - break; - } - const ch2 = text.charCodeAt(pos++); - switch (ch2) { - case CharacterCodes.doubleQuote: - result += '\"'; - break; - case CharacterCodes.backslash: - result += '\\'; - break; - case CharacterCodes.slash: - result += '/'; - break; - case CharacterCodes.b: - result += '\b'; - break; - case CharacterCodes.f: - result += '\f'; - break; - case CharacterCodes.n: - result += '\n'; - break; - case CharacterCodes.r: - result += '\r'; - break; - case CharacterCodes.t: - result += '\t'; - break; - case CharacterCodes.u: { - const ch3 = scanHexDigits(4); - if (ch3 >= 0) { - result += String.fromCharCode(ch3); - } else { - scanError = ScanError.InvalidUnicode; - } - break; - } - default: - scanError = ScanError.InvalidEscapeCharacter; - } - start = pos; - continue; - } - if (ch >= 0 && ch <= 0x1F) { - if (isLineBreak(ch)) { - result += text.substring(start, pos); - scanError = ScanError.UnexpectedEndOfString; - break; - } else { - scanError = ScanError.InvalidCharacter; - // mark as error but continue with string - } - } - pos++; - } - return result; - } - - function scanNext(): SyntaxKind { - - value = ''; - scanError = ScanError.None; - - tokenOffset = pos; - - if (pos >= len) { - // at the end - tokenOffset = len; - return token = SyntaxKind.EOF; - } - - let code = text.charCodeAt(pos); - // trivia: whitespace - if (isWhitespace(code)) { - do { - pos++; - value += String.fromCharCode(code); - code = text.charCodeAt(pos); - } while (isWhitespace(code)); - - return token = SyntaxKind.Trivia; - } - - // trivia: newlines - if (isLineBreak(code)) { - pos++; - value += String.fromCharCode(code); - if (code === CharacterCodes.carriageReturn && text.charCodeAt(pos) === CharacterCodes.lineFeed) { - pos++; - value += '\n'; - } - return token = SyntaxKind.LineBreakTrivia; - } - - switch (code) { - // tokens: []{}:, - case CharacterCodes.openBrace: - pos++; - return token = SyntaxKind.OpenBraceToken; - case CharacterCodes.closeBrace: - pos++; - return token = SyntaxKind.CloseBraceToken; - case CharacterCodes.openBracket: - pos++; - return token = SyntaxKind.OpenBracketToken; - case CharacterCodes.closeBracket: - pos++; - return token = SyntaxKind.CloseBracketToken; - case CharacterCodes.colon: - pos++; - return token = SyntaxKind.ColonToken; - case CharacterCodes.comma: - pos++; - return token = SyntaxKind.CommaToken; - - // strings - case CharacterCodes.doubleQuote: - pos++; - value = scanString(); - return token = SyntaxKind.StringLiteral; - - // comments - case CharacterCodes.slash: { - const start = pos - 1; - // Single-line comment - if (text.charCodeAt(pos + 1) === CharacterCodes.slash) { - pos += 2; - - while (pos < len) { - if (isLineBreak(text.charCodeAt(pos))) { - break; - } - pos++; - - } - value = text.substring(start, pos); - return token = SyntaxKind.LineCommentTrivia; - } - - // Multi-line comment - if (text.charCodeAt(pos + 1) === CharacterCodes.asterisk) { - pos += 2; - - const safeLength = len - 1; // For lookahead. - let commentClosed = false; - while (pos < safeLength) { - const ch = text.charCodeAt(pos); - - if (ch === CharacterCodes.asterisk && text.charCodeAt(pos + 1) === CharacterCodes.slash) { - pos += 2; - commentClosed = true; - break; - } - pos++; - } - - if (!commentClosed) { - pos++; - scanError = ScanError.UnexpectedEndOfComment; - } - - value = text.substring(start, pos); - return token = SyntaxKind.BlockCommentTrivia; - } - // just a single slash - value += String.fromCharCode(code); - pos++; - return token = SyntaxKind.Unknown; - } - // numbers - case CharacterCodes.minus: - value += String.fromCharCode(code); - pos++; - if (pos === len || !isDigit(text.charCodeAt(pos))) { - return token = SyntaxKind.Unknown; - } - // found a minus, followed by a number so - // we fall through to proceed with scanning - // numbers - case CharacterCodes._0: - case CharacterCodes._1: - case CharacterCodes._2: - case CharacterCodes._3: - case CharacterCodes._4: - case CharacterCodes._5: - case CharacterCodes._6: - case CharacterCodes._7: - case CharacterCodes._8: - case CharacterCodes._9: - value += scanNumber(); - return token = SyntaxKind.NumericLiteral; - // literals and unknown symbols - default: - // is a literal? Read the full word. - while (pos < len && isUnknownContentCharacter(code)) { - pos++; - code = text.charCodeAt(pos); - } - if (tokenOffset !== pos) { - value = text.substring(tokenOffset, pos); - // keywords: true, false, null - switch (value) { - case 'true': return token = SyntaxKind.TrueKeyword; - case 'false': return token = SyntaxKind.FalseKeyword; - case 'null': return token = SyntaxKind.NullKeyword; - } - return token = SyntaxKind.Unknown; - } - // some - value += String.fromCharCode(code); - pos++; - return token = SyntaxKind.Unknown; - } - } - - function isUnknownContentCharacter(code: CharacterCodes) { - if (isWhitespace(code) || isLineBreak(code)) { - return false; - } - switch (code) { - case CharacterCodes.closeBrace: - case CharacterCodes.closeBracket: - case CharacterCodes.openBrace: - case CharacterCodes.openBracket: - case CharacterCodes.doubleQuote: - case CharacterCodes.colon: - case CharacterCodes.comma: - case CharacterCodes.slash: - return false; - } - return true; - } - - - function scanNextNonTrivia(): SyntaxKind { - let result: SyntaxKind; - do { - result = scanNext(); - } while (result >= SyntaxKind.LineCommentTrivia && result <= SyntaxKind.Trivia); - return result; - } - - return { - setPosition: setPosition, - getPosition: () => pos, - scan: ignoreTrivia ? scanNextNonTrivia : scanNext, - getToken: () => token, - getTokenValue: () => value, - getTokenOffset: () => tokenOffset, - getTokenLength: () => pos - tokenOffset, - getTokenError: () => scanError - }; -} - -function isWhitespace(ch: number): boolean { - return ch === CharacterCodes.space || ch === CharacterCodes.tab || ch === CharacterCodes.verticalTab || ch === CharacterCodes.formFeed || - ch === CharacterCodes.nonBreakingSpace || ch === CharacterCodes.ogham || ch >= CharacterCodes.enQuad && ch <= CharacterCodes.zeroWidthSpace || - ch === CharacterCodes.narrowNoBreakSpace || ch === CharacterCodes.mathematicalSpace || ch === CharacterCodes.ideographicSpace || ch === CharacterCodes.byteOrderMark; -} - -function isLineBreak(ch: number): boolean { - return ch === CharacterCodes.lineFeed || ch === CharacterCodes.carriageReturn || ch === CharacterCodes.lineSeparator || ch === CharacterCodes.paragraphSeparator; -} - -function isDigit(ch: number): boolean { - return ch >= CharacterCodes._0 && ch <= CharacterCodes._9; -} - -const enum CharacterCodes { - nullCharacter = 0, - maxAsciiCharacter = 0x7F, - - lineFeed = 0x0A, // \n - carriageReturn = 0x0D, // \r - lineSeparator = 0x2028, - paragraphSeparator = 0x2029, - - // REVIEW: do we need to support this? The scanner doesn't, but our IText does. This seems - // like an odd disparity? (Or maybe it's completely fine for them to be different). - nextLine = 0x0085, - - // Unicode 3.0 space characters - space = 0x0020, // " " - nonBreakingSpace = 0x00A0, // - enQuad = 0x2000, - emQuad = 0x2001, - enSpace = 0x2002, - emSpace = 0x2003, - threePerEmSpace = 0x2004, - fourPerEmSpace = 0x2005, - sixPerEmSpace = 0x2006, - figureSpace = 0x2007, - punctuationSpace = 0x2008, - thinSpace = 0x2009, - hairSpace = 0x200A, - zeroWidthSpace = 0x200B, - narrowNoBreakSpace = 0x202F, - ideographicSpace = 0x3000, - mathematicalSpace = 0x205F, - ogham = 0x1680, - - _ = 0x5F, - $ = 0x24, - - _0 = 0x30, - _1 = 0x31, - _2 = 0x32, - _3 = 0x33, - _4 = 0x34, - _5 = 0x35, - _6 = 0x36, - _7 = 0x37, - _8 = 0x38, - _9 = 0x39, - - a = 0x61, - b = 0x62, - c = 0x63, - d = 0x64, - e = 0x65, - f = 0x66, - g = 0x67, - h = 0x68, - i = 0x69, - j = 0x6A, - k = 0x6B, - l = 0x6C, - m = 0x6D, - n = 0x6E, - o = 0x6F, - p = 0x70, - q = 0x71, - r = 0x72, - s = 0x73, - t = 0x74, - u = 0x75, - v = 0x76, - w = 0x77, - x = 0x78, - y = 0x79, - z = 0x7A, - - A = 0x41, - B = 0x42, - C = 0x43, - D = 0x44, - E = 0x45, - F = 0x46, - G = 0x47, - H = 0x48, - I = 0x49, - J = 0x4A, - K = 0x4B, - L = 0x4C, - M = 0x4D, - N = 0x4E, - O = 0x4F, - P = 0x50, - Q = 0x51, - R = 0x52, - S = 0x53, - T = 0x54, - U = 0x55, - V = 0x56, - W = 0x57, - X = 0x58, - Y = 0x59, - Z = 0x5A, - - ampersand = 0x26, // & - asterisk = 0x2A, // * - at = 0x40, // @ - backslash = 0x5C, // \ - bar = 0x7C, // | - caret = 0x5E, // ^ - closeBrace = 0x7D, // } - closeBracket = 0x5D, // ] - closeParen = 0x29, // ) - colon = 0x3A, // : - comma = 0x2C, // , - dot = 0x2E, // . - doubleQuote = 0x22, // " - equals = 0x3D, // = - exclamation = 0x21, // ! - greaterThan = 0x3E, // > - lessThan = 0x3C, // < - minus = 0x2D, // - - openBrace = 0x7B, // { - openBracket = 0x5B, // [ - openParen = 0x28, // ( - percent = 0x25, // % - plus = 0x2B, // + - question = 0x3F, // ? - semicolon = 0x3B, // ; - singleQuote = 0x27, // ' - slash = 0x2F, // / - tilde = 0x7E, // ~ - - backspace = 0x08, // \b - formFeed = 0x0C, // \f - byteOrderMark = 0xFEFF, - tab = 0x09, // \t - verticalTab = 0x0B, // \v -} - -interface NodeImpl extends Node { - type: NodeType; - value?: any; - offset: number; - length: number; - colonOffset?: number; - parent?: NodeImpl; - children?: NodeImpl[]; -} - -/** - * For a given offset, evaluate the location in the JSON document. Each segment in the location path is either a property name or an array index. - */ -export function getLocation(text: string, position: number): Location { - const segments: Segment[] = []; // strings or numbers - const earlyReturnException = new Object(); - let previousNode: NodeImpl | undefined = undefined; - const previousNodeInst: NodeImpl = { - value: {}, - offset: 0, - length: 0, - type: 'object', - parent: undefined - }; - let isAtPropertyKey = false; - function setPreviousNode(value: string, offset: number, length: number, type: NodeType) { - previousNodeInst.value = value; - previousNodeInst.offset = offset; - previousNodeInst.length = length; - previousNodeInst.type = type; - previousNodeInst.colonOffset = undefined; - previousNode = previousNodeInst; - } - try { - - visit(text, { - onObjectBegin: (offset: number, length: number) => { - if (position <= offset) { - throw earlyReturnException; - } - previousNode = undefined; - isAtPropertyKey = position > offset; - segments.push(''); // push a placeholder (will be replaced) - }, - onObjectProperty: (name: string, offset: number, length: number) => { - if (position < offset) { - throw earlyReturnException; - } - setPreviousNode(name, offset, length, 'property'); - segments[segments.length - 1] = name; - if (position <= offset + length) { - throw earlyReturnException; - } - }, - onObjectEnd: (offset: number, length: number) => { - if (position <= offset) { - throw earlyReturnException; - } - previousNode = undefined; - segments.pop(); - }, - onArrayBegin: (offset: number, length: number) => { - if (position <= offset) { - throw earlyReturnException; - } - previousNode = undefined; - segments.push(0); - }, - onArrayEnd: (offset: number, length: number) => { - if (position <= offset) { - throw earlyReturnException; - } - previousNode = undefined; - segments.pop(); - }, - onLiteralValue: (value: any, offset: number, length: number) => { - if (position < offset) { - throw earlyReturnException; - } - setPreviousNode(value, offset, length, getNodeType(value)); - - if (position <= offset + length) { - throw earlyReturnException; - } - }, - onSeparator: (sep: string, offset: number, length: number) => { - if (position <= offset) { - throw earlyReturnException; - } - if (sep === ':' && previousNode && previousNode.type === 'property') { - previousNode.colonOffset = offset; - isAtPropertyKey = false; - previousNode = undefined; - } else if (sep === ',') { - const last = segments[segments.length - 1]; - if (typeof last === 'number') { - segments[segments.length - 1] = last + 1; - } else { - isAtPropertyKey = true; - segments[segments.length - 1] = ''; - } - previousNode = undefined; - } - } - }); - } catch (e) { - if (e !== earlyReturnException) { - throw e; - } - } - - return { - path: segments, - previousNode, - isAtPropertyKey, - matches: (pattern: Segment[]) => { - let k = 0; - for (let i = 0; k < pattern.length && i < segments.length; i++) { - if (pattern[k] === segments[i] || pattern[k] === '*') { - k++; - } else if (pattern[k] !== '**') { - return false; - } - } - return k === pattern.length; - } - }; -} - - -/** - * Parses the given text and returns the object the JSON content represents. On invalid input, the parser tries to be as fault tolerant as possible, but still return a result. - * Therefore always check the errors list to find out if the input was valid. - */ -export function parse(text: string, errors: ParseError[] = [], options: ParseOptions = ParseOptions.DEFAULT): any { - let currentProperty: string | null = null; - let currentParent: any = []; - const previousParents: any[] = []; - - function onValue(value: any) { - if (Array.isArray(currentParent)) { - (currentParent).push(value); - } else if (currentProperty !== null) { - currentParent[currentProperty] = value; - } - } - - const visitor: JSONVisitor = { - onObjectBegin: () => { - const object = {}; - onValue(object); - previousParents.push(currentParent); - currentParent = object; - currentProperty = null; - }, - onObjectProperty: (name: string) => { - currentProperty = name; - }, - onObjectEnd: () => { - currentParent = previousParents.pop(); - }, - onArrayBegin: () => { - const array: any[] = []; - onValue(array); - previousParents.push(currentParent); - currentParent = array; - currentProperty = null; - }, - onArrayEnd: () => { - currentParent = previousParents.pop(); - }, - onLiteralValue: onValue, - onError: (error: ParseErrorCode, offset: number, length: number) => { - errors.push({ error, offset, length }); - } - }; - visit(text, visitor, options); - return currentParent[0]; -} - - -/** - * Parses the given text and returns a tree representation the JSON content. On invalid input, the parser tries to be as fault tolerant as possible, but still return a result. - */ -export function parseTree(text: string, errors: ParseError[] = [], options: ParseOptions = ParseOptions.DEFAULT): Node { - let currentParent: NodeImpl = { type: 'array', offset: -1, length: -1, children: [], parent: undefined }; // artificial root - - function ensurePropertyComplete(endOffset: number) { - if (currentParent.type === 'property') { - currentParent.length = endOffset - currentParent.offset; - currentParent = currentParent.parent!; - } - } - - function onValue(valueNode: Node): Node { - currentParent.children!.push(valueNode); - return valueNode; - } - - const visitor: JSONVisitor = { - onObjectBegin: (offset: number) => { - currentParent = onValue({ type: 'object', offset, length: -1, parent: currentParent, children: [] }); - }, - onObjectProperty: (name: string, offset: number, length: number) => { - currentParent = onValue({ type: 'property', offset, length: -1, parent: currentParent, children: [] }); - currentParent.children!.push({ type: 'string', value: name, offset, length, parent: currentParent }); - }, - onObjectEnd: (offset: number, length: number) => { - currentParent.length = offset + length - currentParent.offset; - currentParent = currentParent.parent!; - ensurePropertyComplete(offset + length); - }, - onArrayBegin: (offset: number, length: number) => { - currentParent = onValue({ type: 'array', offset, length: -1, parent: currentParent, children: [] }); - }, - onArrayEnd: (offset: number, length: number) => { - currentParent.length = offset + length - currentParent.offset; - currentParent = currentParent.parent!; - ensurePropertyComplete(offset + length); - }, - onLiteralValue: (value: any, offset: number, length: number) => { - onValue({ type: getNodeType(value), offset, length, parent: currentParent, value }); - ensurePropertyComplete(offset + length); - }, - onSeparator: (sep: string, offset: number, length: number) => { - if (currentParent.type === 'property') { - if (sep === ':') { - currentParent.colonOffset = offset; - } else if (sep === ',') { - ensurePropertyComplete(offset); - } - } - }, - onError: (error: ParseErrorCode, offset: number, length: number) => { - errors.push({ error, offset, length }); - } - }; - visit(text, visitor, options); - - const result = currentParent.children![0]; - if (result) { - delete result.parent; - } - return result; -} - -/** - * Finds the node at the given path in a JSON DOM. - */ -export function findNodeAtLocation(root: Node, path: JSONPath): Node | undefined { - if (!root) { - return undefined; - } - let node = root; - for (const segment of path) { - if (typeof segment === 'string') { - if (node.type !== 'object' || !Array.isArray(node.children)) { - return undefined; - } - let found = false; - for (const propertyNode of node.children) { - if (Array.isArray(propertyNode.children) && propertyNode.children[0].value === segment) { - node = propertyNode.children[1]; - found = true; - break; - } - } - if (!found) { - return undefined; - } - } else { - const index = segment; - if (node.type !== 'array' || index < 0 || !Array.isArray(node.children) || index >= node.children.length) { - return undefined; - } - node = node.children[index]; - } - } - return node; -} - -/** - * Gets the JSON path of the given JSON DOM node - */ -export function getNodePath(node: Node): JSONPath { - if (!node.parent || !node.parent.children) { - return []; - } - const path = getNodePath(node.parent); - if (node.parent.type === 'property') { - const key = node.parent.children[0].value; - path.push(key); - } else if (node.parent.type === 'array') { - const index = node.parent.children.indexOf(node); - if (index !== -1) { - path.push(index); - } - } - return path; -} - -/** - * Evaluates the JavaScript object of the given JSON DOM node - */ -export function getNodeValue(node: Node): any { - switch (node.type) { - case 'array': - return node.children!.map(getNodeValue); - case 'object': { - const obj = Object.create(null); - for (const prop of node.children!) { - const valueNode = prop.children![1]; - if (valueNode) { - obj[prop.children![0].value] = getNodeValue(valueNode); - } - } - return obj; - } - case 'null': - case 'string': - case 'number': - case 'boolean': - return node.value; - default: - return undefined; - } - -} - -export function contains(node: Node, offset: number, includeRightBound = false): boolean { - return (offset >= node.offset && offset < (node.offset + node.length)) || includeRightBound && (offset === (node.offset + node.length)); -} - -/** - * Finds the most inner node at the given offset. If includeRightBound is set, also finds nodes that end at the given offset. - */ -export function findNodeAtOffset(node: Node, offset: number, includeRightBound = false): Node | undefined { - if (contains(node, offset, includeRightBound)) { - const children = node.children; - if (Array.isArray(children)) { - for (let i = 0; i < children.length && children[i].offset <= offset; i++) { - const item = findNodeAtOffset(children[i], offset, includeRightBound); - if (item) { - return item; - } - } - - } - return node; - } - return undefined; -} - - -/** - * Parses the given text and invokes the visitor functions for each object, array and literal reached. - */ -export function visit(text: string, visitor: JSONVisitor, options: ParseOptions = ParseOptions.DEFAULT): any { - - const _scanner = createScanner(text, false); - - function toNoArgVisit(visitFunction?: (offset: number, length: number) => void): () => void { - return visitFunction ? () => visitFunction(_scanner.getTokenOffset(), _scanner.getTokenLength()) : () => true; - } - function toOneArgVisit(visitFunction?: (arg: T, offset: number, length: number) => void): (arg: T) => void { - return visitFunction ? (arg: T) => visitFunction(arg, _scanner.getTokenOffset(), _scanner.getTokenLength()) : () => true; - } - - const onObjectBegin = toNoArgVisit(visitor.onObjectBegin), - onObjectProperty = toOneArgVisit(visitor.onObjectProperty), - onObjectEnd = toNoArgVisit(visitor.onObjectEnd), - onArrayBegin = toNoArgVisit(visitor.onArrayBegin), - onArrayEnd = toNoArgVisit(visitor.onArrayEnd), - onLiteralValue = toOneArgVisit(visitor.onLiteralValue), - onSeparator = toOneArgVisit(visitor.onSeparator), - onComment = toNoArgVisit(visitor.onComment), - onError = toOneArgVisit(visitor.onError); - - const disallowComments = options && options.disallowComments; - const allowTrailingComma = options && options.allowTrailingComma; - function scanNext(): SyntaxKind { - while (true) { - const token = _scanner.scan(); - switch (_scanner.getTokenError()) { - case ScanError.InvalidUnicode: - handleError(ParseErrorCode.InvalidUnicode); - break; - case ScanError.InvalidEscapeCharacter: - handleError(ParseErrorCode.InvalidEscapeCharacter); - break; - case ScanError.UnexpectedEndOfNumber: - handleError(ParseErrorCode.UnexpectedEndOfNumber); - break; - case ScanError.UnexpectedEndOfComment: - if (!disallowComments) { - handleError(ParseErrorCode.UnexpectedEndOfComment); - } - break; - case ScanError.UnexpectedEndOfString: - handleError(ParseErrorCode.UnexpectedEndOfString); - break; - case ScanError.InvalidCharacter: - handleError(ParseErrorCode.InvalidCharacter); - break; - } - switch (token) { - case SyntaxKind.LineCommentTrivia: - case SyntaxKind.BlockCommentTrivia: - if (disallowComments) { - handleError(ParseErrorCode.InvalidCommentToken); - } else { - onComment(); - } - break; - case SyntaxKind.Unknown: - handleError(ParseErrorCode.InvalidSymbol); - break; - case SyntaxKind.Trivia: - case SyntaxKind.LineBreakTrivia: - break; - default: - return token; - } - } - } - - function handleError(error: ParseErrorCode, skipUntilAfter: SyntaxKind[] = [], skipUntil: SyntaxKind[] = []): void { - onError(error); - if (skipUntilAfter.length + skipUntil.length > 0) { - let token = _scanner.getToken(); - while (token !== SyntaxKind.EOF) { - if (skipUntilAfter.indexOf(token) !== -1) { - scanNext(); - break; - } else if (skipUntil.indexOf(token) !== -1) { - break; - } - token = scanNext(); - } - } - } - - function parseString(isValue: boolean): boolean { - const value = _scanner.getTokenValue(); - if (isValue) { - onLiteralValue(value); - } else { - onObjectProperty(value); - } - scanNext(); - return true; - } - - function parseLiteral(): boolean { - switch (_scanner.getToken()) { - case SyntaxKind.NumericLiteral: { - let value = 0; - try { - value = JSON.parse(_scanner.getTokenValue()); - if (typeof value !== 'number') { - handleError(ParseErrorCode.InvalidNumberFormat); - value = 0; - } - } catch (e) { - handleError(ParseErrorCode.InvalidNumberFormat); - } - onLiteralValue(value); - break; - } - case SyntaxKind.NullKeyword: - onLiteralValue(null); - break; - case SyntaxKind.TrueKeyword: - onLiteralValue(true); - break; - case SyntaxKind.FalseKeyword: - onLiteralValue(false); - break; - default: - return false; - } - scanNext(); - return true; - } - - function parseProperty(): boolean { - if (_scanner.getToken() !== SyntaxKind.StringLiteral) { - handleError(ParseErrorCode.PropertyNameExpected, [], [SyntaxKind.CloseBraceToken, SyntaxKind.CommaToken]); - return false; - } - parseString(false); - if (_scanner.getToken() === SyntaxKind.ColonToken) { - onSeparator(':'); - scanNext(); // consume colon - - if (!parseValue()) { - handleError(ParseErrorCode.ValueExpected, [], [SyntaxKind.CloseBraceToken, SyntaxKind.CommaToken]); - } - } else { - handleError(ParseErrorCode.ColonExpected, [], [SyntaxKind.CloseBraceToken, SyntaxKind.CommaToken]); - } - return true; - } - - function parseObject(): boolean { - onObjectBegin(); - scanNext(); // consume open brace - - let needsComma = false; - while (_scanner.getToken() !== SyntaxKind.CloseBraceToken && _scanner.getToken() !== SyntaxKind.EOF) { - if (_scanner.getToken() === SyntaxKind.CommaToken) { - if (!needsComma) { - handleError(ParseErrorCode.ValueExpected, [], []); - } - onSeparator(','); - scanNext(); // consume comma - if (_scanner.getToken() === SyntaxKind.CloseBraceToken && allowTrailingComma) { - break; - } - } else if (needsComma) { - handleError(ParseErrorCode.CommaExpected, [], []); - } - if (!parseProperty()) { - handleError(ParseErrorCode.ValueExpected, [], [SyntaxKind.CloseBraceToken, SyntaxKind.CommaToken]); - } - needsComma = true; - } - onObjectEnd(); - if (_scanner.getToken() !== SyntaxKind.CloseBraceToken) { - handleError(ParseErrorCode.CloseBraceExpected, [SyntaxKind.CloseBraceToken], []); - } else { - scanNext(); // consume close brace - } - return true; - } - - function parseArray(): boolean { - onArrayBegin(); - scanNext(); // consume open bracket - - let needsComma = false; - while (_scanner.getToken() !== SyntaxKind.CloseBracketToken && _scanner.getToken() !== SyntaxKind.EOF) { - if (_scanner.getToken() === SyntaxKind.CommaToken) { - if (!needsComma) { - handleError(ParseErrorCode.ValueExpected, [], []); - } - onSeparator(','); - scanNext(); // consume comma - if (_scanner.getToken() === SyntaxKind.CloseBracketToken && allowTrailingComma) { - break; - } - } else if (needsComma) { - handleError(ParseErrorCode.CommaExpected, [], []); - } - if (!parseValue()) { - handleError(ParseErrorCode.ValueExpected, [], [SyntaxKind.CloseBracketToken, SyntaxKind.CommaToken]); - } - needsComma = true; - } - onArrayEnd(); - if (_scanner.getToken() !== SyntaxKind.CloseBracketToken) { - handleError(ParseErrorCode.CloseBracketExpected, [SyntaxKind.CloseBracketToken], []); - } else { - scanNext(); // consume close bracket - } - return true; - } - - function parseValue(): boolean { - switch (_scanner.getToken()) { - case SyntaxKind.OpenBracketToken: - return parseArray(); - case SyntaxKind.OpenBraceToken: - return parseObject(); - case SyntaxKind.StringLiteral: - return parseString(true); - default: - return parseLiteral(); - } - } - - scanNext(); - if (_scanner.getToken() === SyntaxKind.EOF) { - if (options.allowEmptyContent) { - return true; - } - handleError(ParseErrorCode.ValueExpected, [], []); - return false; - } - if (!parseValue()) { - handleError(ParseErrorCode.ValueExpected, [], []); - return false; - } - if (_scanner.getToken() !== SyntaxKind.EOF) { - handleError(ParseErrorCode.EndOfFileExpected, [], []); - } - return true; -} - -export function getNodeType(value: any): NodeType { - switch (typeof value) { - case 'boolean': return 'boolean'; - case 'number': return 'number'; - case 'string': return 'string'; - case 'object': { - if (!value) { - return 'null'; - } else if (Array.isArray(value)) { - return 'array'; - } - return 'object'; - } - default: return 'null'; - } -} diff --git a/src/vs/base/common/jsonEdit.ts b/src/vs/base/common/jsonEdit.ts deleted file mode 100644 index 9d62ed9e66..0000000000 --- a/src/vs/base/common/jsonEdit.ts +++ /dev/null @@ -1,176 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { findNodeAtLocation, JSONPath, Node, ParseError, parseTree, Segment } from './json'; -import { Edit, format, FormattingOptions, isEOL } from './jsonFormatter'; - - -export function removeProperty(text: string, path: JSONPath, formattingOptions: FormattingOptions): Edit[] { - return setProperty(text, path, undefined, formattingOptions); -} - -export function setProperty(text: string, originalPath: JSONPath, value: any, formattingOptions: FormattingOptions, getInsertionIndex?: (properties: string[]) => number): Edit[] { - const path = originalPath.slice(); - const errors: ParseError[] = []; - const root = parseTree(text, errors); - let parent: Node | undefined = undefined; - - let lastSegment: Segment | undefined = undefined; - while (path.length > 0) { - lastSegment = path.pop(); - parent = findNodeAtLocation(root, path); - if (parent === undefined && value !== undefined) { - if (typeof lastSegment === 'string') { - value = { [lastSegment]: value }; - } else { - value = [value]; - } - } else { - break; - } - } - - if (!parent) { - // empty document - if (value === undefined) { // delete - return []; // property does not exist, nothing to do - } - return withFormatting(text, { offset: root ? root.offset : 0, length: root ? root.length : 0, content: JSON.stringify(value) }, formattingOptions); - } else if (parent.type === 'object' && typeof lastSegment === 'string' && Array.isArray(parent.children)) { - const existing = findNodeAtLocation(parent, [lastSegment]); - if (existing !== undefined) { - if (value === undefined) { // delete - if (!existing.parent) { - throw new Error('Malformed AST'); - } - const propertyIndex = parent.children.indexOf(existing.parent); - let removeBegin: number; - let removeEnd = existing.parent.offset + existing.parent.length; - if (propertyIndex > 0) { - // remove the comma of the previous node - const previous = parent.children[propertyIndex - 1]; - removeBegin = previous.offset + previous.length; - } else { - removeBegin = parent.offset + 1; - if (parent.children.length > 1) { - // remove the comma of the next node - const next = parent.children[1]; - removeEnd = next.offset; - } - } - return withFormatting(text, { offset: removeBegin, length: removeEnd - removeBegin, content: '' }, formattingOptions); - } else { - // set value of existing property - return withFormatting(text, { offset: existing.offset, length: existing.length, content: JSON.stringify(value) }, formattingOptions); - } - } else { - if (value === undefined) { // delete - return []; // property does not exist, nothing to do - } - const newProperty = `${JSON.stringify(lastSegment)}: ${JSON.stringify(value)}`; - const index = getInsertionIndex ? getInsertionIndex(parent.children.map(p => p.children![0].value)) : parent.children.length; - let edit: Edit; - if (index > 0) { - const previous = parent.children[index - 1]; - edit = { offset: previous.offset + previous.length, length: 0, content: ',' + newProperty }; - } else if (parent.children.length === 0) { - edit = { offset: parent.offset + 1, length: 0, content: newProperty }; - } else { - edit = { offset: parent.offset + 1, length: 0, content: newProperty + ',' }; - } - return withFormatting(text, edit, formattingOptions); - } - } else if (parent.type === 'array' && typeof lastSegment === 'number' && Array.isArray(parent.children)) { - if (value !== undefined) { - // Insert - const newProperty = `${JSON.stringify(value)}`; - let edit: Edit; - if (parent.children.length === 0 || lastSegment === 0) { - edit = { offset: parent.offset + 1, length: 0, content: parent.children.length === 0 ? newProperty : newProperty + ',' }; - } else { - const index = lastSegment === -1 || lastSegment > parent.children.length ? parent.children.length : lastSegment; - const previous = parent.children[index - 1]; - edit = { offset: previous.offset + previous.length, length: 0, content: ',' + newProperty }; - } - return withFormatting(text, edit, formattingOptions); - } else { - //Removal - const removalIndex = lastSegment; - const toRemove = parent.children[removalIndex]; - let edit: Edit; - if (parent.children.length === 1) { - // only item - edit = { offset: parent.offset + 1, length: parent.length - 2, content: '' }; - } else if (parent.children.length - 1 === removalIndex) { - // last item - const previous = parent.children[removalIndex - 1]; - const offset = previous.offset + previous.length; - const parentEndOffset = parent.offset + parent.length; - edit = { offset, length: parentEndOffset - 2 - offset, content: '' }; - } else { - edit = { offset: toRemove.offset, length: parent.children[removalIndex + 1].offset - toRemove.offset, content: '' }; - } - return withFormatting(text, edit, formattingOptions); - } - } else { - throw new Error(`Can not add ${typeof lastSegment !== 'number' ? 'index' : 'property'} to parent of type ${parent.type}`); - } -} - -export function withFormatting(text: string, edit: Edit, formattingOptions: FormattingOptions): Edit[] { - // apply the edit - let newText = applyEdit(text, edit); - - // format the new text - let begin = edit.offset; - let end = edit.offset + edit.content.length; - if (edit.length === 0 || edit.content.length === 0) { // insert or remove - while (begin > 0 && !isEOL(newText, begin - 1)) { - begin--; - } - while (end < newText.length && !isEOL(newText, end)) { - end++; - } - } - - const edits = format(newText, { offset: begin, length: end - begin }, formattingOptions); - - // apply the formatting edits and track the begin and end offsets of the changes - for (let i = edits.length - 1; i >= 0; i--) { - const curr = edits[i]; - newText = applyEdit(newText, curr); - begin = Math.min(begin, curr.offset); - end = Math.max(end, curr.offset + curr.length); - end += curr.content.length - curr.length; - } - // create a single edit with all changes - const editLength = text.length - (newText.length - end) - begin; - return [{ offset: begin, length: editLength, content: newText.substring(begin, end) }]; -} - -export function applyEdit(text: string, edit: Edit): string { - return text.substring(0, edit.offset) + edit.content + text.substring(edit.offset + edit.length); -} - -export function applyEdits(text: string, edits: Edit[]): string { - const sortedEdits = edits.slice(0).sort((a, b) => { - const diff = a.offset - b.offset; - if (diff === 0) { - return a.length - b.length; - } - return diff; - }); - let lastModifiedOffset = text.length; - for (let i = sortedEdits.length - 1; i >= 0; i--) { - const e = sortedEdits[i]; - if (e.offset + e.length <= lastModifiedOffset) { - text = applyEdit(text, e); - } else { - throw new Error('Overlapping edit'); - } - lastModifiedOffset = e.offset; - } - return text; -} diff --git a/src/vs/base/common/jsonErrorMessages.ts b/src/vs/base/common/jsonErrorMessages.ts deleted file mode 100644 index 49b5b988cf..0000000000 --- a/src/vs/base/common/jsonErrorMessages.ts +++ /dev/null @@ -1,26 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -/** - * Extracted from json.ts to keep json nls free. - */ -import { localize } from 'vs/nls'; -import { ParseErrorCode } from './json'; - -export function getParseErrorMessage(errorCode: ParseErrorCode): string { - switch (errorCode) { - case ParseErrorCode.InvalidSymbol: return localize('error.invalidSymbol', 'Invalid symbol'); - case ParseErrorCode.InvalidNumberFormat: return localize('error.invalidNumberFormat', 'Invalid number format'); - case ParseErrorCode.PropertyNameExpected: return localize('error.propertyNameExpected', 'Property name expected'); - case ParseErrorCode.ValueExpected: return localize('error.valueExpected', 'Value expected'); - case ParseErrorCode.ColonExpected: return localize('error.colonExpected', 'Colon expected'); - case ParseErrorCode.CommaExpected: return localize('error.commaExpected', 'Comma expected'); - case ParseErrorCode.CloseBraceExpected: return localize('error.closeBraceExpected', 'Closing brace expected'); - case ParseErrorCode.CloseBracketExpected: return localize('error.closeBracketExpected', 'Closing bracket expected'); - case ParseErrorCode.EndOfFileExpected: return localize('error.endOfFileExpected', 'End of file expected'); - default: - return ''; - } -} diff --git a/src/vs/base/common/jsonFormatter.ts b/src/vs/base/common/jsonFormatter.ts deleted file mode 100644 index 18fc0d5338..0000000000 --- a/src/vs/base/common/jsonFormatter.ts +++ /dev/null @@ -1,261 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { createScanner, ScanError, SyntaxKind } from './json'; - -export interface FormattingOptions { - /** - * If indentation is based on spaces (`insertSpaces` = true), then what is the number of spaces that make an indent? - */ - tabSize?: number; - /** - * Is indentation based on spaces? - */ - insertSpaces?: boolean; - /** - * The default 'end of line' character. If not set, '\n' is used as default. - */ - eol?: string; -} - -/** - * Represents a text modification - */ -export interface Edit { - /** - * The start offset of the modification. - */ - offset: number; - /** - * The length of the modification. Must not be negative. Empty length represents an *insert*. - */ - length: number; - /** - * The new content. Empty content represents a *remove*. - */ - content: string; -} - -/** - * A text range in the document -*/ -export interface Range { - /** - * The start offset of the range. - */ - offset: number; - /** - * The length of the range. Must not be negative. - */ - length: number; -} - - -export function format(documentText: string, range: Range | undefined, options: FormattingOptions): Edit[] { - let initialIndentLevel: number; - let formatText: string; - let formatTextStart: number; - let rangeStart: number; - let rangeEnd: number; - if (range) { - rangeStart = range.offset; - rangeEnd = rangeStart + range.length; - - formatTextStart = rangeStart; - while (formatTextStart > 0 && !isEOL(documentText, formatTextStart - 1)) { - formatTextStart--; - } - let endOffset = rangeEnd; - while (endOffset < documentText.length && !isEOL(documentText, endOffset)) { - endOffset++; - } - formatText = documentText.substring(formatTextStart, endOffset); - initialIndentLevel = computeIndentLevel(formatText, options); - } else { - formatText = documentText; - initialIndentLevel = 0; - formatTextStart = 0; - rangeStart = 0; - rangeEnd = documentText.length; - } - const eol = getEOL(options, documentText); - - let lineBreak = false; - let indentLevel = 0; - let indentValue: string; - if (options.insertSpaces) { - indentValue = repeat(' ', options.tabSize || 4); - } else { - indentValue = '\t'; - } - - const scanner = createScanner(formatText, false); - let hasError = false; - - function newLineAndIndent(): string { - return eol + repeat(indentValue, initialIndentLevel + indentLevel); - } - function scanNext(): SyntaxKind { - let token = scanner.scan(); - lineBreak = false; - while (token === SyntaxKind.Trivia || token === SyntaxKind.LineBreakTrivia) { - lineBreak = lineBreak || (token === SyntaxKind.LineBreakTrivia); - token = scanner.scan(); - } - hasError = token === SyntaxKind.Unknown || scanner.getTokenError() !== ScanError.None; - return token; - } - const editOperations: Edit[] = []; - function addEdit(text: string, startOffset: number, endOffset: number) { - if (!hasError && startOffset < rangeEnd && endOffset > rangeStart && documentText.substring(startOffset, endOffset) !== text) { - editOperations.push({ offset: startOffset, length: endOffset - startOffset, content: text }); - } - } - - let firstToken = scanNext(); - - if (firstToken !== SyntaxKind.EOF) { - const firstTokenStart = scanner.getTokenOffset() + formatTextStart; - const initialIndent = repeat(indentValue, initialIndentLevel); - addEdit(initialIndent, formatTextStart, firstTokenStart); - } - - while (firstToken !== SyntaxKind.EOF) { - let firstTokenEnd = scanner.getTokenOffset() + scanner.getTokenLength() + formatTextStart; - let secondToken = scanNext(); - - let replaceContent = ''; - while (!lineBreak && (secondToken === SyntaxKind.LineCommentTrivia || secondToken === SyntaxKind.BlockCommentTrivia)) { - // comments on the same line: keep them on the same line, but ignore them otherwise - const commentTokenStart = scanner.getTokenOffset() + formatTextStart; - addEdit(' ', firstTokenEnd, commentTokenStart); - firstTokenEnd = scanner.getTokenOffset() + scanner.getTokenLength() + formatTextStart; - replaceContent = secondToken === SyntaxKind.LineCommentTrivia ? newLineAndIndent() : ''; - secondToken = scanNext(); - } - - if (secondToken === SyntaxKind.CloseBraceToken) { - if (firstToken !== SyntaxKind.OpenBraceToken) { - indentLevel--; - replaceContent = newLineAndIndent(); - } - } else if (secondToken === SyntaxKind.CloseBracketToken) { - if (firstToken !== SyntaxKind.OpenBracketToken) { - indentLevel--; - replaceContent = newLineAndIndent(); - } - } else { - switch (firstToken) { - case SyntaxKind.OpenBracketToken: - case SyntaxKind.OpenBraceToken: - indentLevel++; - replaceContent = newLineAndIndent(); - break; - case SyntaxKind.CommaToken: - case SyntaxKind.LineCommentTrivia: - replaceContent = newLineAndIndent(); - break; - case SyntaxKind.BlockCommentTrivia: - if (lineBreak) { - replaceContent = newLineAndIndent(); - } else { - // symbol following comment on the same line: keep on same line, separate with ' ' - replaceContent = ' '; - } - break; - case SyntaxKind.ColonToken: - replaceContent = ' '; - break; - case SyntaxKind.StringLiteral: - if (secondToken === SyntaxKind.ColonToken) { - replaceContent = ''; - break; - } - // fall through - case SyntaxKind.NullKeyword: - case SyntaxKind.TrueKeyword: - case SyntaxKind.FalseKeyword: - case SyntaxKind.NumericLiteral: - case SyntaxKind.CloseBraceToken: - case SyntaxKind.CloseBracketToken: - if (secondToken === SyntaxKind.LineCommentTrivia || secondToken === SyntaxKind.BlockCommentTrivia) { - replaceContent = ' '; - } else if (secondToken !== SyntaxKind.CommaToken && secondToken !== SyntaxKind.EOF) { - hasError = true; - } - break; - case SyntaxKind.Unknown: - hasError = true; - break; - } - if (lineBreak && (secondToken === SyntaxKind.LineCommentTrivia || secondToken === SyntaxKind.BlockCommentTrivia)) { - replaceContent = newLineAndIndent(); - } - - } - const secondTokenStart = scanner.getTokenOffset() + formatTextStart; - addEdit(replaceContent, firstTokenEnd, secondTokenStart); - firstToken = secondToken; - } - return editOperations; -} - -/** - * Creates a formatted string out of the object passed as argument, using the given formatting options - * @param any The object to stringify and format - * @param options The formatting options to use - */ -export function toFormattedString(obj: any, options: FormattingOptions) { - const content = JSON.stringify(obj, undefined, options.insertSpaces ? options.tabSize || 4 : '\t'); - if (options.eol !== undefined) { - return content.replace(/\r\n|\r|\n/g, options.eol); - } - return content; -} - -function repeat(s: string, count: number): string { - let result = ''; - for (let i = 0; i < count; i++) { - result += s; - } - return result; -} - -function computeIndentLevel(content: string, options: FormattingOptions): number { - let i = 0; - let nChars = 0; - const tabSize = options.tabSize || 4; - while (i < content.length) { - const ch = content.charAt(i); - if (ch === ' ') { - nChars++; - } else if (ch === '\t') { - nChars += tabSize; - } else { - break; - } - i++; - } - return Math.floor(nChars / tabSize); -} - -export function getEOL(options: FormattingOptions, text: string): string { - for (let i = 0; i < text.length; i++) { - const ch = text.charAt(i); - if (ch === '\r') { - if (i + 1 < text.length && text.charAt(i + 1) === '\n') { - return '\r\n'; - } - return '\r'; - } else if (ch === '\n') { - return '\n'; - } - } - return (options && options.eol) || '\n'; -} - -export function isEOL(text: string, offset: number) { - return '\r\n'.indexOf(text.charAt(offset)) !== -1; -} diff --git a/src/vs/base/common/jsonSchema.ts b/src/vs/base/common/jsonSchema.ts deleted file mode 100644 index fbf6d9813f..0000000000 --- a/src/vs/base/common/jsonSchema.ts +++ /dev/null @@ -1,267 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -export type JSONSchemaType = 'string' | 'number' | 'integer' | 'boolean' | 'null' | 'array' | 'object'; - -export interface IJSONSchema { - id?: string; - $id?: string; - $schema?: string; - type?: JSONSchemaType | JSONSchemaType[]; - title?: string; - default?: any; - definitions?: IJSONSchemaMap; - description?: string; - properties?: IJSONSchemaMap; - patternProperties?: IJSONSchemaMap; - additionalProperties?: boolean | IJSONSchema; - minProperties?: number; - maxProperties?: number; - dependencies?: IJSONSchemaMap | { [prop: string]: string[] }; - items?: IJSONSchema | IJSONSchema[]; - minItems?: number; - maxItems?: number; - uniqueItems?: boolean; - additionalItems?: boolean | IJSONSchema; - pattern?: string; - minLength?: number; - maxLength?: number; - minimum?: number; - maximum?: number; - exclusiveMinimum?: boolean | number; - exclusiveMaximum?: boolean | number; - multipleOf?: number; - required?: string[]; - $ref?: string; - anyOf?: IJSONSchema[]; - allOf?: IJSONSchema[]; - oneOf?: IJSONSchema[]; - not?: IJSONSchema; - enum?: any[]; - format?: string; - - // schema draft 06 - const?: any; - contains?: IJSONSchema; - propertyNames?: IJSONSchema; - examples?: any[]; - - // schema draft 07 - $comment?: string; - if?: IJSONSchema; - then?: IJSONSchema; - else?: IJSONSchema; - - // schema 2019-09 - unevaluatedProperties?: boolean | IJSONSchema; - unevaluatedItems?: boolean | IJSONSchema; - minContains?: number; - maxContains?: number; - deprecated?: boolean; - dependentRequired?: { [prop: string]: string[] }; - dependentSchemas?: IJSONSchemaMap; - $defs?: { [name: string]: IJSONSchema }; - $anchor?: string; - $recursiveRef?: string; - $recursiveAnchor?: string; - $vocabulary?: any; - - // schema 2020-12 - prefixItems?: IJSONSchema[]; - $dynamicRef?: string; - $dynamicAnchor?: string; - - // VSCode extensions - - defaultSnippets?: IJSONSchemaSnippet[]; - errorMessage?: string; - patternErrorMessage?: string; - deprecationMessage?: string; - markdownDeprecationMessage?: string; - enumDescriptions?: string[]; - markdownEnumDescriptions?: string[]; - markdownDescription?: string; - doNotSuggest?: boolean; - suggestSortText?: string; - allowComments?: boolean; - allowTrailingCommas?: boolean; -} - -export interface IJSONSchemaMap { - [name: string]: IJSONSchema; -} - -export interface IJSONSchemaSnippet { - label?: string; - description?: string; - body?: any; // a object that will be JSON stringified - bodyText?: string; // an already stringified JSON object that can contain new lines (\n) and tabs (\t) -} - -/** - * Converts a basic JSON schema to a TypeScript type. - * - * TODO: only supports basic schemas. Doesn't support all JSON schema features. - */ -export type SchemaToType = T extends { type: 'string' } - ? string - : T extends { type: 'number' } - ? number - : T extends { type: 'boolean' } - ? boolean - : T extends { type: 'null' } - ? null - : T extends { type: 'object'; properties: infer P } - ? { [K in keyof P]: SchemaToType } - : T extends { type: 'array'; items: infer I } - ? Array> - : never; - -interface Equals { schemas: IJSONSchema[]; id?: string } - -export function getCompressedContent(schema: IJSONSchema): string { - let hasDups = false; - - - // visit all schema nodes and collect the ones that are equal - const equalsByString = new Map(); - const nodeToEquals = new Map(); - const visitSchemas = (next: IJSONSchema) => { - if (schema === next) { - return true; - } - const val = JSON.stringify(next); - if (val.length < 30) { - // the $ref takes around 25 chars, so we don't save anything - return true; - } - const eq = equalsByString.get(val); - if (!eq) { - const newEq = { schemas: [next] }; - equalsByString.set(val, newEq); - nodeToEquals.set(next, newEq); - return true; - } - eq.schemas.push(next); - nodeToEquals.set(next, eq); - hasDups = true; - return false; - }; - traverseNodes(schema, visitSchemas); - equalsByString.clear(); - - if (!hasDups) { - return JSON.stringify(schema); - } - - let defNodeName = '$defs'; - while (schema.hasOwnProperty(defNodeName)) { - defNodeName += '_'; - } - - // used to collect all schemas that are later put in `$defs`. The index in the array is the id of the schema. - const definitions: IJSONSchema[] = []; - - function stringify(root: IJSONSchema): string { - return JSON.stringify(root, (_key: string, value: any) => { - if (value !== root) { - const eq = nodeToEquals.get(value); - if (eq && eq.schemas.length > 1) { - if (!eq.id) { - eq.id = `_${definitions.length}`; - definitions.push(eq.schemas[0]); - } - return { $ref: `#/${defNodeName}/${eq.id}` }; - } - } - return value; - }); - } - - // stringify the schema and replace duplicate subtrees with $ref - // this will add new items to the definitions array - const str = stringify(schema); - - // now stringify the definitions. Each invication of stringify cann add new items to the definitions array, so the length can grow while we iterate - const defStrings: string[] = []; - for (let i = 0; i < definitions.length; i++) { - defStrings.push(`"_${i}":${stringify(definitions[i])}`); - } - if (defStrings.length) { - return `${str.substring(0, str.length - 1)},"${defNodeName}":{${defStrings.join(',')}}}`; - } - return str; -} - -type IJSONSchemaRef = IJSONSchema | boolean; - -function isObject(thing: any): thing is object { - return typeof thing === 'object' && thing !== null; -} - -/* - * Traverse a JSON schema and visit each schema node -*/ -function traverseNodes(root: IJSONSchema, visit: (schema: IJSONSchema) => boolean) { - if (!root || typeof root !== 'object') { - return; - } - const collectEntries = (...entries: (IJSONSchemaRef | undefined)[]) => { - for (const entry of entries) { - if (isObject(entry)) { - toWalk.push(entry); - } - } - }; - const collectMapEntries = (...maps: (IJSONSchemaMap | undefined)[]) => { - for (const map of maps) { - if (isObject(map)) { - for (const key in map) { - const entry = map[key]; - if (isObject(entry)) { - toWalk.push(entry); - } - } - } - } - }; - const collectArrayEntries = (...arrays: (IJSONSchemaRef[] | undefined)[]) => { - for (const array of arrays) { - if (Array.isArray(array)) { - for (const entry of array) { - if (isObject(entry)) { - toWalk.push(entry); - } - } - } - } - }; - const collectEntryOrArrayEntries = (items: (IJSONSchemaRef[] | IJSONSchemaRef | undefined)) => { - if (Array.isArray(items)) { - for (const entry of items) { - if (isObject(entry)) { - toWalk.push(entry); - } - } - } else if (isObject(items)) { - toWalk.push(items); - } - }; - - const toWalk: IJSONSchema[] = [root]; - - let next = toWalk.pop(); - while (next) { - const visitChildern = visit(next); - if (visitChildern) { - collectEntries(next.additionalItems, next.additionalProperties, next.not, next.contains, next.propertyNames, next.if, next.then, next.else, next.unevaluatedItems, next.unevaluatedProperties); - collectMapEntries(next.definitions, next.$defs, next.properties, next.patternProperties, next.dependencies, next.dependentSchemas); - collectArrayEntries(next.anyOf, next.allOf, next.oneOf, next.prefixItems); - collectEntryOrArrayEntries(next.items); - } - next = toWalk.pop(); - } -} - diff --git a/src/vs/base/common/jsonc.d.ts b/src/vs/base/common/jsonc.d.ts deleted file mode 100644 index 504e6c60f9..0000000000 --- a/src/vs/base/common/jsonc.d.ts +++ /dev/null @@ -1,24 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -/** - * A drop-in replacement for JSON.parse that can parse - * JSON with comments and trailing commas. - * - * @param content the content to strip comments from - * @returns the parsed content as JSON -*/ -export function parse(content: string): any; - -/** - * Strips single and multi line JavaScript comments from JSON - * content. Ignores characters in strings BUT doesn't support - * string continuation across multiple lines since it is not - * supported in JSON. - * - * @param content the content to strip comments from - * @returns the content without comments -*/ -export function stripComments(content: string): string; diff --git a/src/vs/base/common/jsonc.js b/src/vs/base/common/jsonc.js deleted file mode 100644 index 21e3b7eae4..0000000000 --- a/src/vs/base/common/jsonc.js +++ /dev/null @@ -1,89 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -/// - -//@ts-check -'use strict'; - -// ESM-uncomment-begin -// const module = { exports: {} }; -// ESM-uncomment-end - -(function () { - - function factory() { - // First group matches a double quoted string - // Second group matches a single quoted string - // Third group matches a multi line comment - // Forth group matches a single line comment - // Fifth group matches a trailing comma - const regexp = /("[^"\\]*(?:\\.[^"\\]*)*")|('[^'\\]*(?:\\.[^'\\]*)*')|(\/\*[^\/\*]*(?:(?:\*|\/)[^\/\*]*)*?\*\/)|(\/{2,}.*?(?:(?:\r?\n)|$))|(,\s*[}\]])/g; - - /** - * @param {string} content - * @returns {string} - */ - function stripComments(content) { - return content.replace(regexp, function (match, _m1, _m2, m3, m4, m5) { - // Only one of m1, m2, m3, m4, m5 matches - if (m3) { - // A block comment. Replace with nothing - return ''; - } else if (m4) { - // Since m4 is a single line comment is is at least of length 2 (e.g. //) - // If it ends in \r?\n then keep it. - const length = m4.length; - if (m4[length - 1] === '\n') { - return m4[length - 2] === '\r' ? '\r\n' : '\n'; - } - else { - return ''; - } - } else if (m5) { - // Remove the trailing comma - return match.substring(1); - } else { - // We match a string - return match; - } - }); - } - - /** - * @param {string} content - * @returns {any} - */ - function parse(content) { - const commentsStripped = stripComments(content); - - try { - return JSON.parse(commentsStripped); - } catch (error) { - const trailingCommasStriped = commentsStripped.replace(/,\s*([}\]])/g, '$1'); - return JSON.parse(trailingCommasStriped); - } - } - return { - stripComments, - parse - }; - } - - if (typeof define === 'function') { - // amd - define([], function () { return factory(); }); - } else if (typeof module === 'object' && typeof module.exports === 'object') { - // commonjs - module.exports = factory(); - } else { - console.trace('jsonc defined in UNKNOWN context (neither requirejs or commonjs)'); - } -})(); - -// ESM-uncomment-begin -// export const stripComments = module.exports.stripComments; -// export const parse = module.exports.parse; -// ESM-uncomment-end diff --git a/src/vs/base/common/keybindingLabels.ts b/src/vs/base/common/keybindingLabels.ts deleted file mode 100644 index 9eb9327359..0000000000 --- a/src/vs/base/common/keybindingLabels.ts +++ /dev/null @@ -1,184 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { Modifiers } from 'vs/base/common/keybindings'; -import { OperatingSystem } from 'vs/base/common/platform'; -import * as nls from 'vs/nls'; - -export interface ModifierLabels { - readonly ctrlKey: string; - readonly shiftKey: string; - readonly altKey: string; - readonly metaKey: string; - readonly separator: string; -} - -export interface KeyLabelProvider { - (keybinding: T): string | null; -} - -export class ModifierLabelProvider { - - public readonly modifierLabels: ModifierLabels[]; - - constructor(mac: ModifierLabels, windows: ModifierLabels, linux: ModifierLabels = windows) { - this.modifierLabels = [null!]; // index 0 will never me accessed. - this.modifierLabels[OperatingSystem.Macintosh] = mac; - this.modifierLabels[OperatingSystem.Windows] = windows; - this.modifierLabels[OperatingSystem.Linux] = linux; - } - - public toLabel(OS: OperatingSystem, chords: readonly T[], keyLabelProvider: KeyLabelProvider): string | null { - if (chords.length === 0) { - return null; - } - - const result: string[] = []; - for (let i = 0, len = chords.length; i < len; i++) { - const chord = chords[i]; - const keyLabel = keyLabelProvider(chord); - if (keyLabel === null) { - // this keybinding cannot be expressed... - return null; - } - result[i] = _simpleAsString(chord, keyLabel, this.modifierLabels[OS]); - } - return result.join(' '); - } -} - -/** - * A label provider that prints modifiers in a suitable format for displaying in the UI. - */ -export const UILabelProvider = new ModifierLabelProvider( - { - ctrlKey: '\u2303', - shiftKey: '⇧', - altKey: '⌥', - metaKey: '⌘', - separator: '', - }, - { - ctrlKey: nls.localize({ key: 'ctrlKey', comment: ['This is the short form for the Control key on the keyboard'] }, "Ctrl"), - shiftKey: nls.localize({ key: 'shiftKey', comment: ['This is the short form for the Shift key on the keyboard'] }, "Shift"), - altKey: nls.localize({ key: 'altKey', comment: ['This is the short form for the Alt key on the keyboard'] }, "Alt"), - metaKey: nls.localize({ key: 'windowsKey', comment: ['This is the short form for the Windows key on the keyboard'] }, "Windows"), - separator: '+', - }, - { - ctrlKey: nls.localize({ key: 'ctrlKey', comment: ['This is the short form for the Control key on the keyboard'] }, "Ctrl"), - shiftKey: nls.localize({ key: 'shiftKey', comment: ['This is the short form for the Shift key on the keyboard'] }, "Shift"), - altKey: nls.localize({ key: 'altKey', comment: ['This is the short form for the Alt key on the keyboard'] }, "Alt"), - metaKey: nls.localize({ key: 'superKey', comment: ['This is the short form for the Super key on the keyboard'] }, "Super"), - separator: '+', - } -); - -/** - * A label provider that prints modifiers in a suitable format for ARIA. - */ -export const AriaLabelProvider = new ModifierLabelProvider( - { - ctrlKey: nls.localize({ key: 'ctrlKey.long', comment: ['This is the long form for the Control key on the keyboard'] }, "Control"), - shiftKey: nls.localize({ key: 'shiftKey.long', comment: ['This is the long form for the Shift key on the keyboard'] }, "Shift"), - altKey: nls.localize({ key: 'optKey.long', comment: ['This is the long form for the Alt/Option key on the keyboard'] }, "Option"), - metaKey: nls.localize({ key: 'cmdKey.long', comment: ['This is the long form for the Command key on the keyboard'] }, "Command"), - separator: '+', - }, - { - ctrlKey: nls.localize({ key: 'ctrlKey.long', comment: ['This is the long form for the Control key on the keyboard'] }, "Control"), - shiftKey: nls.localize({ key: 'shiftKey.long', comment: ['This is the long form for the Shift key on the keyboard'] }, "Shift"), - altKey: nls.localize({ key: 'altKey.long', comment: ['This is the long form for the Alt key on the keyboard'] }, "Alt"), - metaKey: nls.localize({ key: 'windowsKey.long', comment: ['This is the long form for the Windows key on the keyboard'] }, "Windows"), - separator: '+', - }, - { - ctrlKey: nls.localize({ key: 'ctrlKey.long', comment: ['This is the long form for the Control key on the keyboard'] }, "Control"), - shiftKey: nls.localize({ key: 'shiftKey.long', comment: ['This is the long form for the Shift key on the keyboard'] }, "Shift"), - altKey: nls.localize({ key: 'altKey.long', comment: ['This is the long form for the Alt key on the keyboard'] }, "Alt"), - metaKey: nls.localize({ key: 'superKey.long', comment: ['This is the long form for the Super key on the keyboard'] }, "Super"), - separator: '+', - } -); - -/** - * A label provider that prints modifiers in a suitable format for Electron Accelerators. - * See https://github.com/electron/electron/blob/master/docs/api/accelerator.md - */ -export const ElectronAcceleratorLabelProvider = new ModifierLabelProvider( - { - ctrlKey: 'Ctrl', - shiftKey: 'Shift', - altKey: 'Alt', - metaKey: 'Cmd', - separator: '+', - }, - { - ctrlKey: 'Ctrl', - shiftKey: 'Shift', - altKey: 'Alt', - metaKey: 'Super', - separator: '+', - } -); - -/** - * A label provider that prints modifiers in a suitable format for user settings. - */ -export const UserSettingsLabelProvider = new ModifierLabelProvider( - { - ctrlKey: 'ctrl', - shiftKey: 'shift', - altKey: 'alt', - metaKey: 'cmd', - separator: '+', - }, - { - ctrlKey: 'ctrl', - shiftKey: 'shift', - altKey: 'alt', - metaKey: 'win', - separator: '+', - }, - { - ctrlKey: 'ctrl', - shiftKey: 'shift', - altKey: 'alt', - metaKey: 'meta', - separator: '+', - } -); - -function _simpleAsString(modifiers: Modifiers, key: string, labels: ModifierLabels): string { - if (key === null) { - return ''; - } - - const result: string[] = []; - - // translate modifier keys: Ctrl-Shift-Alt-Meta - if (modifiers.ctrlKey) { - result.push(labels.ctrlKey); - } - - if (modifiers.shiftKey) { - result.push(labels.shiftKey); - } - - if (modifiers.altKey) { - result.push(labels.altKey); - } - - if (modifiers.metaKey) { - result.push(labels.metaKey); - } - - // the actual key - if (key !== '') { - result.push(key); - } - - return result.join(labels.separator); -} diff --git a/src/vs/base/common/keybindingParser.ts b/src/vs/base/common/keybindingParser.ts deleted file mode 100644 index 3c36b8103f..0000000000 --- a/src/vs/base/common/keybindingParser.ts +++ /dev/null @@ -1,102 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { KeyCodeUtils, ScanCodeUtils } from 'vs/base/common/keyCodes'; -import { KeyCodeChord, ScanCodeChord, Keybinding, Chord } from 'vs/base/common/keybindings'; - -export class KeybindingParser { - - private static _readModifiers(input: string) { - input = input.toLowerCase().trim(); - - let ctrl = false; - let shift = false; - let alt = false; - let meta = false; - - let matchedModifier: boolean; - - do { - matchedModifier = false; - if (/^ctrl(\+|\-)/.test(input)) { - ctrl = true; - input = input.substr('ctrl-'.length); - matchedModifier = true; - } - if (/^shift(\+|\-)/.test(input)) { - shift = true; - input = input.substr('shift-'.length); - matchedModifier = true; - } - if (/^alt(\+|\-)/.test(input)) { - alt = true; - input = input.substr('alt-'.length); - matchedModifier = true; - } - if (/^meta(\+|\-)/.test(input)) { - meta = true; - input = input.substr('meta-'.length); - matchedModifier = true; - } - if (/^win(\+|\-)/.test(input)) { - meta = true; - input = input.substr('win-'.length); - matchedModifier = true; - } - if (/^cmd(\+|\-)/.test(input)) { - meta = true; - input = input.substr('cmd-'.length); - matchedModifier = true; - } - } while (matchedModifier); - - let key: string; - - const firstSpaceIdx = input.indexOf(' '); - if (firstSpaceIdx > 0) { - key = input.substring(0, firstSpaceIdx); - input = input.substring(firstSpaceIdx); - } else { - key = input; - input = ''; - } - - return { - remains: input, - ctrl, - shift, - alt, - meta, - key - }; - } - - private static parseChord(input: string): [Chord, string] { - const mods = this._readModifiers(input); - const scanCodeMatch = mods.key.match(/^\[([^\]]+)\]$/); - if (scanCodeMatch) { - const strScanCode = scanCodeMatch[1]; - const scanCode = ScanCodeUtils.lowerCaseToEnum(strScanCode); - return [new ScanCodeChord(mods.ctrl, mods.shift, mods.alt, mods.meta, scanCode), mods.remains]; - } - const keyCode = KeyCodeUtils.fromUserSettings(mods.key); - return [new KeyCodeChord(mods.ctrl, mods.shift, mods.alt, mods.meta, keyCode), mods.remains]; - } - - static parseKeybinding(input: string): Keybinding | null { - if (!input) { - return null; - } - - const chords: Chord[] = []; - let chord: Chord; - - while (input.length > 0) { - [chord, input] = this.parseChord(input); - chords.push(chord); - } - return (chords.length > 0 ? new Keybinding(chords) : null); - } -} diff --git a/src/vs/base/common/linkedText.ts b/src/vs/base/common/linkedText.ts deleted file mode 100644 index 89f6da527d..0000000000 --- a/src/vs/base/common/linkedText.ts +++ /dev/null @@ -1,55 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { memoize } from 'vs/base/common/decorators'; - -export interface ILink { - readonly label: string; - readonly href: string; - readonly title?: string; -} - -export type LinkedTextNode = string | ILink; - -export class LinkedText { - - constructor(readonly nodes: LinkedTextNode[]) { } - - @memoize - toString(): string { - return this.nodes.map(node => typeof node === 'string' ? node : node.label).join(''); - } -} - -const LINK_REGEX = /\[([^\]]+)\]\(((?:https?:\/\/|command:|file:)[^\)\s]+)(?: (["'])(.+?)(\3))?\)/gi; - -export function parseLinkedText(text: string): LinkedText { - const result: LinkedTextNode[] = []; - - let index = 0; - let match: RegExpExecArray | null; - - while (match = LINK_REGEX.exec(text)) { - if (match.index - index > 0) { - result.push(text.substring(index, match.index)); - } - - const [, label, href, , title] = match; - - if (title) { - result.push({ label, href, title }); - } else { - result.push({ label, href }); - } - - index = match.index + match[0].length; - } - - if (index < text.length) { - result.push(text.substring(index)); - } - - return new LinkedText(result); -} diff --git a/src/vs/base/common/mime.ts b/src/vs/base/common/mime.ts deleted file mode 100644 index 288a8daf33..0000000000 --- a/src/vs/base/common/mime.ts +++ /dev/null @@ -1,126 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { extname } from 'vs/base/common/path'; - -export const Mimes = Object.freeze({ - text: 'text/plain', - binary: 'application/octet-stream', - unknown: 'application/unknown', - markdown: 'text/markdown', - latex: 'text/latex', - uriList: 'text/uri-list', -}); - -interface MapExtToMediaMimes { - [index: string]: string; -} - -const mapExtToTextMimes: MapExtToMediaMimes = { - '.css': 'text/css', - '.csv': 'text/csv', - '.htm': 'text/html', - '.html': 'text/html', - '.ics': 'text/calendar', - '.js': 'text/javascript', - '.mjs': 'text/javascript', - '.txt': 'text/plain', - '.xml': 'text/xml' -}; - -// Known media mimes that we can handle -const mapExtToMediaMimes: MapExtToMediaMimes = { - '.aac': 'audio/x-aac', - '.avi': 'video/x-msvideo', - '.bmp': 'image/bmp', - '.flv': 'video/x-flv', - '.gif': 'image/gif', - '.ico': 'image/x-icon', - '.jpe': 'image/jpg', - '.jpeg': 'image/jpg', - '.jpg': 'image/jpg', - '.m1v': 'video/mpeg', - '.m2a': 'audio/mpeg', - '.m2v': 'video/mpeg', - '.m3a': 'audio/mpeg', - '.mid': 'audio/midi', - '.midi': 'audio/midi', - '.mk3d': 'video/x-matroska', - '.mks': 'video/x-matroska', - '.mkv': 'video/x-matroska', - '.mov': 'video/quicktime', - '.movie': 'video/x-sgi-movie', - '.mp2': 'audio/mpeg', - '.mp2a': 'audio/mpeg', - '.mp3': 'audio/mpeg', - '.mp4': 'video/mp4', - '.mp4a': 'audio/mp4', - '.mp4v': 'video/mp4', - '.mpe': 'video/mpeg', - '.mpeg': 'video/mpeg', - '.mpg': 'video/mpeg', - '.mpg4': 'video/mp4', - '.mpga': 'audio/mpeg', - '.oga': 'audio/ogg', - '.ogg': 'audio/ogg', - '.opus': 'audio/opus', - '.ogv': 'video/ogg', - '.png': 'image/png', - '.psd': 'image/vnd.adobe.photoshop', - '.qt': 'video/quicktime', - '.spx': 'audio/ogg', - '.svg': 'image/svg+xml', - '.tga': 'image/x-tga', - '.tif': 'image/tiff', - '.tiff': 'image/tiff', - '.wav': 'audio/x-wav', - '.webm': 'video/webm', - '.webp': 'image/webp', - '.wma': 'audio/x-ms-wma', - '.wmv': 'video/x-ms-wmv', - '.woff': 'application/font-woff', -}; - -export function getMediaOrTextMime(path: string): string | undefined { - const ext = extname(path); - const textMime = mapExtToTextMimes[ext.toLowerCase()]; - if (textMime !== undefined) { - return textMime; - } else { - return getMediaMime(path); - } -} - -export function getMediaMime(path: string): string | undefined { - const ext = extname(path); - return mapExtToMediaMimes[ext.toLowerCase()]; -} - -export function getExtensionForMimeType(mimeType: string): string | undefined { - for (const extension in mapExtToMediaMimes) { - if (mapExtToMediaMimes[extension] === mimeType) { - return extension; - } - } - - return undefined; -} - -const _simplePattern = /^(.+)\/(.+?)(;.+)?$/; - -export function normalizeMimeType(mimeType: string): string; -export function normalizeMimeType(mimeType: string, strict: true): string | undefined; -export function normalizeMimeType(mimeType: string, strict?: true): string | undefined { - - const match = _simplePattern.exec(mimeType); - if (!match) { - return strict - ? undefined - : mimeType; - } - // https://datatracker.ietf.org/doc/html/rfc2045#section-5.1 - // media and subtype must ALWAYS be lowercase, parameter not - return `${match[1].toLowerCase()}/${match[2].toLowerCase()}${match[3] ?? ''}`; -} diff --git a/src/vs/base/common/navigator.ts b/src/vs/base/common/navigator.ts deleted file mode 100644 index ba7feffef5..0000000000 --- a/src/vs/base/common/navigator.ts +++ /dev/null @@ -1,50 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -export interface INavigator { - current(): T | null; - previous(): T | null; - first(): T | null; - last(): T | null; - next(): T | null; -} - -export class ArrayNavigator implements INavigator { - - constructor( - private readonly items: readonly T[], - protected start: number = 0, - protected end: number = items.length, - protected index = start - 1 - ) { } - - current(): T | null { - if (this.index === this.start - 1 || this.index === this.end) { - return null; - } - - return this.items[this.index]; - } - - next(): T | null { - this.index = Math.min(this.index + 1, this.end); - return this.current(); - } - - previous(): T | null { - this.index = Math.max(this.index - 1, this.start - 1); - return this.current(); - } - - first(): T | null { - this.index = this.start; - return this.current(); - } - - last(): T | null { - this.index = this.end - 1; - return this.current(); - } -} diff --git a/src/vs/base/common/network.ts b/src/vs/base/common/network.ts deleted file mode 100644 index 5662c57a3e..0000000000 --- a/src/vs/base/common/network.ts +++ /dev/null @@ -1,128 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -export namespace Schemas { - - /** - * A schema that is used for models that exist in memory - * only and that have no correspondence on a server or such. - */ - export const inMemory = 'inmemory'; - - /** - * A schema that is used for setting files - */ - export const vscode = 'vscode'; - - /** - * A schema that is used for internal private files - */ - export const internal = 'private'; - - /** - * A walk-through document. - */ - export const walkThrough = 'walkThrough'; - - /** - * An embedded code snippet. - */ - export const walkThroughSnippet = 'walkThroughSnippet'; - - export const http = 'http'; - - export const https = 'https'; - - export const file = 'file'; - - export const mailto = 'mailto'; - - export const untitled = 'untitled'; - - export const data = 'data'; - - export const command = 'command'; - - export const vscodeRemote = 'vscode-remote'; - - export const vscodeRemoteResource = 'vscode-remote-resource'; - - export const vscodeManagedRemoteResource = 'vscode-managed-remote-resource'; - - export const vscodeUserData = 'vscode-userdata'; - - export const vscodeCustomEditor = 'vscode-custom-editor'; - - export const vscodeNotebookCell = 'vscode-notebook-cell'; - export const vscodeNotebookCellMetadata = 'vscode-notebook-cell-metadata'; - export const vscodeNotebookCellOutput = 'vscode-notebook-cell-output'; - export const vscodeInteractiveInput = 'vscode-interactive-input'; - - export const vscodeSettings = 'vscode-settings'; - - export const vscodeWorkspaceTrust = 'vscode-workspace-trust'; - - export const vscodeTerminal = 'vscode-terminal'; - - /** Scheme used for code blocks in chat. */ - export const vscodeChatCodeBlock = 'vscode-chat-code-block'; - - /** - * Scheme used for backing documents created by copilot for chat. - */ - export const vscodeCopilotBackingChatCodeBlock = 'vscode-copilot-chat-code-block'; - - /** Scheme used for LHS of code compare (aka diff) blocks in chat. */ - export const vscodeChatCodeCompareBlock = 'vscode-chat-code-compare-block'; - - /** Scheme used for the chat input editor. */ - export const vscodeChatSesssion = 'vscode-chat-editor'; - - /** - * Scheme used internally for webviews that aren't linked to a resource (i.e. not custom editors) - */ - export const webviewPanel = 'webview-panel'; - - /** - * Scheme used for loading the wrapper html and script in webviews. - */ - export const vscodeWebview = 'vscode-webview'; - - /** - * Scheme used for extension pages - */ - export const extension = 'extension'; - - /** - * Scheme used as a replacement of `file` scheme to load - * files with our custom protocol handler (desktop only). - */ - export const vscodeFileResource = 'vscode-file'; - - /** - * Scheme used for temporary resources - */ - export const tmp = 'tmp'; - - /** - * Scheme used vs live share - */ - export const vsls = 'vsls'; - - /** - * Scheme used for the Source Control commit input's text document - */ - export const vscodeSourceControl = 'vscode-scm'; - - /** - * Scheme used for input box for creating comments. - */ - export const commentsInput = 'comment'; - - /** - * Scheme used for special rendering of settings in the release notes - */ - export const codeSetting = 'code-setting'; -} diff --git a/src/vs/base/common/objects.ts b/src/vs/base/common/objects.ts deleted file mode 100644 index 14ec0e7197..0000000000 --- a/src/vs/base/common/objects.ts +++ /dev/null @@ -1,274 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { isTypedArray, isObject, isUndefinedOrNull } from 'vs/base/common/types'; - -export function deepClone(obj: T): T { - if (!obj || typeof obj !== 'object') { - return obj; - } - if (obj instanceof RegExp) { - return obj; - } - const result: any = Array.isArray(obj) ? [] : {}; - Object.entries(obj).forEach(([key, value]) => { - result[key] = value && typeof value === 'object' ? deepClone(value) : value; - }); - return result; -} - -export function deepFreeze(obj: T): T { - if (!obj || typeof obj !== 'object') { - return obj; - } - const stack: any[] = [obj]; - while (stack.length > 0) { - const obj = stack.shift(); - Object.freeze(obj); - for (const key in obj) { - if (_hasOwnProperty.call(obj, key)) { - const prop = obj[key]; - if (typeof prop === 'object' && !Object.isFrozen(prop) && !isTypedArray(prop)) { - stack.push(prop); - } - } - } - } - return obj; -} - -const _hasOwnProperty = Object.prototype.hasOwnProperty; - - -export function cloneAndChange(obj: any, changer: (orig: any) => any): any { - return _cloneAndChange(obj, changer, new Set()); -} - -function _cloneAndChange(obj: any, changer: (orig: any) => any, seen: Set): any { - if (isUndefinedOrNull(obj)) { - return obj; - } - - const changed = changer(obj); - if (typeof changed !== 'undefined') { - return changed; - } - - if (Array.isArray(obj)) { - const r1: any[] = []; - for (const e of obj) { - r1.push(_cloneAndChange(e, changer, seen)); - } - return r1; - } - - if (isObject(obj)) { - if (seen.has(obj)) { - throw new Error('Cannot clone recursive data-structure'); - } - seen.add(obj); - const r2 = {}; - for (const i2 in obj) { - if (_hasOwnProperty.call(obj, i2)) { - (r2 as any)[i2] = _cloneAndChange(obj[i2], changer, seen); - } - } - seen.delete(obj); - return r2; - } - - return obj; -} - -/** - * Copies all properties of source into destination. The optional parameter "overwrite" allows to control - * if existing properties on the destination should be overwritten or not. Defaults to true (overwrite). - */ -export function mixin(destination: any, source: any, overwrite: boolean = true): any { - if (!isObject(destination)) { - return source; - } - - if (isObject(source)) { - Object.keys(source).forEach(key => { - if (key in destination) { - if (overwrite) { - if (isObject(destination[key]) && isObject(source[key])) { - mixin(destination[key], source[key], overwrite); - } else { - destination[key] = source[key]; - } - } - } else { - destination[key] = source[key]; - } - }); - } - return destination; -} - -export function equals(one: any, other: any): boolean { - if (one === other) { - return true; - } - if (one === null || one === undefined || other === null || other === undefined) { - return false; - } - if (typeof one !== typeof other) { - return false; - } - if (typeof one !== 'object') { - return false; - } - if ((Array.isArray(one)) !== (Array.isArray(other))) { - return false; - } - - let i: number; - let key: string; - - if (Array.isArray(one)) { - if (one.length !== other.length) { - return false; - } - for (i = 0; i < one.length; i++) { - if (!equals(one[i], other[i])) { - return false; - } - } - } else { - const oneKeys: string[] = []; - - for (key in one) { - oneKeys.push(key); - } - oneKeys.sort(); - const otherKeys: string[] = []; - for (key in other) { - otherKeys.push(key); - } - otherKeys.sort(); - if (!equals(oneKeys, otherKeys)) { - return false; - } - for (i = 0; i < oneKeys.length; i++) { - if (!equals(one[oneKeys[i]], other[oneKeys[i]])) { - return false; - } - } - } - return true; -} - -/** - * Calls `JSON.Stringify` with a replacer to break apart any circular references. - * This prevents `JSON`.stringify` from throwing the exception - * "Uncaught TypeError: Converting circular structure to JSON" - */ -export function safeStringify(obj: any): string { - const seen = new Set(); - return JSON.stringify(obj, (key, value) => { - if (isObject(value) || Array.isArray(value)) { - if (seen.has(value)) { - return '[Circular]'; - } else { - seen.add(value); - } - } - if (typeof value === 'bigint') { - return `[BigInt ${value.toString()}]`; - } - return value; - }); -} - -type obj = { [key: string]: any }; -/** - * Returns an object that has keys for each value that is different in the base object. Keys - * that do not exist in the target but in the base object are not considered. - * - * Note: This is not a deep-diffing method, so the values are strictly taken into the resulting - * object if they differ. - * - * @param base the object to diff against - * @param obj the object to use for diffing - */ -export function distinct(base: obj, target: obj): obj { - const result = Object.create(null); - - if (!base || !target) { - return result; - } - - const targetKeys = Object.keys(target); - targetKeys.forEach(k => { - const baseValue = base[k]; - const targetValue = target[k]; - - if (!equals(baseValue, targetValue)) { - result[k] = targetValue; - } - }); - - return result; -} - -export function getCaseInsensitive(target: obj, key: string): any { - const lowercaseKey = key.toLowerCase(); - const equivalentKey = Object.keys(target).find(k => k.toLowerCase() === lowercaseKey); - return equivalentKey ? target[equivalentKey] : target[key]; -} - -export function filter(obj: obj, predicate: (key: string, value: any) => boolean): obj { - const result = Object.create(null); - for (const [key, value] of Object.entries(obj)) { - if (predicate(key, value)) { - result[key] = value; - } - } - return result; -} - -export function getAllPropertyNames(obj: object): string[] { - let res: string[] = []; - while (Object.prototype !== obj) { - res = res.concat(Object.getOwnPropertyNames(obj)); - obj = Object.getPrototypeOf(obj); - } - return res; -} - -export function getAllMethodNames(obj: object): string[] { - const methods: string[] = []; - for (const prop of getAllPropertyNames(obj)) { - if (typeof (obj as any)[prop] === 'function') { - methods.push(prop); - } - } - return methods; -} - -export function createProxyObject(methodNames: string[], invoke: (method: string, args: unknown[]) => unknown): T { - const createProxyMethod = (method: string): () => unknown => { - return function () { - const args = Array.prototype.slice.call(arguments, 0); - return invoke(method, args); - }; - }; - - const result = {} as T; - for (const methodName of methodNames) { - (result)[methodName] = createProxyMethod(methodName); - } - return result; -} - -export function mapValues(obj: T, fn: (value: T[keyof T], key: string) => R): { [K in keyof T]: R } { - const result: { [key: string]: R } = {}; - for (const [key, value] of Object.entries(obj)) { - result[key] = fn(value, key); - } - return result as { [K in keyof T]: R }; -} diff --git a/src/vs/base/common/paging.ts b/src/vs/base/common/paging.ts deleted file mode 100644 index c13957cc0d..0000000000 --- a/src/vs/base/common/paging.ts +++ /dev/null @@ -1,189 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { range } from 'vs/base/common/arrays'; -import { CancellationToken, CancellationTokenSource } from 'vs/base/common/cancellation'; -import { CancellationError } from 'vs/base/common/errors'; - -/** - * A Pager is a stateless abstraction over a paged collection. - */ -export interface IPager { - firstPage: T[]; - total: number; - pageSize: number; - getPage(pageIndex: number, cancellationToken: CancellationToken): Promise; -} - -interface IPage { - isResolved: boolean; - promise: Promise | null; - cts: CancellationTokenSource | null; - promiseIndexes: Set; - elements: T[]; -} - -function createPage(elements?: T[]): IPage { - return { - isResolved: !!elements, - promise: null, - cts: null, - promiseIndexes: new Set(), - elements: elements || [] - }; -} - -/** - * A PagedModel is a stateful model over an abstracted paged collection. - */ -export interface IPagedModel { - length: number; - isResolved(index: number): boolean; - get(index: number): T; - resolve(index: number, cancellationToken: CancellationToken): Promise; -} - -export function singlePagePager(elements: T[]): IPager { - return { - firstPage: elements, - total: elements.length, - pageSize: elements.length, - getPage: (pageIndex: number, cancellationToken: CancellationToken): Promise => { - return Promise.resolve(elements); - } - }; -} - -export class PagedModel implements IPagedModel { - - private pager: IPager; - private pages: IPage[] = []; - - get length(): number { return this.pager.total; } - - constructor(arg: IPager | T[]) { - this.pager = Array.isArray(arg) ? singlePagePager(arg) : arg; - - const totalPages = Math.ceil(this.pager.total / this.pager.pageSize); - - this.pages = [ - createPage(this.pager.firstPage.slice()), - ...range(totalPages - 1).map(() => createPage()) - ]; - } - - isResolved(index: number): boolean { - const pageIndex = Math.floor(index / this.pager.pageSize); - const page = this.pages[pageIndex]; - - return !!page.isResolved; - } - - get(index: number): T { - const pageIndex = Math.floor(index / this.pager.pageSize); - const indexInPage = index % this.pager.pageSize; - const page = this.pages[pageIndex]; - - return page.elements[indexInPage]; - } - - resolve(index: number, cancellationToken: CancellationToken): Promise { - if (cancellationToken.isCancellationRequested) { - return Promise.reject(new CancellationError()); - } - - const pageIndex = Math.floor(index / this.pager.pageSize); - const indexInPage = index % this.pager.pageSize; - const page = this.pages[pageIndex]; - - if (page.isResolved) { - return Promise.resolve(page.elements[indexInPage]); - } - - if (!page.promise) { - page.cts = new CancellationTokenSource(); - page.promise = this.pager.getPage(pageIndex, page.cts.token) - .then(elements => { - page.elements = elements; - page.isResolved = true; - page.promise = null; - page.cts = null; - }, err => { - page.isResolved = false; - page.promise = null; - page.cts = null; - return Promise.reject(err); - }); - } - - const listener = cancellationToken.onCancellationRequested(() => { - if (!page.cts) { - return; - } - - page.promiseIndexes.delete(index); - - if (page.promiseIndexes.size === 0) { - page.cts.cancel(); - } - }); - - page.promiseIndexes.add(index); - - return page.promise.then(() => page.elements[indexInPage]) - .finally(() => listener.dispose()); - } -} - -export class DelayedPagedModel implements IPagedModel { - - get length(): number { return this.model.length; } - - constructor(private model: IPagedModel, private timeout: number = 500) { } - - isResolved(index: number): boolean { - return this.model.isResolved(index); - } - - get(index: number): T { - return this.model.get(index); - } - - resolve(index: number, cancellationToken: CancellationToken): Promise { - return new Promise((c, e) => { - if (cancellationToken.isCancellationRequested) { - return e(new CancellationError()); - } - - const timer = setTimeout(() => { - if (cancellationToken.isCancellationRequested) { - return e(new CancellationError()); - } - - timeoutCancellation.dispose(); - this.model.resolve(index, cancellationToken).then(c, e); - }, this.timeout); - - const timeoutCancellation = cancellationToken.onCancellationRequested(() => { - clearTimeout(timer); - timeoutCancellation.dispose(); - e(new CancellationError()); - }); - }); - } -} - -/** - * Similar to array.map, `mapPager` lets you map the elements of an - * abstract paged collection to another type. - */ -export function mapPager(pager: IPager, fn: (t: T) => R): IPager { - return { - firstPage: pager.firstPage.map(fn), - total: pager.total, - pageSize: pager.pageSize, - getPage: (pageIndex, token) => pager.getPage(pageIndex, token).then(r => r.map(fn)) - }; -} diff --git a/src/vs/base/common/parsers.ts b/src/vs/base/common/parsers.ts deleted file mode 100644 index 070103093a..0000000000 --- a/src/vs/base/common/parsers.ts +++ /dev/null @@ -1,79 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -export const enum ValidationState { - OK = 0, - Info = 1, - Warning = 2, - Error = 3, - Fatal = 4 -} - -export class ValidationStatus { - private _state: ValidationState; - - constructor() { - this._state = ValidationState.OK; - } - - public get state(): ValidationState { - return this._state; - } - - public set state(value: ValidationState) { - if (value > this._state) { - this._state = value; - } - } - - public isOK(): boolean { - return this._state === ValidationState.OK; - } - - public isFatal(): boolean { - return this._state === ValidationState.Fatal; - } -} - -export interface IProblemReporter { - info(message: string): void; - warn(message: string): void; - error(message: string): void; - fatal(message: string): void; - status: ValidationStatus; -} - -export abstract class Parser { - - private _problemReporter: IProblemReporter; - - constructor(problemReporter: IProblemReporter) { - this._problemReporter = problemReporter; - } - - public reset(): void { - this._problemReporter.status.state = ValidationState.OK; - } - - public get problemReporter(): IProblemReporter { - return this._problemReporter; - } - - public info(message: string): void { - this._problemReporter.info(message); - } - - public warn(message: string): void { - this._problemReporter.warn(message); - } - - public error(message: string): void { - this._problemReporter.error(message); - } - - public fatal(message: string): void { - this._problemReporter.fatal(message); - } -} diff --git a/src/vs/base/common/path.ts b/src/vs/base/common/path.ts deleted file mode 100644 index 6f40f7d035..0000000000 --- a/src/vs/base/common/path.ts +++ /dev/null @@ -1,1529 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -// NOTE: VSCode's copy of nodejs path library to be usable in common (non-node) namespace -// Copied from: https://github.com/nodejs/node/commits/v20.9.0/lib/path.js -// Excluding: the change that adds primordials -// (https://github.com/nodejs/node/commit/187a862d221dec42fa9a5c4214e7034d9092792f and others) - -/** - * Copyright Joyent, Inc. and other Node contributors. - * - * Permission is hereby granted, free of charge, to any person obtaining a - * copy of this software and associated documentation files (the - * "Software"), to deal in the Software without restriction, including - * without limitation the rights to use, copy, modify, merge, publish, - * distribute, sublicense, and/or sell copies of the Software, and to permit - * persons to whom the Software is furnished to do so, subject to the - * following conditions: - * - * The above copyright notice and this permission notice shall be included - * in all copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS - * OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF - * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN - * NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, - * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR - * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE - * USE OR OTHER DEALINGS IN THE SOFTWARE. - */ - -import * as process from 'vs/base/common/process'; - -const CHAR_UPPERCASE_A = 65;/* A */ -const CHAR_LOWERCASE_A = 97; /* a */ -const CHAR_UPPERCASE_Z = 90; /* Z */ -const CHAR_LOWERCASE_Z = 122; /* z */ -const CHAR_DOT = 46; /* . */ -const CHAR_FORWARD_SLASH = 47; /* / */ -const CHAR_BACKWARD_SLASH = 92; /* \ */ -const CHAR_COLON = 58; /* : */ -const CHAR_QUESTION_MARK = 63; /* ? */ - -class ErrorInvalidArgType extends Error { - code: 'ERR_INVALID_ARG_TYPE'; - constructor(name: string, expected: string, actual: unknown) { - // determiner: 'must be' or 'must not be' - let determiner; - if (typeof expected === 'string' && expected.indexOf('not ') === 0) { - determiner = 'must not be'; - expected = expected.replace(/^not /, ''); - } else { - determiner = 'must be'; - } - - const type = name.indexOf('.') !== -1 ? 'property' : 'argument'; - let msg = `The "${name}" ${type} ${determiner} of type ${expected}`; - - msg += `. Received type ${typeof actual}`; - super(msg); - - this.code = 'ERR_INVALID_ARG_TYPE'; - } -} - -function validateObject(pathObject: object, name: string) { - if (pathObject === null || typeof pathObject !== 'object') { - throw new ErrorInvalidArgType(name, 'Object', pathObject); - } -} - -function validateString(value: string, name: string) { - if (typeof value !== 'string') { - throw new ErrorInvalidArgType(name, 'string', value); - } -} - -const platformIsWin32 = (process.platform === 'win32'); - -function isPathSeparator(code: number | undefined) { - return code === CHAR_FORWARD_SLASH || code === CHAR_BACKWARD_SLASH; -} - -function isPosixPathSeparator(code: number | undefined) { - return code === CHAR_FORWARD_SLASH; -} - -function isWindowsDeviceRoot(code: number) { - return (code >= CHAR_UPPERCASE_A && code <= CHAR_UPPERCASE_Z) || - (code >= CHAR_LOWERCASE_A && code <= CHAR_LOWERCASE_Z); -} - -// Resolves . and .. elements in a path with directory names -function normalizeString(path: string, allowAboveRoot: boolean, separator: string, isPathSeparator: (code?: number) => boolean) { - let res = ''; - let lastSegmentLength = 0; - let lastSlash = -1; - let dots = 0; - let code = 0; - for (let i = 0; i <= path.length; ++i) { - if (i < path.length) { - code = path.charCodeAt(i); - } - else if (isPathSeparator(code)) { - break; - } - else { - code = CHAR_FORWARD_SLASH; - } - - if (isPathSeparator(code)) { - if (lastSlash === i - 1 || dots === 1) { - // NOOP - } else if (dots === 2) { - if (res.length < 2 || lastSegmentLength !== 2 || - res.charCodeAt(res.length - 1) !== CHAR_DOT || - res.charCodeAt(res.length - 2) !== CHAR_DOT) { - if (res.length > 2) { - const lastSlashIndex = res.lastIndexOf(separator); - if (lastSlashIndex === -1) { - res = ''; - lastSegmentLength = 0; - } else { - res = res.slice(0, lastSlashIndex); - lastSegmentLength = res.length - 1 - res.lastIndexOf(separator); - } - lastSlash = i; - dots = 0; - continue; - } else if (res.length !== 0) { - res = ''; - lastSegmentLength = 0; - lastSlash = i; - dots = 0; - continue; - } - } - if (allowAboveRoot) { - res += res.length > 0 ? `${separator}..` : '..'; - lastSegmentLength = 2; - } - } else { - if (res.length > 0) { - res += `${separator}${path.slice(lastSlash + 1, i)}`; - } - else { - res = path.slice(lastSlash + 1, i); - } - lastSegmentLength = i - lastSlash - 1; - } - lastSlash = i; - dots = 0; - } else if (code === CHAR_DOT && dots !== -1) { - ++dots; - } else { - dots = -1; - } - } - return res; -} - -function formatExt(ext: string): string { - return ext ? `${ext[0] === '.' ? '' : '.'}${ext}` : ''; -} - -function _format(sep: string, pathObject: ParsedPath) { - validateObject(pathObject, 'pathObject'); - const dir = pathObject.dir || pathObject.root; - const base = pathObject.base || - `${pathObject.name || ''}${formatExt(pathObject.ext)}`; - if (!dir) { - return base; - } - return dir === pathObject.root ? `${dir}${base}` : `${dir}${sep}${base}`; -} - -export interface ParsedPath { - root: string; - dir: string; - base: string; - ext: string; - name: string; -} - -export interface IPath { - normalize(path: string): string; - isAbsolute(path: string): boolean; - join(...paths: string[]): string; - resolve(...pathSegments: string[]): string; - relative(from: string, to: string): string; - dirname(path: string): string; - basename(path: string, suffix?: string): string; - extname(path: string): string; - format(pathObject: ParsedPath): string; - parse(path: string): ParsedPath; - toNamespacedPath(path: string): string; - sep: '\\' | '/'; - delimiter: string; - win32: IPath | null; - posix: IPath | null; -} - -export const win32: IPath = { - // path.resolve([from ...], to) - resolve(...pathSegments: string[]): string { - let resolvedDevice = ''; - let resolvedTail = ''; - let resolvedAbsolute = false; - - for (let i = pathSegments.length - 1; i >= -1; i--) { - let path; - if (i >= 0) { - path = pathSegments[i]; - validateString(path, `paths[${i}]`); - - // Skip empty entries - if (path.length === 0) { - continue; - } - } else if (resolvedDevice.length === 0) { - path = process.cwd(); - } else { - // Windows has the concept of drive-specific current working - // directories. If we've resolved a drive letter but not yet an - // absolute path, get cwd for that drive, or the process cwd if - // the drive cwd is not available. We're sure the device is not - // a UNC path at this points, because UNC paths are always absolute. - path = process.env[`=${resolvedDevice}`] || process.cwd(); - - // Verify that a cwd was found and that it actually points - // to our drive. If not, default to the drive's root. - if (path === undefined || - (path.slice(0, 2).toLowerCase() !== resolvedDevice.toLowerCase() && - path.charCodeAt(2) === CHAR_BACKWARD_SLASH)) { - path = `${resolvedDevice}\\`; - } - } - - const len = path.length; - let rootEnd = 0; - let device = ''; - let isAbsolute = false; - const code = path.charCodeAt(0); - - // Try to match a root - if (len === 1) { - if (isPathSeparator(code)) { - // `path` contains just a path separator - rootEnd = 1; - isAbsolute = true; - } - } else if (isPathSeparator(code)) { - // Possible UNC root - - // If we started with a separator, we know we at least have an - // absolute path of some kind (UNC or otherwise) - isAbsolute = true; - - if (isPathSeparator(path.charCodeAt(1))) { - // Matched double path separator at beginning - let j = 2; - let last = j; - // Match 1 or more non-path separators - while (j < len && !isPathSeparator(path.charCodeAt(j))) { - j++; - } - if (j < len && j !== last) { - const firstPart = path.slice(last, j); - // Matched! - last = j; - // Match 1 or more path separators - while (j < len && isPathSeparator(path.charCodeAt(j))) { - j++; - } - if (j < len && j !== last) { - // Matched! - last = j; - // Match 1 or more non-path separators - while (j < len && !isPathSeparator(path.charCodeAt(j))) { - j++; - } - if (j === len || j !== last) { - // We matched a UNC root - device = `\\\\${firstPart}\\${path.slice(last, j)}`; - rootEnd = j; - } - } - } - } else { - rootEnd = 1; - } - } else if (isWindowsDeviceRoot(code) && - path.charCodeAt(1) === CHAR_COLON) { - // Possible device root - device = path.slice(0, 2); - rootEnd = 2; - if (len > 2 && isPathSeparator(path.charCodeAt(2))) { - // Treat separator following drive name as an absolute path - // indicator - isAbsolute = true; - rootEnd = 3; - } - } - - if (device.length > 0) { - if (resolvedDevice.length > 0) { - if (device.toLowerCase() !== resolvedDevice.toLowerCase()) { - // This path points to another device so it is not applicable - continue; - } - } else { - resolvedDevice = device; - } - } - - if (resolvedAbsolute) { - if (resolvedDevice.length > 0) { - break; - } - } else { - resolvedTail = `${path.slice(rootEnd)}\\${resolvedTail}`; - resolvedAbsolute = isAbsolute; - if (isAbsolute && resolvedDevice.length > 0) { - break; - } - } - } - - // At this point the path should be resolved to a full absolute path, - // but handle relative paths to be safe (might happen when process.cwd() - // fails) - - // Normalize the tail path - resolvedTail = normalizeString(resolvedTail, !resolvedAbsolute, '\\', - isPathSeparator); - - return resolvedAbsolute ? - `${resolvedDevice}\\${resolvedTail}` : - `${resolvedDevice}${resolvedTail}` || '.'; - }, - - normalize(path: string): string { - validateString(path, 'path'); - const len = path.length; - if (len === 0) { - return '.'; - } - let rootEnd = 0; - let device; - let isAbsolute = false; - const code = path.charCodeAt(0); - - // Try to match a root - if (len === 1) { - // `path` contains just a single char, exit early to avoid - // unnecessary work - return isPosixPathSeparator(code) ? '\\' : path; - } - if (isPathSeparator(code)) { - // Possible UNC root - - // If we started with a separator, we know we at least have an absolute - // path of some kind (UNC or otherwise) - isAbsolute = true; - - if (isPathSeparator(path.charCodeAt(1))) { - // Matched double path separator at beginning - let j = 2; - let last = j; - // Match 1 or more non-path separators - while (j < len && !isPathSeparator(path.charCodeAt(j))) { - j++; - } - if (j < len && j !== last) { - const firstPart = path.slice(last, j); - // Matched! - last = j; - // Match 1 or more path separators - while (j < len && isPathSeparator(path.charCodeAt(j))) { - j++; - } - if (j < len && j !== last) { - // Matched! - last = j; - // Match 1 or more non-path separators - while (j < len && !isPathSeparator(path.charCodeAt(j))) { - j++; - } - if (j === len) { - // We matched a UNC root only - // Return the normalized version of the UNC root since there - // is nothing left to process - return `\\\\${firstPart}\\${path.slice(last)}\\`; - } - if (j !== last) { - // We matched a UNC root with leftovers - device = `\\\\${firstPart}\\${path.slice(last, j)}`; - rootEnd = j; - } - } - } - } else { - rootEnd = 1; - } - } else if (isWindowsDeviceRoot(code) && path.charCodeAt(1) === CHAR_COLON) { - // Possible device root - device = path.slice(0, 2); - rootEnd = 2; - if (len > 2 && isPathSeparator(path.charCodeAt(2))) { - // Treat separator following drive name as an absolute path - // indicator - isAbsolute = true; - rootEnd = 3; - } - } - - let tail = rootEnd < len ? - normalizeString(path.slice(rootEnd), !isAbsolute, '\\', isPathSeparator) : - ''; - if (tail.length === 0 && !isAbsolute) { - tail = '.'; - } - if (tail.length > 0 && isPathSeparator(path.charCodeAt(len - 1))) { - tail += '\\'; - } - if (device === undefined) { - return isAbsolute ? `\\${tail}` : tail; - } - return isAbsolute ? `${device}\\${tail}` : `${device}${tail}`; - }, - - isAbsolute(path: string): boolean { - validateString(path, 'path'); - const len = path.length; - if (len === 0) { - return false; - } - - const code = path.charCodeAt(0); - return isPathSeparator(code) || - // Possible device root - (len > 2 && - isWindowsDeviceRoot(code) && - path.charCodeAt(1) === CHAR_COLON && - isPathSeparator(path.charCodeAt(2))); - }, - - join(...paths: string[]): string { - if (paths.length === 0) { - return '.'; - } - - let joined; - let firstPart: string | undefined; - for (let i = 0; i < paths.length; ++i) { - const arg = paths[i]; - validateString(arg, 'path'); - if (arg.length > 0) { - if (joined === undefined) { - joined = firstPart = arg; - } - else { - joined += `\\${arg}`; - } - } - } - - if (joined === undefined) { - return '.'; - } - - // Make sure that the joined path doesn't start with two slashes, because - // normalize() will mistake it for a UNC path then. - // - // This step is skipped when it is very clear that the user actually - // intended to point at a UNC path. This is assumed when the first - // non-empty string arguments starts with exactly two slashes followed by - // at least one more non-slash character. - // - // Note that for normalize() to treat a path as a UNC path it needs to - // have at least 2 components, so we don't filter for that here. - // This means that the user can use join to construct UNC paths from - // a server name and a share name; for example: - // path.join('//server', 'share') -> '\\\\server\\share\\') - let needsReplace = true; - let slashCount = 0; - if (typeof firstPart === 'string' && isPathSeparator(firstPart.charCodeAt(0))) { - ++slashCount; - const firstLen = firstPart.length; - if (firstLen > 1 && isPathSeparator(firstPart.charCodeAt(1))) { - ++slashCount; - if (firstLen > 2) { - if (isPathSeparator(firstPart.charCodeAt(2))) { - ++slashCount; - } else { - // We matched a UNC path in the first part - needsReplace = false; - } - } - } - } - if (needsReplace) { - // Find any more consecutive slashes we need to replace - while (slashCount < joined.length && - isPathSeparator(joined.charCodeAt(slashCount))) { - slashCount++; - } - - // Replace the slashes if needed - if (slashCount >= 2) { - joined = `\\${joined.slice(slashCount)}`; - } - } - - return win32.normalize(joined); - }, - - - // It will solve the relative path from `from` to `to`, for instance: - // from = 'C:\\orandea\\test\\aaa' - // to = 'C:\\orandea\\impl\\bbb' - // The output of the function should be: '..\\..\\impl\\bbb' - relative(from: string, to: string): string { - validateString(from, 'from'); - validateString(to, 'to'); - - if (from === to) { - return ''; - } - - const fromOrig = win32.resolve(from); - const toOrig = win32.resolve(to); - - if (fromOrig === toOrig) { - return ''; - } - - from = fromOrig.toLowerCase(); - to = toOrig.toLowerCase(); - - if (from === to) { - return ''; - } - - // Trim any leading backslashes - let fromStart = 0; - while (fromStart < from.length && - from.charCodeAt(fromStart) === CHAR_BACKWARD_SLASH) { - fromStart++; - } - // Trim trailing backslashes (applicable to UNC paths only) - let fromEnd = from.length; - while (fromEnd - 1 > fromStart && - from.charCodeAt(fromEnd - 1) === CHAR_BACKWARD_SLASH) { - fromEnd--; - } - const fromLen = fromEnd - fromStart; - - // Trim any leading backslashes - let toStart = 0; - while (toStart < to.length && - to.charCodeAt(toStart) === CHAR_BACKWARD_SLASH) { - toStart++; - } - // Trim trailing backslashes (applicable to UNC paths only) - let toEnd = to.length; - while (toEnd - 1 > toStart && - to.charCodeAt(toEnd - 1) === CHAR_BACKWARD_SLASH) { - toEnd--; - } - const toLen = toEnd - toStart; - - // Compare paths to find the longest common path from root - const length = fromLen < toLen ? fromLen : toLen; - let lastCommonSep = -1; - let i = 0; - for (; i < length; i++) { - const fromCode = from.charCodeAt(fromStart + i); - if (fromCode !== to.charCodeAt(toStart + i)) { - break; - } else if (fromCode === CHAR_BACKWARD_SLASH) { - lastCommonSep = i; - } - } - - // We found a mismatch before the first common path separator was seen, so - // return the original `to`. - if (i !== length) { - if (lastCommonSep === -1) { - return toOrig; - } - } else { - if (toLen > length) { - if (to.charCodeAt(toStart + i) === CHAR_BACKWARD_SLASH) { - // We get here if `from` is the exact base path for `to`. - // For example: from='C:\\foo\\bar'; to='C:\\foo\\bar\\baz' - return toOrig.slice(toStart + i + 1); - } - if (i === 2) { - // We get here if `from` is the device root. - // For example: from='C:\\'; to='C:\\foo' - return toOrig.slice(toStart + i); - } - } - if (fromLen > length) { - if (from.charCodeAt(fromStart + i) === CHAR_BACKWARD_SLASH) { - // We get here if `to` is the exact base path for `from`. - // For example: from='C:\\foo\\bar'; to='C:\\foo' - lastCommonSep = i; - } else if (i === 2) { - // We get here if `to` is the device root. - // For example: from='C:\\foo\\bar'; to='C:\\' - lastCommonSep = 3; - } - } - if (lastCommonSep === -1) { - lastCommonSep = 0; - } - } - - let out = ''; - // Generate the relative path based on the path difference between `to` and - // `from` - for (i = fromStart + lastCommonSep + 1; i <= fromEnd; ++i) { - if (i === fromEnd || from.charCodeAt(i) === CHAR_BACKWARD_SLASH) { - out += out.length === 0 ? '..' : '\\..'; - } - } - - toStart += lastCommonSep; - - // Lastly, append the rest of the destination (`to`) path that comes after - // the common path parts - if (out.length > 0) { - return `${out}${toOrig.slice(toStart, toEnd)}`; - } - - if (toOrig.charCodeAt(toStart) === CHAR_BACKWARD_SLASH) { - ++toStart; - } - - return toOrig.slice(toStart, toEnd); - }, - - toNamespacedPath(path: string): string { - // Note: this will *probably* throw somewhere. - if (typeof path !== 'string' || path.length === 0) { - return path; - } - - const resolvedPath = win32.resolve(path); - - if (resolvedPath.length <= 2) { - return path; - } - - if (resolvedPath.charCodeAt(0) === CHAR_BACKWARD_SLASH) { - // Possible UNC root - if (resolvedPath.charCodeAt(1) === CHAR_BACKWARD_SLASH) { - const code = resolvedPath.charCodeAt(2); - if (code !== CHAR_QUESTION_MARK && code !== CHAR_DOT) { - // Matched non-long UNC root, convert the path to a long UNC path - return `\\\\?\\UNC\\${resolvedPath.slice(2)}`; - } - } - } else if (isWindowsDeviceRoot(resolvedPath.charCodeAt(0)) && - resolvedPath.charCodeAt(1) === CHAR_COLON && - resolvedPath.charCodeAt(2) === CHAR_BACKWARD_SLASH) { - // Matched device root, convert the path to a long UNC path - return `\\\\?\\${resolvedPath}`; - } - - return path; - }, - - dirname(path: string): string { - validateString(path, 'path'); - const len = path.length; - if (len === 0) { - return '.'; - } - let rootEnd = -1; - let offset = 0; - const code = path.charCodeAt(0); - - if (len === 1) { - // `path` contains just a path separator, exit early to avoid - // unnecessary work or a dot. - return isPathSeparator(code) ? path : '.'; - } - - // Try to match a root - if (isPathSeparator(code)) { - // Possible UNC root - - rootEnd = offset = 1; - - if (isPathSeparator(path.charCodeAt(1))) { - // Matched double path separator at beginning - let j = 2; - let last = j; - // Match 1 or more non-path separators - while (j < len && !isPathSeparator(path.charCodeAt(j))) { - j++; - } - if (j < len && j !== last) { - // Matched! - last = j; - // Match 1 or more path separators - while (j < len && isPathSeparator(path.charCodeAt(j))) { - j++; - } - if (j < len && j !== last) { - // Matched! - last = j; - // Match 1 or more non-path separators - while (j < len && !isPathSeparator(path.charCodeAt(j))) { - j++; - } - if (j === len) { - // We matched a UNC root only - return path; - } - if (j !== last) { - // We matched a UNC root with leftovers - - // Offset by 1 to include the separator after the UNC root to - // treat it as a "normal root" on top of a (UNC) root - rootEnd = offset = j + 1; - } - } - } - } - // Possible device root - } else if (isWindowsDeviceRoot(code) && path.charCodeAt(1) === CHAR_COLON) { - rootEnd = len > 2 && isPathSeparator(path.charCodeAt(2)) ? 3 : 2; - offset = rootEnd; - } - - let end = -1; - let matchedSlash = true; - for (let i = len - 1; i >= offset; --i) { - if (isPathSeparator(path.charCodeAt(i))) { - if (!matchedSlash) { - end = i; - break; - } - } else { - // We saw the first non-path separator - matchedSlash = false; - } - } - - if (end === -1) { - if (rootEnd === -1) { - return '.'; - } - - end = rootEnd; - } - return path.slice(0, end); - }, - - basename(path: string, suffix?: string): string { - if (suffix !== undefined) { - validateString(suffix, 'suffix'); - } - validateString(path, 'path'); - let start = 0; - let end = -1; - let matchedSlash = true; - let i; - - // Check for a drive letter prefix so as not to mistake the following - // path separator as an extra separator at the end of the path that can be - // disregarded - if (path.length >= 2 && - isWindowsDeviceRoot(path.charCodeAt(0)) && - path.charCodeAt(1) === CHAR_COLON) { - start = 2; - } - - if (suffix !== undefined && suffix.length > 0 && suffix.length <= path.length) { - if (suffix === path) { - return ''; - } - let extIdx = suffix.length - 1; - let firstNonSlashEnd = -1; - for (i = path.length - 1; i >= start; --i) { - const code = path.charCodeAt(i); - if (isPathSeparator(code)) { - // If we reached a path separator that was not part of a set of path - // separators at the end of the string, stop now - if (!matchedSlash) { - start = i + 1; - break; - } - } else { - if (firstNonSlashEnd === -1) { - // We saw the first non-path separator, remember this index in case - // we need it if the extension ends up not matching - matchedSlash = false; - firstNonSlashEnd = i + 1; - } - if (extIdx >= 0) { - // Try to match the explicit extension - if (code === suffix.charCodeAt(extIdx)) { - if (--extIdx === -1) { - // We matched the extension, so mark this as the end of our path - // component - end = i; - } - } else { - // Extension does not match, so our result is the entire path - // component - extIdx = -1; - end = firstNonSlashEnd; - } - } - } - } - - if (start === end) { - end = firstNonSlashEnd; - } else if (end === -1) { - end = path.length; - } - return path.slice(start, end); - } - for (i = path.length - 1; i >= start; --i) { - if (isPathSeparator(path.charCodeAt(i))) { - // If we reached a path separator that was not part of a set of path - // separators at the end of the string, stop now - if (!matchedSlash) { - start = i + 1; - break; - } - } else if (end === -1) { - // We saw the first non-path separator, mark this as the end of our - // path component - matchedSlash = false; - end = i + 1; - } - } - - if (end === -1) { - return ''; - } - return path.slice(start, end); - }, - - extname(path: string): string { - validateString(path, 'path'); - let start = 0; - let startDot = -1; - let startPart = 0; - let end = -1; - let matchedSlash = true; - // Track the state of characters (if any) we see before our first dot and - // after any path separator we find - let preDotState = 0; - - // Check for a drive letter prefix so as not to mistake the following - // path separator as an extra separator at the end of the path that can be - // disregarded - - if (path.length >= 2 && - path.charCodeAt(1) === CHAR_COLON && - isWindowsDeviceRoot(path.charCodeAt(0))) { - start = startPart = 2; - } - - for (let i = path.length - 1; i >= start; --i) { - const code = path.charCodeAt(i); - if (isPathSeparator(code)) { - // If we reached a path separator that was not part of a set of path - // separators at the end of the string, stop now - if (!matchedSlash) { - startPart = i + 1; - break; - } - continue; - } - if (end === -1) { - // We saw the first non-path separator, mark this as the end of our - // extension - matchedSlash = false; - end = i + 1; - } - if (code === CHAR_DOT) { - // If this is our first dot, mark it as the start of our extension - if (startDot === -1) { - startDot = i; - } - else if (preDotState !== 1) { - preDotState = 1; - } - } else if (startDot !== -1) { - // We saw a non-dot and non-path separator before our dot, so we should - // have a good chance at having a non-empty extension - preDotState = -1; - } - } - - if (startDot === -1 || - end === -1 || - // We saw a non-dot character immediately before the dot - preDotState === 0 || - // The (right-most) trimmed path component is exactly '..' - (preDotState === 1 && - startDot === end - 1 && - startDot === startPart + 1)) { - return ''; - } - return path.slice(startDot, end); - }, - - format: _format.bind(null, '\\'), - - parse(path) { - validateString(path, 'path'); - - const ret = { root: '', dir: '', base: '', ext: '', name: '' }; - if (path.length === 0) { - return ret; - } - - const len = path.length; - let rootEnd = 0; - let code = path.charCodeAt(0); - - if (len === 1) { - if (isPathSeparator(code)) { - // `path` contains just a path separator, exit early to avoid - // unnecessary work - ret.root = ret.dir = path; - return ret; - } - ret.base = ret.name = path; - return ret; - } - // Try to match a root - if (isPathSeparator(code)) { - // Possible UNC root - - rootEnd = 1; - if (isPathSeparator(path.charCodeAt(1))) { - // Matched double path separator at beginning - let j = 2; - let last = j; - // Match 1 or more non-path separators - while (j < len && !isPathSeparator(path.charCodeAt(j))) { - j++; - } - if (j < len && j !== last) { - // Matched! - last = j; - // Match 1 or more path separators - while (j < len && isPathSeparator(path.charCodeAt(j))) { - j++; - } - if (j < len && j !== last) { - // Matched! - last = j; - // Match 1 or more non-path separators - while (j < len && !isPathSeparator(path.charCodeAt(j))) { - j++; - } - if (j === len) { - // We matched a UNC root only - rootEnd = j; - } else if (j !== last) { - // We matched a UNC root with leftovers - rootEnd = j + 1; - } - } - } - } - } else if (isWindowsDeviceRoot(code) && path.charCodeAt(1) === CHAR_COLON) { - // Possible device root - if (len <= 2) { - // `path` contains just a drive root, exit early to avoid - // unnecessary work - ret.root = ret.dir = path; - return ret; - } - rootEnd = 2; - if (isPathSeparator(path.charCodeAt(2))) { - if (len === 3) { - // `path` contains just a drive root, exit early to avoid - // unnecessary work - ret.root = ret.dir = path; - return ret; - } - rootEnd = 3; - } - } - if (rootEnd > 0) { - ret.root = path.slice(0, rootEnd); - } - - let startDot = -1; - let startPart = rootEnd; - let end = -1; - let matchedSlash = true; - let i = path.length - 1; - - // Track the state of characters (if any) we see before our first dot and - // after any path separator we find - let preDotState = 0; - - // Get non-dir info - for (; i >= rootEnd; --i) { - code = path.charCodeAt(i); - if (isPathSeparator(code)) { - // If we reached a path separator that was not part of a set of path - // separators at the end of the string, stop now - if (!matchedSlash) { - startPart = i + 1; - break; - } - continue; - } - if (end === -1) { - // We saw the first non-path separator, mark this as the end of our - // extension - matchedSlash = false; - end = i + 1; - } - if (code === CHAR_DOT) { - // If this is our first dot, mark it as the start of our extension - if (startDot === -1) { - startDot = i; - } else if (preDotState !== 1) { - preDotState = 1; - } - } else if (startDot !== -1) { - // We saw a non-dot and non-path separator before our dot, so we should - // have a good chance at having a non-empty extension - preDotState = -1; - } - } - - if (end !== -1) { - if (startDot === -1 || - // We saw a non-dot character immediately before the dot - preDotState === 0 || - // The (right-most) trimmed path component is exactly '..' - (preDotState === 1 && - startDot === end - 1 && - startDot === startPart + 1)) { - ret.base = ret.name = path.slice(startPart, end); - } else { - ret.name = path.slice(startPart, startDot); - ret.base = path.slice(startPart, end); - ret.ext = path.slice(startDot, end); - } - } - - // If the directory is the root, use the entire root as the `dir` including - // the trailing slash if any (`C:\abc` -> `C:\`). Otherwise, strip out the - // trailing slash (`C:\abc\def` -> `C:\abc`). - if (startPart > 0 && startPart !== rootEnd) { - ret.dir = path.slice(0, startPart - 1); - } else { - ret.dir = ret.root; - } - - return ret; - }, - - sep: '\\', - delimiter: ';', - win32: null, - posix: null -}; - -const posixCwd = (() => { - if (platformIsWin32) { - // Converts Windows' backslash path separators to POSIX forward slashes - // and truncates any drive indicator - const regexp = /\\/g; - return () => { - const cwd = process.cwd().replace(regexp, '/'); - return cwd.slice(cwd.indexOf('/')); - }; - } - - // We're already on POSIX, no need for any transformations - return () => process.cwd(); -})(); - -export const posix: IPath = { - // path.resolve([from ...], to) - resolve(...pathSegments: string[]): string { - let resolvedPath = ''; - let resolvedAbsolute = false; - - for (let i = pathSegments.length - 1; i >= -1 && !resolvedAbsolute; i--) { - const path = i >= 0 ? pathSegments[i] : posixCwd(); - - validateString(path, `paths[${i}]`); - - // Skip empty entries - if (path.length === 0) { - continue; - } - - resolvedPath = `${path}/${resolvedPath}`; - resolvedAbsolute = path.charCodeAt(0) === CHAR_FORWARD_SLASH; - } - - // At this point the path should be resolved to a full absolute path, but - // handle relative paths to be safe (might happen when process.cwd() fails) - - // Normalize the path - resolvedPath = normalizeString(resolvedPath, !resolvedAbsolute, '/', - isPosixPathSeparator); - - if (resolvedAbsolute) { - return `/${resolvedPath}`; - } - return resolvedPath.length > 0 ? resolvedPath : '.'; - }, - - normalize(path: string): string { - validateString(path, 'path'); - - if (path.length === 0) { - return '.'; - } - - const isAbsolute = path.charCodeAt(0) === CHAR_FORWARD_SLASH; - const trailingSeparator = - path.charCodeAt(path.length - 1) === CHAR_FORWARD_SLASH; - - // Normalize the path - path = normalizeString(path, !isAbsolute, '/', isPosixPathSeparator); - - if (path.length === 0) { - if (isAbsolute) { - return '/'; - } - return trailingSeparator ? './' : '.'; - } - if (trailingSeparator) { - path += '/'; - } - - return isAbsolute ? `/${path}` : path; - }, - - isAbsolute(path: string): boolean { - validateString(path, 'path'); - return path.length > 0 && path.charCodeAt(0) === CHAR_FORWARD_SLASH; - }, - - join(...paths: string[]): string { - if (paths.length === 0) { - return '.'; - } - let joined; - for (let i = 0; i < paths.length; ++i) { - const arg = paths[i]; - validateString(arg, 'path'); - if (arg.length > 0) { - if (joined === undefined) { - joined = arg; - } else { - joined += `/${arg}`; - } - } - } - if (joined === undefined) { - return '.'; - } - return posix.normalize(joined); - }, - - relative(from: string, to: string): string { - validateString(from, 'from'); - validateString(to, 'to'); - - if (from === to) { - return ''; - } - - // Trim leading forward slashes. - from = posix.resolve(from); - to = posix.resolve(to); - - if (from === to) { - return ''; - } - - const fromStart = 1; - const fromEnd = from.length; - const fromLen = fromEnd - fromStart; - const toStart = 1; - const toLen = to.length - toStart; - - // Compare paths to find the longest common path from root - const length = (fromLen < toLen ? fromLen : toLen); - let lastCommonSep = -1; - let i = 0; - for (; i < length; i++) { - const fromCode = from.charCodeAt(fromStart + i); - if (fromCode !== to.charCodeAt(toStart + i)) { - break; - } else if (fromCode === CHAR_FORWARD_SLASH) { - lastCommonSep = i; - } - } - if (i === length) { - if (toLen > length) { - if (to.charCodeAt(toStart + i) === CHAR_FORWARD_SLASH) { - // We get here if `from` is the exact base path for `to`. - // For example: from='/foo/bar'; to='/foo/bar/baz' - return to.slice(toStart + i + 1); - } - if (i === 0) { - // We get here if `from` is the root - // For example: from='/'; to='/foo' - return to.slice(toStart + i); - } - } else if (fromLen > length) { - if (from.charCodeAt(fromStart + i) === CHAR_FORWARD_SLASH) { - // We get here if `to` is the exact base path for `from`. - // For example: from='/foo/bar/baz'; to='/foo/bar' - lastCommonSep = i; - } else if (i === 0) { - // We get here if `to` is the root. - // For example: from='/foo/bar'; to='/' - lastCommonSep = 0; - } - } - } - - let out = ''; - // Generate the relative path based on the path difference between `to` - // and `from`. - for (i = fromStart + lastCommonSep + 1; i <= fromEnd; ++i) { - if (i === fromEnd || from.charCodeAt(i) === CHAR_FORWARD_SLASH) { - out += out.length === 0 ? '..' : '/..'; - } - } - - // Lastly, append the rest of the destination (`to`) path that comes after - // the common path parts. - return `${out}${to.slice(toStart + lastCommonSep)}`; - }, - - toNamespacedPath(path: string): string { - // Non-op on posix systems - return path; - }, - - dirname(path: string): string { - validateString(path, 'path'); - if (path.length === 0) { - return '.'; - } - const hasRoot = path.charCodeAt(0) === CHAR_FORWARD_SLASH; - let end = -1; - let matchedSlash = true; - for (let i = path.length - 1; i >= 1; --i) { - if (path.charCodeAt(i) === CHAR_FORWARD_SLASH) { - if (!matchedSlash) { - end = i; - break; - } - } else { - // We saw the first non-path separator - matchedSlash = false; - } - } - - if (end === -1) { - return hasRoot ? '/' : '.'; - } - if (hasRoot && end === 1) { - return '//'; - } - return path.slice(0, end); - }, - - basename(path: string, suffix?: string): string { - if (suffix !== undefined) { - validateString(suffix, 'ext'); - } - validateString(path, 'path'); - - let start = 0; - let end = -1; - let matchedSlash = true; - let i; - - if (suffix !== undefined && suffix.length > 0 && suffix.length <= path.length) { - if (suffix === path) { - return ''; - } - let extIdx = suffix.length - 1; - let firstNonSlashEnd = -1; - for (i = path.length - 1; i >= 0; --i) { - const code = path.charCodeAt(i); - if (code === CHAR_FORWARD_SLASH) { - // If we reached a path separator that was not part of a set of path - // separators at the end of the string, stop now - if (!matchedSlash) { - start = i + 1; - break; - } - } else { - if (firstNonSlashEnd === -1) { - // We saw the first non-path separator, remember this index in case - // we need it if the extension ends up not matching - matchedSlash = false; - firstNonSlashEnd = i + 1; - } - if (extIdx >= 0) { - // Try to match the explicit extension - if (code === suffix.charCodeAt(extIdx)) { - if (--extIdx === -1) { - // We matched the extension, so mark this as the end of our path - // component - end = i; - } - } else { - // Extension does not match, so our result is the entire path - // component - extIdx = -1; - end = firstNonSlashEnd; - } - } - } - } - - if (start === end) { - end = firstNonSlashEnd; - } else if (end === -1) { - end = path.length; - } - return path.slice(start, end); - } - for (i = path.length - 1; i >= 0; --i) { - if (path.charCodeAt(i) === CHAR_FORWARD_SLASH) { - // If we reached a path separator that was not part of a set of path - // separators at the end of the string, stop now - if (!matchedSlash) { - start = i + 1; - break; - } - } else if (end === -1) { - // We saw the first non-path separator, mark this as the end of our - // path component - matchedSlash = false; - end = i + 1; - } - } - - if (end === -1) { - return ''; - } - return path.slice(start, end); - }, - - extname(path: string): string { - validateString(path, 'path'); - let startDot = -1; - let startPart = 0; - let end = -1; - let matchedSlash = true; - // Track the state of characters (if any) we see before our first dot and - // after any path separator we find - let preDotState = 0; - for (let i = path.length - 1; i >= 0; --i) { - const code = path.charCodeAt(i); - if (code === CHAR_FORWARD_SLASH) { - // If we reached a path separator that was not part of a set of path - // separators at the end of the string, stop now - if (!matchedSlash) { - startPart = i + 1; - break; - } - continue; - } - if (end === -1) { - // We saw the first non-path separator, mark this as the end of our - // extension - matchedSlash = false; - end = i + 1; - } - if (code === CHAR_DOT) { - // If this is our first dot, mark it as the start of our extension - if (startDot === -1) { - startDot = i; - } - else if (preDotState !== 1) { - preDotState = 1; - } - } else if (startDot !== -1) { - // We saw a non-dot and non-path separator before our dot, so we should - // have a good chance at having a non-empty extension - preDotState = -1; - } - } - - if (startDot === -1 || - end === -1 || - // We saw a non-dot character immediately before the dot - preDotState === 0 || - // The (right-most) trimmed path component is exactly '..' - (preDotState === 1 && - startDot === end - 1 && - startDot === startPart + 1)) { - return ''; - } - return path.slice(startDot, end); - }, - - format: _format.bind(null, '/'), - - parse(path: string): ParsedPath { - validateString(path, 'path'); - - const ret = { root: '', dir: '', base: '', ext: '', name: '' }; - if (path.length === 0) { - return ret; - } - const isAbsolute = path.charCodeAt(0) === CHAR_FORWARD_SLASH; - let start; - if (isAbsolute) { - ret.root = '/'; - start = 1; - } else { - start = 0; - } - let startDot = -1; - let startPart = 0; - let end = -1; - let matchedSlash = true; - let i = path.length - 1; - - // Track the state of characters (if any) we see before our first dot and - // after any path separator we find - let preDotState = 0; - - // Get non-dir info - for (; i >= start; --i) { - const code = path.charCodeAt(i); - if (code === CHAR_FORWARD_SLASH) { - // If we reached a path separator that was not part of a set of path - // separators at the end of the string, stop now - if (!matchedSlash) { - startPart = i + 1; - break; - } - continue; - } - if (end === -1) { - // We saw the first non-path separator, mark this as the end of our - // extension - matchedSlash = false; - end = i + 1; - } - if (code === CHAR_DOT) { - // If this is our first dot, mark it as the start of our extension - if (startDot === -1) { - startDot = i; - } else if (preDotState !== 1) { - preDotState = 1; - } - } else if (startDot !== -1) { - // We saw a non-dot and non-path separator before our dot, so we should - // have a good chance at having a non-empty extension - preDotState = -1; - } - } - - if (end !== -1) { - const start = startPart === 0 && isAbsolute ? 1 : startPart; - if (startDot === -1 || - // We saw a non-dot character immediately before the dot - preDotState === 0 || - // The (right-most) trimmed path component is exactly '..' - (preDotState === 1 && - startDot === end - 1 && - startDot === startPart + 1)) { - ret.base = ret.name = path.slice(start, end); - } else { - ret.name = path.slice(start, startDot); - ret.base = path.slice(start, end); - ret.ext = path.slice(startDot, end); - } - } - - if (startPart > 0) { - ret.dir = path.slice(0, startPart - 1); - } else if (isAbsolute) { - ret.dir = '/'; - } - - return ret; - }, - - sep: '/', - delimiter: ':', - win32: null, - posix: null -}; - -posix.win32 = win32.win32 = win32; -posix.posix = win32.posix = posix; - -export const normalize = (platformIsWin32 ? win32.normalize : posix.normalize); -export const isAbsolute = (platformIsWin32 ? win32.isAbsolute : posix.isAbsolute); -export const join = (platformIsWin32 ? win32.join : posix.join); -export const resolve = (platformIsWin32 ? win32.resolve : posix.resolve); -export const relative = (platformIsWin32 ? win32.relative : posix.relative); -export const dirname = (platformIsWin32 ? win32.dirname : posix.dirname); -export const basename = (platformIsWin32 ? win32.basename : posix.basename); -export const extname = (platformIsWin32 ? win32.extname : posix.extname); -export const format = (platformIsWin32 ? win32.format : posix.format); -export const parse = (platformIsWin32 ? win32.parse : posix.parse); -export const toNamespacedPath = (platformIsWin32 ? win32.toNamespacedPath : posix.toNamespacedPath); -export const sep = (platformIsWin32 ? win32.sep : posix.sep); -export const delimiter = (platformIsWin32 ? win32.delimiter : posix.delimiter); diff --git a/src/vs/base/common/performance.d.ts b/src/vs/base/common/performance.d.ts deleted file mode 100644 index fc233e6f83..0000000000 --- a/src/vs/base/common/performance.d.ts +++ /dev/null @@ -1,16 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -export interface PerformanceMark { - readonly name: string; - readonly startTime: number; -} - -export function mark(name: string): void; - -/** - * Returns all marks, sorted by `startTime`. - */ -export function getMarks(): PerformanceMark[]; diff --git a/src/vs/base/common/performance.js b/src/vs/base/common/performance.js deleted file mode 100644 index cdea491777..0000000000 --- a/src/vs/base/common/performance.js +++ /dev/null @@ -1,135 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -//@ts-check -'use strict'; - -// ESM-uncomment-begin -// const module = { exports: {} }; -// ESM-uncomment-end - -(function () { - - /** - * @returns {{mark(name:string):void, getMarks():{name:string, startTime:number}[]}} - */ - function _definePolyfillMarks(timeOrigin) { - - const _data = []; - if (typeof timeOrigin === 'number') { - _data.push('code/timeOrigin', timeOrigin); - } - - function mark(name) { - _data.push(name, Date.now()); - } - function getMarks() { - const result = []; - for (let i = 0; i < _data.length; i += 2) { - result.push({ - name: _data[i], - startTime: _data[i + 1], - }); - } - return result; - } - return { mark, getMarks }; - } - - /** - * @returns {{mark(name:string):void, getMarks():{name:string, startTime:number}[]}} - */ - function _define() { - - // Identify browser environment when following property is not present - // https://nodejs.org/dist/latest-v16.x/docs/api/perf_hooks.html#performancenodetiming - // @ts-ignore - if (typeof performance === 'object' && typeof performance.mark === 'function' && !performance.nodeTiming) { - // in a browser context, reuse performance-util - - if (typeof performance.timeOrigin !== 'number' && !performance.timing) { - // safari & webworker: because there is no timeOrigin and no workaround - // we use the `Date.now`-based polyfill. - return _definePolyfillMarks(); - - } else { - // use "native" performance for mark and getMarks - return { - mark(name) { - performance.mark(name); - }, - getMarks() { - let timeOrigin = performance.timeOrigin; - if (typeof timeOrigin !== 'number') { - // safari: there is no timerOrigin but in renderers there is the timing-property - // see https://bugs.webkit.org/show_bug.cgi?id=174862 - timeOrigin = performance.timing.navigationStart || performance.timing.redirectStart || performance.timing.fetchStart; - } - const result = [{ name: 'code/timeOrigin', startTime: Math.round(timeOrigin) }]; - for (const entry of performance.getEntriesByType('mark')) { - result.push({ - name: entry.name, - startTime: Math.round(timeOrigin + entry.startTime) - }); - } - return result; - } - }; - } - - } else if (typeof process === 'object') { - // node.js: use the normal polyfill but add the timeOrigin - // from the node perf_hooks API as very first mark - const timeOrigin = performance?.timeOrigin ?? Math.round((require.__$__nodeRequire || require)('perf_hooks').performance.timeOrigin); - return _definePolyfillMarks(timeOrigin); - - } else { - // unknown environment - console.trace('perf-util loaded in UNKNOWN environment'); - return _definePolyfillMarks(); - } - } - - function _factory(sharedObj) { - if (!sharedObj.MonacoPerformanceMarks) { - sharedObj.MonacoPerformanceMarks = _define(); - } - return sharedObj.MonacoPerformanceMarks; - } - - // This module can be loaded in an amd and commonjs-context. - // Because we want both instances to use the same perf-data - // we store them globally - - // eslint-disable-next-line no-var - var sharedObj; - if (typeof global === 'object') { - // nodejs - sharedObj = global; - } else if (typeof self === 'object') { - // browser - sharedObj = self; - } else { - sharedObj = {}; - } - - if (typeof define === 'function') { - // amd - define([], function () { return _factory(sharedObj); }); - } else if (typeof module === 'object' && typeof module.exports === 'object') { - // commonjs - module.exports = _factory(sharedObj); - } else { - console.trace('perf-util defined in UNKNOWN context (neither requirejs or commonjs)'); - // @ts-ignore - sharedObj.perf = _factory(sharedObj); - } - -})(); - -// ESM-uncomment-begin -// export const mark = module.exports.mark; -// export const getMarks = module.exports.getMarks; -// ESM-uncomment-end diff --git a/src/vs/base/common/ports.ts b/src/vs/base/common/ports.ts deleted file mode 100644 index 5ec75530a8..0000000000 --- a/src/vs/base/common/ports.ts +++ /dev/null @@ -1,13 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -/** - * @returns Returns a random port between 1025 and 65535. - */ -export function randomPort(): number { - const min = 1025; - const max = 65535; - return min + Math.floor((max - min) * Math.random()); -} diff --git a/src/vs/base/common/prefixTree.ts b/src/vs/base/common/prefixTree.ts deleted file mode 100644 index 53f02964d3..0000000000 --- a/src/vs/base/common/prefixTree.ts +++ /dev/null @@ -1,252 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { Iterable } from 'vs/base/common/iterator'; - -const unset = Symbol('unset'); - -export interface IPrefixTreeNode { - /** Possible children of the node. */ - children?: ReadonlyMap>; - - /** The value if data exists for this node in the tree. Mutable. */ - value: T | undefined; -} - -/** - * A simple prefix tree implementation where a value is stored based on - * well-defined prefix segments. - */ -export class WellDefinedPrefixTree { - private readonly root = new Node(); - private _size = 0; - - public get size() { - return this._size; - } - - /** Gets the top-level nodes of the tree */ - public get nodes(): Iterable> { - return this.root.children?.values() || Iterable.empty(); - } - - /** Gets the top-level nodes of the tree */ - public get entries(): Iterable<[string, IPrefixTreeNode]> { - return this.root.children?.entries() || Iterable.empty(); - } - - /** - * Inserts a new value in the prefix tree. - * @param onNode - called for each node as we descend to the insertion point, - * including the insertion point itself. - */ - insert(key: Iterable, value: V, onNode?: (n: IPrefixTreeNode) => void): void { - this.opNode(key, n => n._value = value, onNode); - } - - /** Mutates a value in the prefix tree. */ - mutate(key: Iterable, mutate: (value?: V) => V): void { - this.opNode(key, n => n._value = mutate(n._value === unset ? undefined : n._value)); - } - - /** Mutates nodes along the path in the prefix tree. */ - mutatePath(key: Iterable, mutate: (node: IPrefixTreeNode) => void): void { - this.opNode(key, () => { }, n => mutate(n)); - } - - /** Deletes a node from the prefix tree, returning the value it contained. */ - delete(key: Iterable): V | undefined { - const path = this.getPathToKey(key); - if (!path) { - return; - } - - let i = path.length - 1; - const value = path[i].node._value; - if (value === unset) { - return; // not actually a real node - } - - this._size--; - path[i].node._value = unset; - - for (; i > 0; i--) { - const { node, part } = path[i]; - if (node.children?.size || node._value !== unset) { - break; - } - - path[i - 1].node.children!.delete(part); - } - - return value; - } - - /** Deletes a subtree from the prefix tree, returning the values they contained. */ - *deleteRecursive(key: Iterable): Iterable { - const path = this.getPathToKey(key); - if (!path) { - return; - } - - const subtree = path[path.length - 1].node; - - // important: run the deletion before we start to yield results, so that - // it still runs even if the caller doesn't consumer the iterator - for (let i = path.length - 1; i > 0; i--) { - const parent = path[i - 1]; - parent.node.children!.delete(path[i].part); - if (parent.node.children!.size > 0 || parent.node._value !== unset) { - break; - } - } - - for (const node of bfsIterate(subtree)) { - if (node._value !== unset) { - this._size--; - yield node._value; - } - } - } - - /** Gets a value from the tree. */ - find(key: Iterable): V | undefined { - let node = this.root; - for (const segment of key) { - const next = node.children?.get(segment); - if (!next) { - return undefined; - } - - node = next; - } - - return node._value === unset ? undefined : node._value; - } - - /** Gets whether the tree has the key, or a parent of the key, already inserted. */ - hasKeyOrParent(key: Iterable): boolean { - let node = this.root; - for (const segment of key) { - const next = node.children?.get(segment); - if (!next) { - return false; - } - if (next._value !== unset) { - return true; - } - - node = next; - } - - return false; - } - - /** Gets whether the tree has the given key or any children. */ - hasKeyOrChildren(key: Iterable): boolean { - let node = this.root; - for (const segment of key) { - const next = node.children?.get(segment); - if (!next) { - return false; - } - - node = next; - } - - return true; - } - - /** Gets whether the tree has the given key. */ - hasKey(key: Iterable): boolean { - let node = this.root; - for (const segment of key) { - const next = node.children?.get(segment); - if (!next) { - return false; - } - - node = next; - } - - return node._value !== unset; - } - - private getPathToKey(key: Iterable) { - const path = [{ part: '', node: this.root }]; - let i = 0; - for (const part of key) { - const node = path[i].node.children?.get(part); - if (!node) { - return; // node not in tree - } - - path.push({ part, node }); - i++; - } - - return path; - } - - private opNode(key: Iterable, fn: (node: Node) => void, onDescend?: (node: Node) => void): void { - let node = this.root; - for (const part of key) { - if (!node.children) { - const next = new Node(); - node.children = new Map([[part, next]]); - node = next; - } else if (!node.children.has(part)) { - const next = new Node(); - node.children.set(part, next); - node = next; - } else { - node = node.children.get(part)!; - } - onDescend?.(node); - } - - const sizeBefore = node._value === unset ? 0 : 1; - fn(node); - const sizeAfter = node._value === unset ? 0 : 1; - this._size += sizeAfter - sizeBefore; - } - - /** Returns an iterable of the tree values in no defined order. */ - *values() { - for (const { _value } of bfsIterate(this.root)) { - if (_value !== unset) { - yield _value; - } - } - } -} - -function* bfsIterate(root: Node): Iterable> { - const stack = [root]; - while (stack.length > 0) { - const node = stack.pop()!; - yield node; - - if (node.children) { - for (const child of node.children.values()) { - stack.push(child); - } - } - } -} - -class Node implements IPrefixTreeNode { - public children?: Map>; - - public get value() { - return this._value === unset ? undefined : this._value; - } - - public set value(value: T | undefined) { - this._value = value === undefined ? unset : value; - } - - public _value: T | typeof unset = unset; -} diff --git a/src/vs/base/common/process.ts b/src/vs/base/common/process.ts deleted file mode 100644 index 48fcd8acb4..0000000000 --- a/src/vs/base/common/process.ts +++ /dev/null @@ -1,76 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { INodeProcess, isMacintosh, isWindows } from 'vs/base/common/platform'; - -let safeProcess: Omit & { arch: string | undefined }; -declare const process: INodeProcess; - -// Native sandbox environment -const vscodeGlobal = (globalThis as any).vscode; -if (typeof vscodeGlobal !== 'undefined' && typeof vscodeGlobal.process !== 'undefined') { - const sandboxProcess: INodeProcess = vscodeGlobal.process; - safeProcess = { - get platform() { return sandboxProcess.platform; }, - get arch() { return sandboxProcess.arch; }, - get env() { return sandboxProcess.env; }, - cwd() { return sandboxProcess.cwd(); } - }; -} - -// Native node.js environment -else if (typeof process !== 'undefined') { - safeProcess = { - get platform() { return process.platform; }, - get arch() { return process.arch; }, - get env() { return process.env; }, - cwd() { return process.env['VSCODE_CWD'] || process.cwd(); } - }; -} - -// Web environment -else { - safeProcess = { - - // Supported - get platform() { return isWindows ? 'win32' : isMacintosh ? 'darwin' : 'linux'; }, - get arch() { return undefined; /* arch is undefined in web */ }, - - // Unsupported - get env() { return {}; }, - cwd() { return '/'; } - }; -} - -/** - * Provides safe access to the `cwd` property in node.js, sandboxed or web - * environments. - * - * Note: in web, this property is hardcoded to be `/`. - * - * @skipMangle - */ -export const cwd = safeProcess.cwd; - -/** - * Provides safe access to the `env` property in node.js, sandboxed or web - * environments. - * - * Note: in web, this property is hardcoded to be `{}`. - */ -export const env = safeProcess.env; - -/** - * Provides safe access to the `platform` property in node.js, sandboxed or web - * environments. - */ -export const platform = safeProcess.platform; - -/** - * Provides safe access to the `arch` method in node.js, sandboxed or web - * environments. - * Note: `arch` is `undefined` in web - */ -export const arch = safeProcess.arch; diff --git a/src/vs/base/common/processes.ts b/src/vs/base/common/processes.ts deleted file mode 100644 index ef29387bc0..0000000000 --- a/src/vs/base/common/processes.ts +++ /dev/null @@ -1,148 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { IProcessEnvironment, isLinux } from 'vs/base/common/platform'; - -/** - * Options to be passed to the external program or shell. - */ -export interface CommandOptions { - /** - * The current working directory of the executed program or shell. - * If omitted VSCode's current workspace root is used. - */ - cwd?: string; - - /** - * The environment of the executed program or shell. If omitted - * the parent process' environment is used. - */ - env?: { [key: string]: string }; -} - -export interface Executable { - /** - * The command to be executed. Can be an external program or a shell - * command. - */ - command: string; - - /** - * Specifies whether the command is a shell command and therefore must - * be executed in a shell interpreter (e.g. cmd.exe, bash, ...). - */ - isShellCommand: boolean; - - /** - * The arguments passed to the command. - */ - args: string[]; - - /** - * The command options used when the command is executed. Can be omitted. - */ - options?: CommandOptions; -} - -export interface ForkOptions extends CommandOptions { - execArgv?: string[]; -} - -export const enum Source { - stdout, - stderr -} - -/** - * The data send via a success callback - */ -export interface SuccessData { - error?: Error; - cmdCode?: number; - terminated?: boolean; -} - -/** - * The data send via a error callback - */ -export interface ErrorData { - error?: Error; - terminated?: boolean; - stdout?: string; - stderr?: string; -} - -export interface TerminateResponse { - success: boolean; - code?: TerminateResponseCode; - error?: any; -} - -export const enum TerminateResponseCode { - Success = 0, - Unknown = 1, - AccessDenied = 2, - ProcessNotFound = 3, -} - -export interface ProcessItem { - name: string; - cmd: string; - pid: number; - ppid: number; - load: number; - mem: number; - - children?: ProcessItem[]; -} - -/** - * Sanitizes a VS Code process environment by removing all Electron/VS Code-related values. - */ -export function sanitizeProcessEnvironment(env: IProcessEnvironment, ...preserve: string[]): void { - const set = preserve.reduce>((set, key) => { - set[key] = true; - return set; - }, {}); - const keysToRemove = [ - /^ELECTRON_.+$/, - /^VSCODE_(?!(PORTABLE|SHELL_LOGIN|ENV_REPLACE|ENV_APPEND|ENV_PREPEND)).+$/, - /^SNAP(|_.*)$/, - /^GDK_PIXBUF_.+$/, - ]; - const envKeys = Object.keys(env); - envKeys - .filter(key => !set[key]) - .forEach(envKey => { - for (let i = 0; i < keysToRemove.length; i++) { - if (envKey.search(keysToRemove[i]) !== -1) { - delete env[envKey]; - break; - } - } - }); -} - -/** - * Remove dangerous environment variables that have caused crashes - * in forked processes (i.e. in ELECTRON_RUN_AS_NODE processes) - * - * @param env The env object to change - */ -export function removeDangerousEnvVariables(env: IProcessEnvironment | undefined): void { - if (!env) { - return; - } - - // Unset `DEBUG`, as an invalid value might lead to process crashes - // See https://github.com/microsoft/vscode/issues/130072 - delete env['DEBUG']; - - if (isLinux) { - // Unset `LD_PRELOAD`, as it might lead to process crashes - // See https://github.com/microsoft/vscode/issues/134177 - delete env['LD_PRELOAD']; - } -} diff --git a/src/vs/base/common/product.ts b/src/vs/base/common/product.ts deleted file mode 100644 index 754eba4958..0000000000 --- a/src/vs/base/common/product.ts +++ /dev/null @@ -1,314 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { IStringDictionary } from 'vs/base/common/collections'; -import { PlatformName } from 'vs/base/common/platform'; - -export interface IBuiltInExtension { - readonly name: string; - readonly version: string; - readonly repo: string; - readonly metadata: any; -} - -export interface IProductWalkthrough { - id: string; - steps: IProductWalkthroughStep[]; -} - -export interface IProductWalkthroughStep { - id: string; - title: string; - when: string; - description: string; - media: - | { type: 'image'; path: string | { hc: string; hcLight?: string; light: string; dark: string }; altText: string } - | { type: 'svg'; path: string; altText: string } - | { type: 'markdown'; path: string }; -} - -export interface IFeaturedExtension { - readonly id: string; - readonly title: string; - readonly description: string; - readonly imagePath: string; -} - -export type ConfigurationSyncStore = { - url: string; - insidersUrl: string; - stableUrl: string; - canSwitch?: boolean; - authenticationProviders: IStringDictionary<{ scopes: string[] }>; -}; - -export type ExtensionUntrustedWorkspaceSupport = { - readonly default?: boolean | 'limited'; - readonly override?: boolean | 'limited'; -}; - -export type ExtensionVirtualWorkspaceSupport = { - readonly default?: boolean; - readonly override?: boolean; -}; - -export interface IProductConfiguration { - readonly version: string; - readonly date?: string; - readonly quality?: string; - readonly commit?: string; - - readonly nameShort: string; - readonly nameLong: string; - - readonly win32AppUserModelId?: string; - readonly win32MutexName?: string; - readonly win32RegValueName?: string; - readonly applicationName: string; - readonly embedderIdentifier?: string; - - readonly urlProtocol: string; - readonly dataFolderName: string; // location for extensions (e.g. ~/.vscode-insiders) - - readonly builtInExtensions?: IBuiltInExtension[]; - readonly walkthroughMetadata?: IProductWalkthrough[]; - readonly featuredExtensions?: IFeaturedExtension[]; - - readonly downloadUrl?: string; - readonly updateUrl?: string; - readonly webUrl?: string; - readonly webEndpointUrlTemplate?: string; - readonly webviewContentExternalBaseUrlTemplate?: string; - readonly target?: string; - readonly nlsCoreBaseUrl?: string; - - readonly settingsSearchBuildId?: number; - readonly settingsSearchUrl?: string; - - readonly tasConfig?: { - endpoint: string; - telemetryEventName: string; - assignmentContextTelemetryPropertyName: string; - }; - - readonly extensionsGallery?: { - readonly serviceUrl: string; - readonly servicePPEUrl?: string; - readonly searchUrl?: string; - readonly itemUrl: string; - readonly publisherUrl: string; - readonly resourceUrlTemplate: string; - readonly controlUrl: string; - readonly nlsBaseUrl: string; - }; - - readonly extensionRecommendations?: IStringDictionary; - readonly configBasedExtensionTips?: IStringDictionary; - readonly exeBasedExtensionTips?: IStringDictionary; - readonly remoteExtensionTips?: IStringDictionary; - readonly virtualWorkspaceExtensionTips?: IStringDictionary; - readonly extensionKeywords?: IStringDictionary; - readonly keymapExtensionTips?: readonly string[]; - readonly webExtensionTips?: readonly string[]; - readonly languageExtensionTips?: readonly string[]; - readonly trustedExtensionUrlPublicKeys?: IStringDictionary; - readonly trustedExtensionAuthAccess?: string[] | IStringDictionary; - readonly trustedExtensionProtocolHandlers?: readonly string[]; - - readonly commandPaletteSuggestedCommandIds?: string[]; - - readonly crashReporter?: { - readonly companyName: string; - readonly productName: string; - }; - - readonly removeTelemetryMachineId?: boolean; - readonly enabledTelemetryLevels?: { error: boolean; usage: boolean }; - readonly enableTelemetry?: boolean; - readonly openToWelcomeMainPage?: boolean; - readonly aiConfig?: { - readonly ariaKey: string; - }; - - readonly documentationUrl?: string; - readonly serverDocumentationUrl?: string; - readonly releaseNotesUrl?: string; - readonly keyboardShortcutsUrlMac?: string; - readonly keyboardShortcutsUrlLinux?: string; - readonly keyboardShortcutsUrlWin?: string; - readonly introductoryVideosUrl?: string; - readonly tipsAndTricksUrl?: string; - readonly newsletterSignupUrl?: string; - readonly youTubeUrl?: string; - readonly requestFeatureUrl?: string; - readonly reportIssueUrl?: string; - readonly reportMarketplaceIssueUrl?: string; - readonly licenseUrl?: string; - readonly serverLicenseUrl?: string; - readonly privacyStatementUrl?: string; - readonly showTelemetryOptOut?: boolean; - - readonly serverGreeting?: string[]; - readonly serverLicense?: string[]; - readonly serverLicensePrompt?: string; - readonly serverApplicationName: string; - readonly serverDataFolderName?: string; - - readonly tunnelApplicationName?: string; - readonly tunnelApplicationConfig?: ITunnelApplicationConfig; - - readonly npsSurveyUrl?: string; - readonly cesSurveyUrl?: string; - readonly surveys?: readonly ISurveyData[]; - - readonly checksums?: { [path: string]: string }; - readonly checksumFailMoreInfoUrl?: string; - - readonly appCenter?: IAppCenterConfiguration; - - readonly portable?: string; - - readonly extensionKind?: { readonly [extensionId: string]: ('ui' | 'workspace' | 'web')[] }; - readonly extensionPointExtensionKind?: { readonly [extensionPointId: string]: ('ui' | 'workspace' | 'web')[] }; - readonly extensionSyncedKeys?: { readonly [extensionId: string]: string[] }; - - readonly extensionsEnabledWithApiProposalVersion?: string[]; - readonly extensionEnabledApiProposals?: { readonly [extensionId: string]: string[] }; - readonly extensionUntrustedWorkspaceSupport?: { readonly [extensionId: string]: ExtensionUntrustedWorkspaceSupport }; - readonly extensionVirtualWorkspacesSupport?: { readonly [extensionId: string]: ExtensionVirtualWorkspaceSupport }; - - readonly msftInternalDomains?: string[]; - readonly linkProtectionTrustedDomains?: readonly string[]; - - readonly 'configurationSync.store'?: ConfigurationSyncStore; - - readonly 'editSessions.store'?: Omit; - readonly darwinUniversalAssetId?: string; - readonly profileTemplatesUrl?: string; - - readonly commonlyUsedSettings?: string[]; - readonly aiGeneratedWorkspaceTrust?: IAiGeneratedWorkspaceTrust; - readonly gitHubEntitlement?: IGitHubEntitlement; - readonly chatWelcomeView?: IChatWelcomeView; - readonly chatParticipantRegistry?: string; -} - -export interface ITunnelApplicationConfig { - authenticationProviders: IStringDictionary<{ scopes: string[] }>; - editorWebUrl: string; - extension: IRemoteExtensionTip; -} - -export interface IExtensionRecommendations { - readonly onFileOpen: IFileOpenCondition[]; - readonly onSettingsEditorOpen?: ISettingsEditorOpenCondition; -} - -export interface ISettingsEditorOpenCondition { - readonly prerelease?: boolean | string; -} - -export interface IExtensionRecommendationCondition { - readonly important?: boolean; - readonly whenInstalled?: string[]; - readonly whenNotInstalled?: string[]; -} - -export type IFileOpenCondition = IFileLanguageCondition | IFilePathCondition | IFileContentCondition; - -export interface IFileLanguageCondition extends IExtensionRecommendationCondition { - readonly languages: string[]; -} - -export interface IFilePathCondition extends IExtensionRecommendationCondition { - readonly pathGlob: string; -} - -export type IFileContentCondition = (IFileLanguageCondition | IFilePathCondition) & { readonly contentPattern: string }; - -export interface IAppCenterConfiguration { - readonly 'win32-x64': string; - readonly 'win32-arm64': string; - readonly 'linux-x64': string; - readonly 'darwin': string; - readonly 'darwin-universal': string; - readonly 'darwin-arm64': string; -} - -export interface IConfigBasedExtensionTip { - configPath: string; - configName: string; - configScheme?: string; - recommendations: IStringDictionary<{ - name: string; - contentPattern?: string; - important?: boolean; - isExtensionPack?: boolean; - whenNotInstalled?: string[]; - }>; -} - -export interface IExeBasedExtensionTip { - friendlyName: string; - windowsPath?: string; - important?: boolean; - recommendations: IStringDictionary<{ name: string; important?: boolean; isExtensionPack?: boolean; whenNotInstalled?: string[] }>; -} - -export interface IRemoteExtensionTip { - friendlyName: string; - extensionId: string; - supportedPlatforms?: PlatformName[]; - startEntry?: { - helpLink: string; - startConnectLabel: string; - startCommand: string; - priority: number; - }; -} - -export interface IVirtualWorkspaceExtensionTip { - friendlyName: string; - extensionId: string; - supportedPlatforms?: PlatformName[]; - startEntry: { - helpLink: string; - startConnectLabel: string; - startCommand: string; - priority: number; - }; -} - -export interface ISurveyData { - surveyId: string; - surveyUrl: string; - languageId: string; - editCount: number; - userProbability: number; -} - -export interface IAiGeneratedWorkspaceTrust { - readonly title: string; - readonly checkboxText: string; - readonly trustOption: string; - readonly dontTrustOption: string; - readonly startupTrustRequestLearnMore: string; -} - -export interface IGitHubEntitlement { - providerId: string; - command: { title: string; titleWithoutPlaceHolder: string; action: string; when: string }; - entitlementUrl: string; - extensionId: string; - enablementKey: string; - confirmationMessage: string; - confirmationAction: string; -} - -export interface IChatWelcomeView { - welcomeViewId: string; - welcomeViewTitle: string; - welcomeViewContent: string; -} diff --git a/src/vs/base/common/range.ts b/src/vs/base/common/range.ts deleted file mode 100644 index 93a1a26914..0000000000 --- a/src/vs/base/common/range.ts +++ /dev/null @@ -1,60 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -export interface IRange { - start: number; - end: number; -} - -export interface IRangedGroup { - range: IRange; - size: number; -} - -export namespace Range { - - /** - * Returns the intersection between two ranges as a range itself. - * Returns `{ start: 0, end: 0 }` if the intersection is empty. - */ - export function intersect(one: IRange, other: IRange): IRange { - if (one.start >= other.end || other.start >= one.end) { - return { start: 0, end: 0 }; - } - - const start = Math.max(one.start, other.start); - const end = Math.min(one.end, other.end); - - if (end - start <= 0) { - return { start: 0, end: 0 }; - } - - return { start, end }; - } - - export function isEmpty(range: IRange): boolean { - return range.end - range.start <= 0; - } - - export function intersects(one: IRange, other: IRange): boolean { - return !isEmpty(intersect(one, other)); - } - - export function relativeComplement(one: IRange, other: IRange): IRange[] { - const result: IRange[] = []; - const first = { start: one.start, end: Math.min(other.start, one.end) }; - const second = { start: Math.max(other.end, one.start), end: one.end }; - - if (!isEmpty(first)) { - result.push(first); - } - - if (!isEmpty(second)) { - result.push(second); - } - - return result; - } -} diff --git a/src/vs/base/common/search.ts b/src/vs/base/common/search.ts deleted file mode 100644 index 24c5bf7929..0000000000 --- a/src/vs/base/common/search.ts +++ /dev/null @@ -1,48 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import * as strings from './strings'; - -export function buildReplaceStringWithCasePreserved(matches: string[] | null, pattern: string): string { - if (matches && (matches[0] !== '')) { - const containsHyphens = validateSpecificSpecialCharacter(matches, pattern, '-'); - const containsUnderscores = validateSpecificSpecialCharacter(matches, pattern, '_'); - if (containsHyphens && !containsUnderscores) { - return buildReplaceStringForSpecificSpecialCharacter(matches, pattern, '-'); - } else if (!containsHyphens && containsUnderscores) { - return buildReplaceStringForSpecificSpecialCharacter(matches, pattern, '_'); - } - if (matches[0].toUpperCase() === matches[0]) { - return pattern.toUpperCase(); - } else if (matches[0].toLowerCase() === matches[0]) { - return pattern.toLowerCase(); - } else if (strings.containsUppercaseCharacter(matches[0][0]) && pattern.length > 0) { - return pattern[0].toUpperCase() + pattern.substr(1); - } else if (matches[0][0].toUpperCase() !== matches[0][0] && pattern.length > 0) { - return pattern[0].toLowerCase() + pattern.substr(1); - } else { - // we don't understand its pattern yet. - return pattern; - } - } else { - return pattern; - } -} - -function validateSpecificSpecialCharacter(matches: string[], pattern: string, specialCharacter: string): boolean { - const doesContainSpecialCharacter = matches[0].indexOf(specialCharacter) !== -1 && pattern.indexOf(specialCharacter) !== -1; - return doesContainSpecialCharacter && matches[0].split(specialCharacter).length === pattern.split(specialCharacter).length; -} - -function buildReplaceStringForSpecificSpecialCharacter(matches: string[], pattern: string, specialCharacter: string): string { - const splitPatternAtSpecialCharacter = pattern.split(specialCharacter); - const splitMatchAtSpecialCharacter = matches[0].split(specialCharacter); - let replaceString: string = ''; - splitPatternAtSpecialCharacter.forEach((splitValue, index) => { - replaceString += buildReplaceStringWithCasePreserved([splitMatchAtSpecialCharacter[index]], splitValue) + specialCharacter; - }); - - return replaceString.slice(0, -1); -} diff --git a/src/vs/base/common/severity.ts b/src/vs/base/common/severity.ts deleted file mode 100644 index 83e753d8cf..0000000000 --- a/src/vs/base/common/severity.ts +++ /dev/null @@ -1,56 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import * as strings from 'vs/base/common/strings'; - -enum Severity { - Ignore = 0, - Info = 1, - Warning = 2, - Error = 3 -} - -namespace Severity { - - const _error = 'error'; - const _warning = 'warning'; - const _warn = 'warn'; - const _info = 'info'; - const _ignore = 'ignore'; - - /** - * Parses 'error', 'warning', 'warn', 'info' in call casings - * and falls back to ignore. - */ - export function fromValue(value: string): Severity { - if (!value) { - return Severity.Ignore; - } - - if (strings.equalsIgnoreCase(_error, value)) { - return Severity.Error; - } - - if (strings.equalsIgnoreCase(_warning, value) || strings.equalsIgnoreCase(_warn, value)) { - return Severity.Warning; - } - - if (strings.equalsIgnoreCase(_info, value)) { - return Severity.Info; - } - return Severity.Ignore; - } - - export function toString(severity: Severity): string { - switch (severity) { - case Severity.Error: return _error; - case Severity.Warning: return _warning; - case Severity.Info: return _info; - default: return _ignore; - } - } -} - -export default Severity; diff --git a/src/vs/base/common/skipList.ts b/src/vs/base/common/skipList.ts deleted file mode 100644 index ed3fd7e5a0..0000000000 --- a/src/vs/base/common/skipList.ts +++ /dev/null @@ -1,204 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - - -class Node { - readonly forward: Node[]; - constructor(readonly level: number, readonly key: K, public value: V) { - this.forward = []; - } -} - -const NIL: undefined = undefined; - -interface Comparator { - (a: K, b: K): number; -} - -export class SkipList implements Map { - - readonly [Symbol.toStringTag] = 'SkipList'; - - private _maxLevel: number; - private _level: number = 0; - private _header: Node; - private _size: number = 0; - - /** - * - * @param capacity Capacity at which the list performs best - */ - constructor( - readonly comparator: (a: K, b: K) => number, - capacity: number = 2 ** 16 - ) { - this._maxLevel = Math.max(1, Math.log2(capacity) | 0); - this._header = new Node(this._maxLevel, NIL, NIL); - } - - get size(): number { - return this._size; - } - - clear(): void { - this._header = new Node(this._maxLevel, NIL, NIL); - this._size = 0; - } - - has(key: K): boolean { - return Boolean(SkipList._search(this, key, this.comparator)); - } - - get(key: K): V | undefined { - return SkipList._search(this, key, this.comparator)?.value; - } - - set(key: K, value: V): this { - if (SkipList._insert(this, key, value, this.comparator)) { - this._size += 1; - } - return this; - } - - delete(key: K): boolean { - const didDelete = SkipList._delete(this, key, this.comparator); - if (didDelete) { - this._size -= 1; - } - return didDelete; - } - - // --- iteration - - forEach(callbackfn: (value: V, key: K, map: Map) => void, thisArg?: any): void { - let node = this._header.forward[0]; - while (node) { - callbackfn.call(thisArg, node.value, node.key, this); - node = node.forward[0]; - } - } - - [Symbol.iterator](): IterableIterator<[K, V]> { - return this.entries(); - } - - *entries(): IterableIterator<[K, V]> { - let node = this._header.forward[0]; - while (node) { - yield [node.key, node.value]; - node = node.forward[0]; - } - } - - *keys(): IterableIterator { - let node = this._header.forward[0]; - while (node) { - yield node.key; - node = node.forward[0]; - } - } - - *values(): IterableIterator { - let node = this._header.forward[0]; - while (node) { - yield node.value; - node = node.forward[0]; - } - } - - toString(): string { - // debug string... - let result = '[SkipList]:'; - let node = this._header.forward[0]; - while (node) { - result += `node(${node.key}, ${node.value}, lvl:${node.level})`; - node = node.forward[0]; - } - return result; - } - - // from https://www.epaperpress.com/sortsearch/download/skiplist.pdf - - private static _search(list: SkipList, searchKey: K, comparator: Comparator) { - let x = list._header; - for (let i = list._level - 1; i >= 0; i--) { - while (x.forward[i] && comparator(x.forward[i].key, searchKey) < 0) { - x = x.forward[i]; - } - } - x = x.forward[0]; - if (x && comparator(x.key, searchKey) === 0) { - return x; - } - return undefined; - } - - private static _insert(list: SkipList, searchKey: K, value: V, comparator: Comparator) { - const update: Node[] = []; - let x = list._header; - for (let i = list._level - 1; i >= 0; i--) { - while (x.forward[i] && comparator(x.forward[i].key, searchKey) < 0) { - x = x.forward[i]; - } - update[i] = x; - } - x = x.forward[0]; - if (x && comparator(x.key, searchKey) === 0) { - // update - x.value = value; - return false; - } else { - // insert - const lvl = SkipList._randomLevel(list); - if (lvl > list._level) { - for (let i = list._level; i < lvl; i++) { - update[i] = list._header; - } - list._level = lvl; - } - x = new Node(lvl, searchKey, value); - for (let i = 0; i < lvl; i++) { - x.forward[i] = update[i].forward[i]; - update[i].forward[i] = x; - } - return true; - } - } - - private static _randomLevel(list: SkipList, p: number = 0.5): number { - let lvl = 1; - while (Math.random() < p && lvl < list._maxLevel) { - lvl += 1; - } - return lvl; - } - - private static _delete(list: SkipList, searchKey: K, comparator: Comparator) { - const update: Node[] = []; - let x = list._header; - for (let i = list._level - 1; i >= 0; i--) { - while (x.forward[i] && comparator(x.forward[i].key, searchKey) < 0) { - x = x.forward[i]; - } - update[i] = x; - } - x = x.forward[0]; - if (!x || comparator(x.key, searchKey) !== 0) { - // not found - return false; - } - for (let i = 0; i < list._level; i++) { - if (update[i].forward[i] !== x) { - break; - } - update[i].forward[i] = x.forward[i]; - } - while (list._level > 0 && list._header.forward[list._level - 1] === NIL) { - list._level -= 1; - } - return true; - } - -} diff --git a/src/vs/base/common/stream.ts b/src/vs/base/common/stream.ts deleted file mode 100644 index d6cd674b1d..0000000000 --- a/src/vs/base/common/stream.ts +++ /dev/null @@ -1,772 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { CancellationToken } from 'vs/base/common/cancellation'; -import { onUnexpectedError } from 'vs/base/common/errors'; -import { DisposableStore, toDisposable } from 'vs/base/common/lifecycle'; - -/** - * The payload that flows in readable stream events. - */ -export type ReadableStreamEventPayload = T | Error | 'end'; - -export interface ReadableStreamEvents { - - /** - * The 'data' event is emitted whenever the stream is - * relinquishing ownership of a chunk of data to a consumer. - * - * NOTE: PLEASE UNDERSTAND THAT ADDING A DATA LISTENER CAN - * TURN THE STREAM INTO FLOWING MODE. IT IS THEREFOR THE - * LAST LISTENER THAT SHOULD BE ADDED AND NOT THE FIRST - * - * Use `listenStream` as a helper method to listen to - * stream events in the right order. - */ - on(event: 'data', callback: (data: T) => void): void; - - /** - * Emitted when any error occurs. - */ - on(event: 'error', callback: (err: Error) => void): void; - - /** - * The 'end' event is emitted when there is no more data - * to be consumed from the stream. The 'end' event will - * not be emitted unless the data is completely consumed. - */ - on(event: 'end', callback: () => void): void; -} - -/** - * A interface that emulates the API shape of a node.js readable - * stream for use in native and web environments. - */ -export interface ReadableStream extends ReadableStreamEvents { - - /** - * Stops emitting any events until resume() is called. - */ - pause(): void; - - /** - * Starts emitting events again after pause() was called. - */ - resume(): void; - - /** - * Destroys the stream and stops emitting any event. - */ - destroy(): void; - - /** - * Allows to remove a listener that was previously added. - */ - removeListener(event: string, callback: Function): void; -} - -/** - * A interface that emulates the API shape of a node.js readable - * for use in native and web environments. - */ -export interface Readable { - - /** - * Read data from the underlying source. Will return - * null to indicate that no more data can be read. - */ - read(): T | null; -} - -export function isReadable(obj: unknown): obj is Readable { - const candidate = obj as Readable | undefined; - if (!candidate) { - return false; - } - - return typeof candidate.read === 'function'; -} - -/** - * A interface that emulates the API shape of a node.js writeable - * stream for use in native and web environments. - */ -export interface WriteableStream extends ReadableStream { - - /** - * Writing data to the stream will trigger the on('data') - * event listener if the stream is flowing and buffer the - * data otherwise until the stream is flowing. - * - * If a `highWaterMark` is configured and writing to the - * stream reaches this mark, a promise will be returned - * that should be awaited on before writing more data. - * Otherwise there is a risk of buffering a large number - * of data chunks without consumer. - */ - write(data: T): void | Promise; - - /** - * Signals an error to the consumer of the stream via the - * on('error') handler if the stream is flowing. - * - * NOTE: call `end` to signal that the stream has ended, - * this DOES NOT happen automatically from `error`. - */ - error(error: Error): void; - - /** - * Signals the end of the stream to the consumer. If the - * result is provided, will trigger the on('data') event - * listener if the stream is flowing and buffer the data - * otherwise until the stream is flowing. - */ - end(result?: T): void; -} - -/** - * A stream that has a buffer already read. Returns the original stream - * that was read as well as the chunks that got read. - * - * The `ended` flag indicates if the stream has been fully consumed. - */ -export interface ReadableBufferedStream { - - /** - * The original stream that is being read. - */ - stream: ReadableStream; - - /** - * An array of chunks already read from this stream. - */ - buffer: T[]; - - /** - * Signals if the stream has ended or not. If not, consumers - * should continue to read from the stream until consumed. - */ - ended: boolean; -} - -export function isReadableStream(obj: unknown): obj is ReadableStream { - const candidate = obj as ReadableStream | undefined; - if (!candidate) { - return false; - } - - return [candidate.on, candidate.pause, candidate.resume, candidate.destroy].every(fn => typeof fn === 'function'); -} - -export function isReadableBufferedStream(obj: unknown): obj is ReadableBufferedStream { - const candidate = obj as ReadableBufferedStream | undefined; - if (!candidate) { - return false; - } - - return isReadableStream(candidate.stream) && Array.isArray(candidate.buffer) && typeof candidate.ended === 'boolean'; -} - -export interface IReducer { - (data: T[]): R; -} - -export interface IDataTransformer { - (data: Original): Transformed; -} - -export interface IErrorTransformer { - (error: Error): Error; -} - -export interface ITransformer { - data: IDataTransformer; - error?: IErrorTransformer; -} - -export function newWriteableStream(reducer: IReducer, options?: WriteableStreamOptions): WriteableStream { - return new WriteableStreamImpl(reducer, options); -} - -export interface WriteableStreamOptions { - - /** - * The number of objects to buffer before WriteableStream#write() - * signals back that the buffer is full. Can be used to reduce - * the memory pressure when the stream is not flowing. - */ - highWaterMark?: number; -} - -class WriteableStreamImpl implements WriteableStream { - - private readonly state = { - flowing: false, - ended: false, - destroyed: false - }; - - private readonly buffer = { - data: [] as T[], - error: [] as Error[] - }; - - private readonly listeners = { - data: [] as { (data: T): void }[], - error: [] as { (error: Error): void }[], - end: [] as { (): void }[] - }; - - private readonly pendingWritePromises: Function[] = []; - - constructor(private reducer: IReducer, private options?: WriteableStreamOptions) { } - - pause(): void { - if (this.state.destroyed) { - return; - } - - this.state.flowing = false; - } - - resume(): void { - if (this.state.destroyed) { - return; - } - - if (!this.state.flowing) { - this.state.flowing = true; - - // emit buffered events - this.flowData(); - this.flowErrors(); - this.flowEnd(); - } - } - - write(data: T): void | Promise { - if (this.state.destroyed) { - return; - } - - // flowing: directly send the data to listeners - if (this.state.flowing) { - this.emitData(data); - } - - // not yet flowing: buffer data until flowing - else { - this.buffer.data.push(data); - - // highWaterMark: if configured, signal back when buffer reached limits - if (typeof this.options?.highWaterMark === 'number' && this.buffer.data.length > this.options.highWaterMark) { - return new Promise(resolve => this.pendingWritePromises.push(resolve)); - } - } - } - - error(error: Error): void { - if (this.state.destroyed) { - return; - } - - // flowing: directly send the error to listeners - if (this.state.flowing) { - this.emitError(error); - } - - // not yet flowing: buffer errors until flowing - else { - this.buffer.error.push(error); - } - } - - end(result?: T): void { - if (this.state.destroyed) { - return; - } - - // end with data if provided - if (typeof result !== 'undefined') { - this.write(result); - } - - // flowing: send end event to listeners - if (this.state.flowing) { - this.emitEnd(); - - this.destroy(); - } - - // not yet flowing: remember state - else { - this.state.ended = true; - } - } - - private emitData(data: T): void { - this.listeners.data.slice(0).forEach(listener => listener(data)); // slice to avoid listener mutation from delivering event - } - - private emitError(error: Error): void { - if (this.listeners.error.length === 0) { - onUnexpectedError(error); // nobody listened to this error so we log it as unexpected - } else { - this.listeners.error.slice(0).forEach(listener => listener(error)); // slice to avoid listener mutation from delivering event - } - } - - private emitEnd(): void { - this.listeners.end.slice(0).forEach(listener => listener()); // slice to avoid listener mutation from delivering event - } - - on(event: 'data', callback: (data: T) => void): void; - on(event: 'error', callback: (err: Error) => void): void; - on(event: 'end', callback: () => void): void; - on(event: 'data' | 'error' | 'end', callback: (arg0?: any) => void): void { - if (this.state.destroyed) { - return; - } - - switch (event) { - case 'data': - this.listeners.data.push(callback); - - // switch into flowing mode as soon as the first 'data' - // listener is added and we are not yet in flowing mode - this.resume(); - - break; - - case 'end': - this.listeners.end.push(callback); - - // emit 'end' event directly if we are flowing - // and the end has already been reached - // - // finish() when it went through - if (this.state.flowing && this.flowEnd()) { - this.destroy(); - } - - break; - - case 'error': - this.listeners.error.push(callback); - - // emit buffered 'error' events unless done already - // now that we know that we have at least one listener - if (this.state.flowing) { - this.flowErrors(); - } - - break; - } - } - - removeListener(event: string, callback: Function): void { - if (this.state.destroyed) { - return; - } - - let listeners: unknown[] | undefined = undefined; - - switch (event) { - case 'data': - listeners = this.listeners.data; - break; - - case 'end': - listeners = this.listeners.end; - break; - - case 'error': - listeners = this.listeners.error; - break; - } - - if (listeners) { - const index = listeners.indexOf(callback); - if (index >= 0) { - listeners.splice(index, 1); - } - } - } - - private flowData(): void { - if (this.buffer.data.length > 0) { - const fullDataBuffer = this.reducer(this.buffer.data); - - this.emitData(fullDataBuffer); - - this.buffer.data.length = 0; - - // When the buffer is empty, resolve all pending writers - const pendingWritePromises = [...this.pendingWritePromises]; - this.pendingWritePromises.length = 0; - pendingWritePromises.forEach(pendingWritePromise => pendingWritePromise()); - } - } - - private flowErrors(): void { - if (this.listeners.error.length > 0) { - for (const error of this.buffer.error) { - this.emitError(error); - } - - this.buffer.error.length = 0; - } - } - - private flowEnd(): boolean { - if (this.state.ended) { - this.emitEnd(); - - return this.listeners.end.length > 0; - } - - return false; - } - - destroy(): void { - if (!this.state.destroyed) { - this.state.destroyed = true; - this.state.ended = true; - - this.buffer.data.length = 0; - this.buffer.error.length = 0; - - this.listeners.data.length = 0; - this.listeners.error.length = 0; - this.listeners.end.length = 0; - - this.pendingWritePromises.length = 0; - } - } -} - -/** - * Helper to fully read a T readable into a T. - */ -export function consumeReadable(readable: Readable, reducer: IReducer): T { - const chunks: T[] = []; - - let chunk: T | null; - while ((chunk = readable.read()) !== null) { - chunks.push(chunk); - } - - return reducer(chunks); -} - -/** - * Helper to read a T readable up to a maximum of chunks. If the limit is - * reached, will return a readable instead to ensure all data can still - * be read. - */ -export function peekReadable(readable: Readable, reducer: IReducer, maxChunks: number): T | Readable { - const chunks: T[] = []; - - let chunk: T | null | undefined = undefined; - while ((chunk = readable.read()) !== null && chunks.length < maxChunks) { - chunks.push(chunk); - } - - // If the last chunk is null, it means we reached the end of - // the readable and return all the data at once - if (chunk === null && chunks.length > 0) { - return reducer(chunks); - } - - // Otherwise, we still have a chunk, it means we reached the maxChunks - // value and as such we return a new Readable that first returns - // the existing read chunks and then continues with reading from - // the underlying readable. - return { - read: () => { - - // First consume chunks from our array - if (chunks.length > 0) { - return chunks.shift()!; - } - - // Then ensure to return our last read chunk - if (typeof chunk !== 'undefined') { - const lastReadChunk = chunk; - - // explicitly use undefined here to indicate that we consumed - // the chunk, which could have either been null or valued. - chunk = undefined; - - return lastReadChunk; - } - - // Finally delegate back to the Readable - return readable.read(); - } - }; -} - -/** - * Helper to fully read a T stream into a T or consuming - * a stream fully, awaiting all the events without caring - * about the data. - */ -export function consumeStream(stream: ReadableStreamEvents, reducer: IReducer): Promise; -export function consumeStream(stream: ReadableStreamEvents): Promise; -export function consumeStream(stream: ReadableStreamEvents, reducer?: IReducer): Promise { - return new Promise((resolve, reject) => { - const chunks: T[] = []; - - listenStream(stream, { - onData: chunk => { - if (reducer) { - chunks.push(chunk); - } - }, - onError: error => { - if (reducer) { - reject(error); - } else { - resolve(undefined); - } - }, - onEnd: () => { - if (reducer) { - resolve(reducer(chunks)); - } else { - resolve(undefined); - } - } - }); - }); -} - -export interface IStreamListener { - - /** - * The 'data' event is emitted whenever the stream is - * relinquishing ownership of a chunk of data to a consumer. - */ - onData(data: T): void; - - /** - * Emitted when any error occurs. - */ - onError(err: Error): void; - - /** - * The 'end' event is emitted when there is no more data - * to be consumed from the stream. The 'end' event will - * not be emitted unless the data is completely consumed. - */ - onEnd(): void; -} - -/** - * Helper to listen to all events of a T stream in proper order. - */ -export function listenStream(stream: ReadableStreamEvents, listener: IStreamListener, token?: CancellationToken): void { - - stream.on('error', error => { - if (!token?.isCancellationRequested) { - listener.onError(error); - } - }); - - stream.on('end', () => { - if (!token?.isCancellationRequested) { - listener.onEnd(); - } - }); - - // Adding the `data` listener will turn the stream - // into flowing mode. As such it is important to - // add this listener last (DO NOT CHANGE!) - stream.on('data', data => { - if (!token?.isCancellationRequested) { - listener.onData(data); - } - }); -} - -/** - * Helper to peek up to `maxChunks` into a stream. The return type signals if - * the stream has ended or not. If not, caller needs to add a `data` listener - * to continue reading. - */ -export function peekStream(stream: ReadableStream, maxChunks: number): Promise> { - return new Promise((resolve, reject) => { - const streamListeners = new DisposableStore(); - const buffer: T[] = []; - - // Data Listener - const dataListener = (chunk: T) => { - - // Add to buffer - buffer.push(chunk); - - // We reached maxChunks and thus need to return - if (buffer.length > maxChunks) { - - // Dispose any listeners and ensure to pause the - // stream so that it can be consumed again by caller - streamListeners.dispose(); - stream.pause(); - - return resolve({ stream, buffer, ended: false }); - } - }; - - // Error Listener - const errorListener = (error: Error) => { - streamListeners.dispose(); - - return reject(error); - }; - - // End Listener - const endListener = () => { - streamListeners.dispose(); - - return resolve({ stream, buffer, ended: true }); - }; - - streamListeners.add(toDisposable(() => stream.removeListener('error', errorListener))); - stream.on('error', errorListener); - - streamListeners.add(toDisposable(() => stream.removeListener('end', endListener))); - stream.on('end', endListener); - - // Important: leave the `data` listener last because - // this can turn the stream into flowing mode and we - // want `error` events to be received as well. - streamListeners.add(toDisposable(() => stream.removeListener('data', dataListener))); - stream.on('data', dataListener); - }); -} - -/** - * Helper to create a readable stream from an existing T. - */ -export function toStream(t: T, reducer: IReducer): ReadableStream { - const stream = newWriteableStream(reducer); - - stream.end(t); - - return stream; -} - -/** - * Helper to create an empty stream - */ -export function emptyStream(): ReadableStream { - const stream = newWriteableStream(() => { throw new Error('not supported'); }); - stream.end(); - - return stream; -} - -/** - * Helper to convert a T into a Readable. - */ -export function toReadable(t: T): Readable { - let consumed = false; - - return { - read: () => { - if (consumed) { - return null; - } - - consumed = true; - - return t; - } - }; -} - -/** - * Helper to transform a readable stream into another stream. - */ -export function transform(stream: ReadableStreamEvents, transformer: ITransformer, reducer: IReducer): ReadableStream { - const target = newWriteableStream(reducer); - - listenStream(stream, { - onData: data => target.write(transformer.data(data)), - onError: error => target.error(transformer.error ? transformer.error(error) : error), - onEnd: () => target.end() - }); - - return target; -} - -/** - * Helper to take an existing readable that will - * have a prefix injected to the beginning. - */ -export function prefixedReadable(prefix: T, readable: Readable, reducer: IReducer): Readable { - let prefixHandled = false; - - return { - read: () => { - const chunk = readable.read(); - - // Handle prefix only once - if (!prefixHandled) { - prefixHandled = true; - - // If we have also a read-result, make - // sure to reduce it to a single result - if (chunk !== null) { - return reducer([prefix, chunk]); - } - - // Otherwise, just return prefix directly - return prefix; - } - - return chunk; - } - }; -} - -/** - * Helper to take an existing stream that will - * have a prefix injected to the beginning. - */ -export function prefixedStream(prefix: T, stream: ReadableStream, reducer: IReducer): ReadableStream { - let prefixHandled = false; - - const target = newWriteableStream(reducer); - - listenStream(stream, { - onData: data => { - - // Handle prefix only once - if (!prefixHandled) { - prefixHandled = true; - - return target.write(reducer([prefix, data])); - } - - return target.write(data); - }, - onError: error => target.error(error), - onEnd: () => { - - // Handle prefix only once - if (!prefixHandled) { - prefixHandled = true; - - target.write(prefix); - } - - target.end(); - } - }); - - return target; -} diff --git a/src/vs/base/common/tfIdf.ts b/src/vs/base/common/tfIdf.ts deleted file mode 100644 index 4504275961..0000000000 --- a/src/vs/base/common/tfIdf.ts +++ /dev/null @@ -1,240 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { CancellationToken } from 'vs/base/common/cancellation'; - -type SparseEmbedding = Record; -type TermFrequencies = Map; -type DocumentOccurrences = Map; - -function countMapFrom(values: Iterable): Map { - const map = new Map(); - for (const value of values) { - map.set(value, (map.get(value) ?? 0) + 1); - } - return map; -} - -interface DocumentChunkEntry { - readonly text: string; - readonly tf: TermFrequencies; -} - -export interface TfIdfDocument { - readonly key: string; - readonly textChunks: readonly string[]; -} - -export interface TfIdfScore { - readonly key: string; - /** - * An unbounded number. - */ - readonly score: number; -} - -export interface NormalizedTfIdfScore { - readonly key: string; - /** - * A number between 0 and 1. - */ - readonly score: number; -} - -/** - * Implementation of tf-idf (term frequency-inverse document frequency) for a set of - * documents where each document contains one or more chunks of text. - * Each document is identified by a key, and the score for each document is computed - * by taking the max score over all the chunks in the document. - */ -export class TfIdfCalculator { - calculateScores(query: string, token: CancellationToken): TfIdfScore[] { - const embedding = this.computeEmbedding(query); - const idfCache = new Map(); - const scores: TfIdfScore[] = []; - // For each document, generate one score - for (const [key, doc] of this.documents) { - if (token.isCancellationRequested) { - return []; - } - - for (const chunk of doc.chunks) { - const score = this.computeSimilarityScore(chunk, embedding, idfCache); - if (score > 0) { - scores.push({ key, score }); - } - } - } - - return scores; - } - - /** - * Count how many times each term (word) appears in a string. - */ - private static termFrequencies(input: string): TermFrequencies { - return countMapFrom(TfIdfCalculator.splitTerms(input)); - } - - /** - * Break a string into terms (words). - */ - private static *splitTerms(input: string): Iterable { - const normalize = (word: string) => word.toLowerCase(); - - // Only match on words that are at least 3 characters long and start with a letter - for (const [word] of input.matchAll(/\b\p{Letter}[\p{Letter}\d]{2,}\b/gu)) { - yield normalize(word); - - const camelParts = word.replace(/([a-z])([A-Z])/g, '$1 $2').split(/\s+/g); - if (camelParts.length > 1) { - for (const part of camelParts) { - // Require at least 3 letters in the parts of a camel case word - if (part.length > 2 && /\p{Letter}{3,}/gu.test(part)) { - yield normalize(part); - } - } - } - } - } - - /** - * Total number of chunks - */ - private chunkCount = 0; - - private readonly chunkOccurrences: DocumentOccurrences = new Map(); - - private readonly documents = new Map; - }>(); - - updateDocuments(documents: ReadonlyArray): this { - for (const { key } of documents) { - this.deleteDocument(key); - } - - for (const doc of documents) { - const chunks: Array<{ text: string; tf: TermFrequencies }> = []; - for (const text of doc.textChunks) { - // TODO: See if we can compute the tf lazily - // The challenge is that we need to also update the `chunkOccurrences` - // and all of those updates need to get flushed before the real TF-IDF of - // anything is computed. - const tf = TfIdfCalculator.termFrequencies(text); - - // Update occurrences list - for (const term of tf.keys()) { - this.chunkOccurrences.set(term, (this.chunkOccurrences.get(term) ?? 0) + 1); - } - - chunks.push({ text, tf }); - } - - this.chunkCount += chunks.length; - this.documents.set(doc.key, { chunks }); - } - return this; - } - - deleteDocument(key: string) { - const doc = this.documents.get(key); - if (!doc) { - return; - } - - this.documents.delete(key); - this.chunkCount -= doc.chunks.length; - - // Update term occurrences for the document - for (const chunk of doc.chunks) { - for (const term of chunk.tf.keys()) { - const currentOccurrences = this.chunkOccurrences.get(term); - if (typeof currentOccurrences === 'number') { - const newOccurrences = currentOccurrences - 1; - if (newOccurrences <= 0) { - this.chunkOccurrences.delete(term); - } else { - this.chunkOccurrences.set(term, newOccurrences); - } - } - } - } - } - - private computeSimilarityScore(chunk: DocumentChunkEntry, queryEmbedding: SparseEmbedding, idfCache: Map): number { - // Compute the dot product between the chunk's embedding and the query embedding - - // Note that the chunk embedding is computed lazily on a per-term basis. - // This lets us skip a large number of calculations because the majority - // of chunks do not share any terms with the query. - - let sum = 0; - for (const [term, termTfidf] of Object.entries(queryEmbedding)) { - const chunkTf = chunk.tf.get(term); - if (!chunkTf) { - // Term does not appear in chunk so it has no contribution - continue; - } - - let chunkIdf = idfCache.get(term); - if (typeof chunkIdf !== 'number') { - chunkIdf = this.computeIdf(term); - idfCache.set(term, chunkIdf); - } - - const chunkTfidf = chunkTf * chunkIdf; - sum += chunkTfidf * termTfidf; - } - return sum; - } - - private computeEmbedding(input: string): SparseEmbedding { - const tf = TfIdfCalculator.termFrequencies(input); - return this.computeTfidf(tf); - } - - private computeIdf(term: string): number { - const chunkOccurrences = this.chunkOccurrences.get(term) ?? 0; - return chunkOccurrences > 0 - ? Math.log((this.chunkCount + 1) / chunkOccurrences) - : 0; - } - - private computeTfidf(termFrequencies: TermFrequencies): SparseEmbedding { - const embedding = Object.create(null); - for (const [word, occurrences] of termFrequencies) { - const idf = this.computeIdf(word); - if (idf > 0) { - embedding[word] = occurrences * idf; - } - } - return embedding; - } -} - -/** - * Normalize the scores to be between 0 and 1 and sort them decending. - * @param scores array of scores from {@link TfIdfCalculator.calculateScores} - * @returns normalized scores - */ -export function normalizeTfIdfScores(scores: TfIdfScore[]): NormalizedTfIdfScore[] { - - // copy of scores - const result = scores.slice(0) as { score: number }[]; - - // sort descending - result.sort((a, b) => b.score - a.score); - - // normalize - const max = result[0]?.score ?? 0; - if (max > 0) { - for (const score of result) { - score.score /= max; - } - } - - return result as TfIdfScore[]; -} diff --git a/src/vs/base/common/types.ts b/src/vs/base/common/types.ts deleted file mode 100644 index 1acab57b75..0000000000 --- a/src/vs/base/common/types.ts +++ /dev/null @@ -1,250 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -/** - * @returns whether the provided parameter is a JavaScript String or not. - */ -export function isString(str: unknown): str is string { - return (typeof str === 'string'); -} - -/** - * @returns whether the provided parameter is a JavaScript Array and each element in the array is a string. - */ -export function isStringArray(value: unknown): value is string[] { - return Array.isArray(value) && (value).every(elem => isString(elem)); -} - -/** - * @returns whether the provided parameter is of type `object` but **not** - * `null`, an `array`, a `regexp`, nor a `date`. - */ -export function isObject(obj: unknown): obj is Object { - // The method can't do a type cast since there are type (like strings) which - // are subclasses of any put not positvely matched by the function. Hence type - // narrowing results in wrong results. - return typeof obj === 'object' - && obj !== null - && !Array.isArray(obj) - && !(obj instanceof RegExp) - && !(obj instanceof Date); -} - -/** - * @returns whether the provided parameter is of type `Buffer` or Uint8Array dervived type - */ -export function isTypedArray(obj: unknown): obj is Object { - const TypedArray = Object.getPrototypeOf(Uint8Array); - return typeof obj === 'object' - && obj instanceof TypedArray; -} - -/** - * In **contrast** to just checking `typeof` this will return `false` for `NaN`. - * @returns whether the provided parameter is a JavaScript Number or not. - */ -export function isNumber(obj: unknown): obj is number { - return (typeof obj === 'number' && !isNaN(obj)); -} - -/** - * @returns whether the provided parameter is an Iterable, casting to the given generic - */ -export function isIterable(obj: unknown): obj is Iterable { - return !!obj && typeof (obj as any)[Symbol.iterator] === 'function'; -} - -/** - * @returns whether the provided parameter is a JavaScript Boolean or not. - */ -export function isBoolean(obj: unknown): obj is boolean { - return (obj === true || obj === false); -} - -/** - * @returns whether the provided parameter is undefined. - */ -export function isUndefined(obj: unknown): obj is undefined { - return (typeof obj === 'undefined'); -} - -/** - * @returns whether the provided parameter is defined. - */ -export function isDefined(arg: T | null | undefined): arg is T { - return !isUndefinedOrNull(arg); -} - -/** - * @returns whether the provided parameter is undefined or null. - */ -export function isUndefinedOrNull(obj: unknown): obj is undefined | null { - return (isUndefined(obj) || obj === null); -} - - -export function assertType(condition: unknown, type?: string): asserts condition { - if (!condition) { - throw new Error(type ? `Unexpected type, expected '${type}'` : 'Unexpected type'); - } -} - -/** - * Asserts that the argument passed in is neither undefined nor null. - */ -export function assertIsDefined(arg: T | null | undefined): T { - if (isUndefinedOrNull(arg)) { - throw new Error('Assertion Failed: argument is undefined or null'); - } - - return arg; -} - -/** - * Asserts that each argument passed in is neither undefined nor null. - */ -export function assertAllDefined(t1: T1 | null | undefined, t2: T2 | null | undefined): [T1, T2]; -export function assertAllDefined(t1: T1 | null | undefined, t2: T2 | null | undefined, t3: T3 | null | undefined): [T1, T2, T3]; -export function assertAllDefined(t1: T1 | null | undefined, t2: T2 | null | undefined, t3: T3 | null | undefined, t4: T4 | null | undefined): [T1, T2, T3, T4]; -export function assertAllDefined(...args: (unknown | null | undefined)[]): unknown[] { - const result = []; - - for (let i = 0; i < args.length; i++) { - const arg = args[i]; - - if (isUndefinedOrNull(arg)) { - throw new Error(`Assertion Failed: argument at index ${i} is undefined or null`); - } - - result.push(arg); - } - - return result; -} - -const hasOwnProperty = Object.prototype.hasOwnProperty; - -/** - * @returns whether the provided parameter is an empty JavaScript Object or not. - */ -export function isEmptyObject(obj: unknown): obj is object { - if (!isObject(obj)) { - return false; - } - - for (const key in obj) { - if (hasOwnProperty.call(obj, key)) { - return false; - } - } - - return true; -} - -/** - * @returns whether the provided parameter is a JavaScript Function or not. - */ -export function isFunction(obj: unknown): obj is Function { - return (typeof obj === 'function'); -} - -/** - * @returns whether the provided parameters is are JavaScript Function or not. - */ -export function areFunctions(...objects: unknown[]): boolean { - return objects.length > 0 && objects.every(isFunction); -} - -export type TypeConstraint = string | Function; - -export function validateConstraints(args: unknown[], constraints: Array): void { - const len = Math.min(args.length, constraints.length); - for (let i = 0; i < len; i++) { - validateConstraint(args[i], constraints[i]); - } -} - -export function validateConstraint(arg: unknown, constraint: TypeConstraint | undefined): void { - - if (isString(constraint)) { - if (typeof arg !== constraint) { - throw new Error(`argument does not match constraint: typeof ${constraint}`); - } - } else if (isFunction(constraint)) { - try { - if (arg instanceof constraint) { - return; - } - } catch { - // ignore - } - if (!isUndefinedOrNull(arg) && (arg as any).constructor === constraint) { - return; - } - if (constraint.length === 1 && constraint.call(undefined, arg) === true) { - return; - } - throw new Error(`argument does not match one of these constraints: arg instanceof constraint, arg.constructor === constraint, nor constraint(arg) === true`); - } -} - -type AddFirstParameterToFunction = T extends (...args: any[]) => TargetFunctionsReturnType ? - // Function: add param to function - (firstArg: FirstParameter, ...args: Parameters) => ReturnType : - - // Else: just leave as is - T; - -/** - * Allows to add a first parameter to functions of a type. - */ -export type AddFirstParameterToFunctions = { - // For every property - [K in keyof Target]: AddFirstParameterToFunction; -}; - -/** - * Given an object with all optional properties, requires at least one to be defined. - * i.e. AtLeastOne; - */ -export type AtLeastOne }> = Partial & U[keyof U]; - -/** - * Only picks the non-optional properties of a type. - */ -export type OmitOptional = { [K in keyof T as T[K] extends Required[K] ? K : never]: T[K] }; - -/** - * A type that removed readonly-less from all properties of `T` - */ -export type Mutable = { - -readonly [P in keyof T]: T[P] -}; - -/** - * A single object or an array of the objects. - */ -export type SingleOrMany = T | T[]; - - -/** - * A type that recursively makes all properties of `T` required - */ -export type DeepRequiredNonNullable = { - [P in keyof T]-?: T[P] extends object ? DeepRequiredNonNullable : Required>; -}; - - -/** - * Represents a type that is a partial version of a given type `T`, where all properties are optional and can be deeply nested. - */ -export type DeepPartial = { - [P in keyof T]?: T[P] extends object ? DeepPartial : Partial; -}; - -/** - * Represents a type that is a partial version of a given type `T`, except a subset. - */ -export type PartialExcept = Partial> & Pick; diff --git a/src/vs/base/common/uuid.ts b/src/vs/base/common/uuid.ts deleted file mode 100644 index 0bd0c937ca..0000000000 --- a/src/vs/base/common/uuid.ts +++ /dev/null @@ -1,81 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - - -const _UUIDPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; - -export function isUUID(value: string): boolean { - return _UUIDPattern.test(value); -} - -declare const crypto: undefined | { - //https://developer.mozilla.org/en-US/docs/Web/API/Crypto/getRandomValues#browser_compatibility - getRandomValues?(data: Uint8Array): Uint8Array; - //https://developer.mozilla.org/en-US/docs/Web/API/Crypto/randomUUID#browser_compatibility - randomUUID?(): string; -}; - -export const generateUuid = (function (): () => string { - - // use `randomUUID` if possible - if (typeof crypto === 'object' && typeof crypto.randomUUID === 'function') { - return crypto.randomUUID.bind(crypto); - } - - // use `randomValues` if possible - let getRandomValues: (bucket: Uint8Array) => Uint8Array; - if (typeof crypto === 'object' && typeof crypto.getRandomValues === 'function') { - getRandomValues = crypto.getRandomValues.bind(crypto); - - } else { - getRandomValues = function (bucket: Uint8Array): Uint8Array { - for (let i = 0; i < bucket.length; i++) { - bucket[i] = Math.floor(Math.random() * 256); - } - return bucket; - }; - } - - // prep-work - const _data = new Uint8Array(16); - const _hex: string[] = []; - for (let i = 0; i < 256; i++) { - _hex.push(i.toString(16).padStart(2, '0')); - } - - return function generateUuid(): string { - // get data - getRandomValues(_data); - - // set version bits - _data[6] = (_data[6] & 0x0f) | 0x40; - _data[8] = (_data[8] & 0x3f) | 0x80; - - // print as string - let i = 0; - let result = ''; - result += _hex[_data[i++]]; - result += _hex[_data[i++]]; - result += _hex[_data[i++]]; - result += _hex[_data[i++]]; - result += '-'; - result += _hex[_data[i++]]; - result += _hex[_data[i++]]; - result += '-'; - result += _hex[_data[i++]]; - result += _hex[_data[i++]]; - result += '-'; - result += _hex[_data[i++]]; - result += _hex[_data[i++]]; - result += '-'; - result += _hex[_data[i++]]; - result += _hex[_data[i++]]; - result += _hex[_data[i++]]; - result += _hex[_data[i++]]; - result += _hex[_data[i++]]; - result += _hex[_data[i++]]; - return result; - }; -})(); diff --git a/src/vs/base/common/verifier.ts b/src/vs/base/common/verifier.ts deleted file mode 100644 index cf77d1800c..0000000000 --- a/src/vs/base/common/verifier.ts +++ /dev/null @@ -1,87 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { isObject } from 'vs/base/common/types'; - -interface IVerifier { - verify(value: unknown): T; -} - -abstract class Verifier implements IVerifier { - - constructor(protected readonly defaultValue: T) { } - - verify(value: unknown): T { - if (!this.isType(value)) { - return this.defaultValue; - } - - return value; - } - - protected abstract isType(value: unknown): value is T; -} - -export class BooleanVerifier extends Verifier { - protected isType(value: unknown): value is boolean { - return typeof value === 'boolean'; - } -} - -export class NumberVerifier extends Verifier { - protected isType(value: unknown): value is number { - return typeof value === 'number'; - } -} - -export class SetVerifier extends Verifier> { - protected isType(value: unknown): value is Set { - return value instanceof Set; - } -} - -export class EnumVerifier extends Verifier { - private readonly allowedValues: ReadonlyArray; - - constructor(defaultValue: T, allowedValues: ReadonlyArray) { - super(defaultValue); - this.allowedValues = allowedValues; - } - - protected isType(value: unknown): value is T { - return this.allowedValues.includes(value as T); - } -} - -export class ObjectVerifier extends Verifier { - - constructor(defaultValue: T, private readonly verifier: { [K in keyof T]: IVerifier }) { - super(defaultValue); - } - - override verify(value: unknown): T { - if (!this.isType(value)) { - return this.defaultValue; - } - return verifyObject(this.verifier, value); - } - - protected isType(value: unknown): value is T { - return isObject(value); - } -} - -export function verifyObject(verifiers: { [K in keyof T]: IVerifier }, value: Object): T { - const result = Object.create(null); - - for (const key in verifiers) { - if (Object.hasOwnProperty.call(verifiers, key)) { - const verifier = verifiers[key]; - result[key] = verifier.verify((value as any)[key]); - } - } - - return result; -} From 381200bce1241ce415a598714d2746a3d4c55ea1 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Tue, 9 Jul 2024 13:34:51 -0700 Subject: [PATCH 07/40] Fix fit addon to get the scroll bar working in the demo --- addons/addon-fit/src/FitAddon.ts | 5 ++- bin/esbuild.mjs | 2 - css/xterm.css | 77 ++++++++++++++++++++++++++++++++ 3 files changed, 80 insertions(+), 4 deletions(-) diff --git a/addons/addon-fit/src/FitAddon.ts b/addons/addon-fit/src/FitAddon.ts index 2087e6146a..9cef244e5e 100644 --- a/addons/addon-fit/src/FitAddon.ts +++ b/addons/addon-fit/src/FitAddon.ts @@ -64,8 +64,9 @@ export class FitAddon implements ITerminalAddon , IFitApi { return undefined; } - const scrollbarWidth = this._terminal.options.scrollback === 0 ? - 0 : core.viewport.scrollBarWidth; + const scrollbarWidth = (this._terminal.options.scrollback === 0 + ? 0 + : (this._terminal.options.overviewRulerWidth || 14)); const parentElementStyle = window.getComputedStyle(this._terminal.element.parentElement); const parentElementHeight = parseInt(parentElementStyle.getPropertyValue('height')); diff --git a/bin/esbuild.mjs b/bin/esbuild.mjs index 9aaac06eab..8d7ee76c5f 100644 --- a/bin/esbuild.mjs +++ b/bin/esbuild.mjs @@ -188,8 +188,6 @@ if (config.addon) { }; } -console.log('Building bundle with config:', JSON.stringify(bundleConfig, undefined, 2)); - if (config.isWatch) { context(bundleConfig).then(e => e.watch()); if (!skipOut) { diff --git a/css/xterm.css b/css/xterm.css index ef39dfea60..3f413d9507 100644 --- a/css/xterm.css +++ b/css/xterm.css @@ -218,3 +218,80 @@ z-index: 2; position: relative; } + + + +/* Derived from vs/base/browser/ui/scrollbar/media/scrollbar.css */ + +/* xterm.js customization: Override xterm's cursor style */ +.xterm .monaco-scrollable-element > .scrollbar { + cursor: default; +} + +/* Arrows */ +.xterm .monaco-scrollable-element > .scrollbar > .scra { + cursor: pointer; + font-size: 11px !important; +} + +.xterm .monaco-scrollable-element > .visible { + opacity: 1; + + /* Background rule added for IE9 - to allow clicks on dom node */ + background:rgba(0,0,0,0); + + transition: opacity 100ms linear; + /* In front of peek view */ + z-index: 11; +} +.xterm .monaco-scrollable-element > .invisible { + opacity: 0; + pointer-events: none; +} +.xterm .monaco-scrollable-element > .invisible.fade { + transition: opacity 800ms linear; +} + +/* Scrollable Content Inset Shadow */ +.xterm .monaco-scrollable-element > .shadow { + position: absolute; + display: none; +} +.xterm .monaco-scrollable-element > .shadow.top { + display: block; + top: 0; + left: 3px; + height: 3px; + width: 100%; + box-shadow: var(--vscode-scrollbar-shadow, #000) 0 6px 6px -6px inset; +} +.xterm .monaco-scrollable-element > .shadow.left { + display: block; + top: 3px; + left: 0; + height: 100%; + width: 3px; + box-shadow: var(--vscode-scrollbar-shadow, #000) 6px 0 6px -6px inset; +} +.xterm .monaco-scrollable-element > .shadow.top-left-corner { + display: block; + top: 0; + left: 0; + height: 3px; + width: 3px; +} +.xterm .monaco-scrollable-element > .shadow.top.left { + box-shadow: var(--vscode-scrollbar-shadow, #000) 6px 0 6px -6px inset; +} + +.xterm .monaco-scrollable-element > .scrollbar > .slider { + background: var(--vscode-scrollbarSlider-background, #79797966); +} + +.xterm .monaco-scrollable-element > .scrollbar > .slider:hover { + background: var(--vscode-scrollbarSlider-hoverBackground, #646464b3); +} + +.xterm .monaco-scrollable-element > .scrollbar > .slider.active { + background: var(--vscode-scrollbarSlider-activeBackground, #bfbfbf66); +} From b401865957ad88b4992c52af08e930f5cb88c059 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Tue, 9 Jul 2024 13:53:16 -0700 Subject: [PATCH 08/40] More clean up, doc updating vs/base --- ...d_used_files.js => vs_base_find_unused.js} | 18 +- ...{update_vs_base.ps1 => vs_base_update.ps1} | 0 src/vs/README.md | 20 +- src/vs/base/browser/performance.ts | 272 ------------------ src/vs/base/browser/pixelRatio.ts | 114 -------- src/vs/base/browser/trustedTypes.ts | 35 --- .../browser/ui/scrollbar/media/scrollbars.css | 72 ----- 7 files changed, 34 insertions(+), 497 deletions(-) rename bin/{vs_base_find_used_files.js => vs_base_find_unused.js} (54%) rename bin/{update_vs_base.ps1 => vs_base_update.ps1} (100%) delete mode 100644 src/vs/base/browser/performance.ts delete mode 100644 src/vs/base/browser/pixelRatio.ts delete mode 100644 src/vs/base/browser/trustedTypes.ts delete mode 100644 src/vs/base/browser/ui/scrollbar/media/scrollbars.css diff --git a/bin/vs_base_find_used_files.js b/bin/vs_base_find_unused.js similarity index 54% rename from bin/vs_base_find_used_files.js rename to bin/vs_base_find_unused.js index 50ffaf4f5e..572cca09ad 100644 --- a/bin/vs_base_find_used_files.js +++ b/bin/vs_base_find_unused.js @@ -2,6 +2,7 @@ const { dirname } = require("path"); const ts = require("typescript"); +const fs = require("fs"); function findUnusedSymbols( /** @type string */ tsconfigPath @@ -17,7 +18,22 @@ function findUnusedSymbols( }); const sourceFiles = program.getSourceFiles(); const usedBaseSourceFiles = sourceFiles.filter(e => e.fileName.includes('src/vs/base/')); - console.log('Source files used in src/vs/base/:', usedBaseSourceFiles.map(e => e.fileName.replace(/^.+\/src\//, 'src/')).sort((a, b) => a.localeCompare(b))); + const usedFilesInBase = usedBaseSourceFiles.map(e => e.fileName.replace(/^.+\/src\//, 'src/')).sort((a, b) => a.localeCompare(b)); + // console.log('Source files used in src/vs/base/:', used); + + // Get an array of all files that exist in src/vs/base/ + const allFilesInBase = ( + fs.readdirSync('src/vs/base', { recursive: true, withFileTypes: true }) + .filter(e => e.isFile()) + .map(e => `${e.parentPath}/${e.name}`.replace(/\\/g, '/')) + ); + const unusedFilesInBase = allFilesInBase.filter(e => !usedFilesInBase.includes(e)); + + console.log({ + allFilesInBase, + usedFilesInBase, + unusedFilesInBase + }); } // Example usage diff --git a/bin/update_vs_base.ps1 b/bin/vs_base_update.ps1 similarity index 100% rename from bin/update_vs_base.ps1 rename to bin/vs_base_update.ps1 diff --git a/src/vs/README.md b/src/vs/README.md index 002749b3c6..860ba53e3a 100644 --- a/src/vs/README.md +++ b/src/vs/README.md @@ -1,9 +1,23 @@ This folder contains the `base/` module from the [Visual Studio Code repository](https://github.com/microsoft/vscode) which has many helpers that are useful to xterm.js. -To update against upstream: +Rarely we want to update these sources when an important bug is fixed upstream or when there is a new feature we want to leverage. To update against upstream: ``` -./bin/update_vs_base.ps1 +./bin/vs_base_update.ps1 ``` -TODO: Mention review `src/vs/base` in xterm.js +If new functions are being used from the project then import them from another project. + +Before committing we need to clean up the diff so that files that aren't being used are not inlcuded. The following script uses the typescript compiler to find any files that are not being imported into the project: + +``` +node ./bin/vs_base_find_unused.js +``` + +The last step is to do a once over of the resulting bundled xterm.js file to ensure it isn't too large: + +1. Run `yarn esbuild` +2. Open up `xterm.mjs` +3. Search for `src/vs/base/` + +This will show you all the parts of base that will be included in the final minified bundle. Unfortunately tree shaking doesn't find everything, be on the lookout for large arrays or classes that aren't being used. If your editor has find decorations in the scroll bar it's easy to find which parts of base are consuming a lot of lines. diff --git a/src/vs/base/browser/performance.ts b/src/vs/base/browser/performance.ts deleted file mode 100644 index dab46447a7..0000000000 --- a/src/vs/base/browser/performance.ts +++ /dev/null @@ -1,272 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -export namespace inputLatency { - - // Measurements are recorded as totals, the average is calculated when the final measurements - // are created. - interface ICumulativeMeasurement { - total: number; - min: number; - max: number; - } - const totalKeydownTime: ICumulativeMeasurement = { total: 0, min: Number.MAX_VALUE, max: 0 }; - const totalInputTime: ICumulativeMeasurement = { ...totalKeydownTime }; - const totalRenderTime: ICumulativeMeasurement = { ...totalKeydownTime }; - const totalInputLatencyTime: ICumulativeMeasurement = { ...totalKeydownTime }; - let measurementsCount = 0; - - - - // The state of each event, this helps ensure the integrity of the measurement and that - // something unexpected didn't happen that could skew the measurement. - const enum EventPhase { - Before = 0, - InProgress = 1, - Finished = 2 - } - const state = { - keydown: EventPhase.Before, - input: EventPhase.Before, - render: EventPhase.Before, - }; - - /** - * Record the start of the keydown event. - */ - export function onKeyDown() { - /** Direct Check C. See explanation in {@link recordIfFinished} */ - recordIfFinished(); - performance.mark('inputlatency/start'); - performance.mark('keydown/start'); - state.keydown = EventPhase.InProgress; - queueMicrotask(markKeyDownEnd); - } - - /** - * Mark the end of the keydown event. - */ - function markKeyDownEnd() { - if (state.keydown === EventPhase.InProgress) { - performance.mark('keydown/end'); - state.keydown = EventPhase.Finished; - } - } - - /** - * Record the start of the beforeinput event. - */ - export function onBeforeInput() { - performance.mark('input/start'); - state.input = EventPhase.InProgress; - /** Schedule Task A. See explanation in {@link recordIfFinished} */ - scheduleRecordIfFinishedTask(); - } - - /** - * Record the start of the input event. - */ - export function onInput() { - if (state.input === EventPhase.Before) { - // it looks like we didn't receive a `beforeinput` - onBeforeInput(); - } - queueMicrotask(markInputEnd); - } - - function markInputEnd() { - if (state.input === EventPhase.InProgress) { - performance.mark('input/end'); - state.input = EventPhase.Finished; - } - } - - /** - * Record the start of the keyup event. - */ - export function onKeyUp() { - /** Direct Check D. See explanation in {@link recordIfFinished} */ - recordIfFinished(); - } - - /** - * Record the start of the selectionchange event. - */ - export function onSelectionChange() { - /** Direct Check E. See explanation in {@link recordIfFinished} */ - recordIfFinished(); - } - - /** - * Record the start of the animation frame performing the rendering. - */ - export function onRenderStart() { - // Render may be triggered during input, but we only measure the following animation frame - if (state.keydown === EventPhase.Finished && state.input === EventPhase.Finished && state.render === EventPhase.Before) { - // Only measure the first render after keyboard input - performance.mark('render/start'); - state.render = EventPhase.InProgress; - queueMicrotask(markRenderEnd); - /** Schedule Task B. See explanation in {@link recordIfFinished} */ - scheduleRecordIfFinishedTask(); - } - } - - /** - * Mark the end of the animation frame performing the rendering. - */ - function markRenderEnd() { - if (state.render === EventPhase.InProgress) { - performance.mark('render/end'); - state.render = EventPhase.Finished; - } - } - - function scheduleRecordIfFinishedTask() { - // Here we can safely assume that the `setTimeout` will not be - // artificially delayed by 4ms because we schedule it from - // event handlers - setTimeout(recordIfFinished); - } - - /** - * Record the input latency sample if input handling and rendering are finished. - * - * The challenge here is that we want to record the latency in such a way that it includes - * also the layout and painting work the browser does during the animation frame task. - * - * Simply scheduling a new task (via `setTimeout`) from the animation frame task would - * schedule the new task at the end of the task queue (after other code that uses `setTimeout`), - * so we need to use multiple strategies to make sure our task runs before others: - * - * We schedule tasks (A and B): - * - we schedule a task A (via a `setTimeout` call) when the input starts in `markInputStart`. - * If the animation frame task is scheduled quickly by the browser, then task A has a very good - * chance of being the very first task after the animation frame and thus will record the input latency. - * - however, if the animation frame task is scheduled a bit later, then task A might execute - * before the animation frame task. We therefore schedule another task B from `markRenderStart`. - * - * We do direct checks in browser event handlers (C, D, E): - * - if the browser has multiple keydown events queued up, they will be scheduled before the `setTimeout` tasks, - * so we do a direct check in the keydown event handler (C). - * - depending on timing, sometimes the animation frame is scheduled even before the `keyup` event, so we - * do a direct check there too (E). - * - the browser oftentimes emits a `selectionchange` event after an `input`, so we do a direct check there (D). - */ - function recordIfFinished() { - if (state.keydown === EventPhase.Finished && state.input === EventPhase.Finished && state.render === EventPhase.Finished) { - performance.mark('inputlatency/end'); - - performance.measure('keydown', 'keydown/start', 'keydown/end'); - performance.measure('input', 'input/start', 'input/end'); - performance.measure('render', 'render/start', 'render/end'); - performance.measure('inputlatency', 'inputlatency/start', 'inputlatency/end'); - - addMeasure('keydown', totalKeydownTime); - addMeasure('input', totalInputTime); - addMeasure('render', totalRenderTime); - addMeasure('inputlatency', totalInputLatencyTime); - - // console.info( - // `input latency=${performance.getEntriesByName('inputlatency')[0].duration.toFixed(1)} [` + - // `keydown=${performance.getEntriesByName('keydown')[0].duration.toFixed(1)}, ` + - // `input=${performance.getEntriesByName('input')[0].duration.toFixed(1)}, ` + - // `render=${performance.getEntriesByName('render')[0].duration.toFixed(1)}` + - // `]` - // ); - - measurementsCount++; - - reset(); - } - } - - function addMeasure(entryName: string, cumulativeMeasurement: ICumulativeMeasurement): void { - const duration = performance.getEntriesByName(entryName)[0].duration; - cumulativeMeasurement.total += duration; - cumulativeMeasurement.min = Math.min(cumulativeMeasurement.min, duration); - cumulativeMeasurement.max = Math.max(cumulativeMeasurement.max, duration); - } - - /** - * Clear the current sample. - */ - function reset() { - performance.clearMarks('keydown/start'); - performance.clearMarks('keydown/end'); - performance.clearMarks('input/start'); - performance.clearMarks('input/end'); - performance.clearMarks('render/start'); - performance.clearMarks('render/end'); - performance.clearMarks('inputlatency/start'); - performance.clearMarks('inputlatency/end'); - - performance.clearMeasures('keydown'); - performance.clearMeasures('input'); - performance.clearMeasures('render'); - performance.clearMeasures('inputlatency'); - - state.keydown = EventPhase.Before; - state.input = EventPhase.Before; - state.render = EventPhase.Before; - } - - export interface IInputLatencyMeasurements { - keydown: IInputLatencySingleMeasurement; - input: IInputLatencySingleMeasurement; - render: IInputLatencySingleMeasurement; - total: IInputLatencySingleMeasurement; - sampleCount: number; - } - - export interface IInputLatencySingleMeasurement { - average: number; - min: number; - max: number; - } - - /** - * Gets all input latency samples and clears the internal buffers to start recording a new set - * of samples. - */ - export function getAndClearMeasurements(): IInputLatencyMeasurements | undefined { - if (measurementsCount === 0) { - return undefined; - } - - // Assemble the result - const result = { - keydown: cumulativeToFinalMeasurement(totalKeydownTime), - input: cumulativeToFinalMeasurement(totalInputTime), - render: cumulativeToFinalMeasurement(totalRenderTime), - total: cumulativeToFinalMeasurement(totalInputLatencyTime), - sampleCount: measurementsCount - }; - - // Clear the cumulative measurements - clearCumulativeMeasurement(totalKeydownTime); - clearCumulativeMeasurement(totalInputTime); - clearCumulativeMeasurement(totalRenderTime); - clearCumulativeMeasurement(totalInputLatencyTime); - measurementsCount = 0; - - return result; - } - - function cumulativeToFinalMeasurement(cumulative: ICumulativeMeasurement): IInputLatencySingleMeasurement { - return { - average: cumulative.total / measurementsCount, - max: cumulative.max, - min: cumulative.min, - }; - } - - function clearCumulativeMeasurement(cumulative: ICumulativeMeasurement): void { - cumulative.total = 0; - cumulative.min = Number.MAX_VALUE; - cumulative.max = 0; - } - -} diff --git a/src/vs/base/browser/pixelRatio.ts b/src/vs/base/browser/pixelRatio.ts deleted file mode 100644 index 197a802ff7..0000000000 --- a/src/vs/base/browser/pixelRatio.ts +++ /dev/null @@ -1,114 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { getWindowId, onDidUnregisterWindow } from 'vs/base/browser/dom'; -import { Emitter, Event } from 'vs/base/common/event'; -import { Disposable, markAsSingleton } from 'vs/base/common/lifecycle'; - -/** - * See https://developer.mozilla.org/en-US/docs/Web/API/Window/devicePixelRatio#monitoring_screen_resolution_or_zoom_level_changes - */ -class DevicePixelRatioMonitor extends Disposable { - - private readonly _onDidChange = this._register(new Emitter()); - readonly onDidChange = this._onDidChange.event; - - private readonly _listener: () => void; - private _mediaQueryList: MediaQueryList | null; - - constructor(targetWindow: Window) { - super(); - - this._listener = () => this._handleChange(targetWindow, true); - this._mediaQueryList = null; - this._handleChange(targetWindow, false); - } - - private _handleChange(targetWindow: Window, fireEvent: boolean): void { - this._mediaQueryList?.removeEventListener('change', this._listener); - - this._mediaQueryList = targetWindow.matchMedia(`(resolution: ${targetWindow.devicePixelRatio}dppx)`); - this._mediaQueryList.addEventListener('change', this._listener); - - if (fireEvent) { - this._onDidChange.fire(); - } - } -} - -export interface IPixelRatioMonitor { - readonly value: number; - readonly onDidChange: Event; -} - -class PixelRatioMonitorImpl extends Disposable implements IPixelRatioMonitor { - - private readonly _onDidChange = this._register(new Emitter()); - readonly onDidChange = this._onDidChange.event; - - private _value: number; - - get value(): number { - return this._value; - } - - constructor(targetWindow: Window) { - super(); - - this._value = this._getPixelRatio(targetWindow); - - const dprMonitor = this._register(new DevicePixelRatioMonitor(targetWindow)); - this._register(dprMonitor.onDidChange(() => { - this._value = this._getPixelRatio(targetWindow); - this._onDidChange.fire(this._value); - })); - } - - private _getPixelRatio(targetWindow: Window): number { - const ctx: any = document.createElement('canvas').getContext('2d'); - const dpr = targetWindow.devicePixelRatio || 1; - const bsr = ctx.webkitBackingStorePixelRatio || - ctx.mozBackingStorePixelRatio || - ctx.msBackingStorePixelRatio || - ctx.oBackingStorePixelRatio || - ctx.backingStorePixelRatio || 1; - return dpr / bsr; - } -} - -class PixelRatioMonitorFacade { - - private readonly mapWindowIdToPixelRatioMonitor = new Map(); - - private _getOrCreatePixelRatioMonitor(targetWindow: Window): PixelRatioMonitorImpl { - const targetWindowId = getWindowId(targetWindow); - let pixelRatioMonitor = this.mapWindowIdToPixelRatioMonitor.get(targetWindowId); - if (!pixelRatioMonitor) { - pixelRatioMonitor = markAsSingleton(new PixelRatioMonitorImpl(targetWindow)); - this.mapWindowIdToPixelRatioMonitor.set(targetWindowId, pixelRatioMonitor); - - markAsSingleton(Event.once(onDidUnregisterWindow)(({ vscodeWindowId }) => { - if (vscodeWindowId === targetWindowId) { - pixelRatioMonitor?.dispose(); - this.mapWindowIdToPixelRatioMonitor.delete(targetWindowId); - } - })); - } - return pixelRatioMonitor; - } - - getInstance(targetWindow: Window): IPixelRatioMonitor { - return this._getOrCreatePixelRatioMonitor(targetWindow); - } -} - -/** - * Returns the pixel ratio. - * - * This is useful for rendering elements at native screen resolution or for being used as - * a cache key when storing font measurements. Fonts might render differently depending on resolution - * and any measurements need to be discarded for example when a window is moved from a monitor to another. - */ -export const PixelRatio = new PixelRatioMonitorFacade(); diff --git a/src/vs/base/browser/trustedTypes.ts b/src/vs/base/browser/trustedTypes.ts deleted file mode 100644 index 0ef4b08452..0000000000 --- a/src/vs/base/browser/trustedTypes.ts +++ /dev/null @@ -1,35 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { onUnexpectedError } from 'vs/base/common/errors'; - -export function createTrustedTypesPolicy( - policyName: string, - policyOptions?: Options, -): undefined | Pick, 'name' | Extract> { - - interface IMonacoEnvironment { - createTrustedTypesPolicy( - policyName: string, - policyOptions?: Options, - ): undefined | Pick, 'name' | Extract>; - } - const monacoEnvironment: IMonacoEnvironment | undefined = (globalThis as any).MonacoEnvironment; - - if (monacoEnvironment?.createTrustedTypesPolicy) { - try { - return monacoEnvironment.createTrustedTypesPolicy(policyName, policyOptions); - } catch (err) { - onUnexpectedError(err); - return undefined; - } - } - try { - return (globalThis as any).trustedTypes?.createPolicy(policyName, policyOptions); - } catch (err) { - onUnexpectedError(err); - return undefined; - } -} diff --git a/src/vs/base/browser/ui/scrollbar/media/scrollbars.css b/src/vs/base/browser/ui/scrollbar/media/scrollbars.css deleted file mode 100644 index 84c1737089..0000000000 --- a/src/vs/base/browser/ui/scrollbar/media/scrollbars.css +++ /dev/null @@ -1,72 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -/* Arrows */ -.monaco-scrollable-element > .scrollbar > .scra { - cursor: pointer; - font-size: 11px !important; -} - -.monaco-scrollable-element > .visible { - opacity: 1; - - /* Background rule added for IE9 - to allow clicks on dom node */ - background:rgba(0,0,0,0); - - transition: opacity 100ms linear; - /* In front of peek view */ - z-index: 11; -} -.monaco-scrollable-element > .invisible { - opacity: 0; - pointer-events: none; -} -.monaco-scrollable-element > .invisible.fade { - transition: opacity 800ms linear; -} - -/* Scrollable Content Inset Shadow */ -.monaco-scrollable-element > .shadow { - position: absolute; - display: none; -} -.monaco-scrollable-element > .shadow.top { - display: block; - top: 0; - left: 3px; - height: 3px; - width: 100%; - box-shadow: var(--vscode-scrollbar-shadow) 0 6px 6px -6px inset; -} -.monaco-scrollable-element > .shadow.left { - display: block; - top: 3px; - left: 0; - height: 100%; - width: 3px; - box-shadow: var(--vscode-scrollbar-shadow) 6px 0 6px -6px inset; -} -.monaco-scrollable-element > .shadow.top-left-corner { - display: block; - top: 0; - left: 0; - height: 3px; - width: 3px; -} -.monaco-scrollable-element > .shadow.top.left { - box-shadow: var(--vscode-scrollbar-shadow) 6px 0 6px -6px inset; -} - -.monaco-scrollable-element > .scrollbar > .slider { - background: var(--vscode-scrollbarSlider-background); -} - -.monaco-scrollable-element > .scrollbar > .slider:hover { - background: var(--vscode-scrollbarSlider-hoverBackground); -} - -.monaco-scrollable-element > .scrollbar > .slider.active { - background: var(--vscode-scrollbarSlider-activeBackground); -} From 27195b003a866668de04cb73c546df33f56b57a9 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Wed, 10 Jul 2024 06:22:16 -0700 Subject: [PATCH 09/40] Patch out unsafe usage of navigator and window in node envs --- bin/esbuild.mjs | 6 +----- src/browser/Terminal.test.ts | 1 + src/vs/base/browser/browser.ts | 2 +- src/vs/base/browser/canIUse.ts | 10 ++++++---- src/vs/base/browser/window.ts | 2 +- 5 files changed, 10 insertions(+), 11 deletions(-) diff --git a/bin/esbuild.mjs b/bin/esbuild.mjs index 8d7ee76c5f..57fa356d10 100644 --- a/bin/esbuild.mjs +++ b/bin/esbuild.mjs @@ -173,11 +173,7 @@ if (config.addon) { outConfig = { ...outConfig, entryPoints: [ - `src/browser/public/Terminal.ts`, - `src/headless/public/Terminal.ts`, - `src/browser/**/*.test.ts`, - `src/common/**/*.test.ts`, - `src/headless/**/*.test.ts` + `src/**/*.ts` ], outdir: 'out-esbuild/' }; diff --git a/src/browser/Terminal.test.ts b/src/browser/Terminal.test.ts index 85a3248eef..cf09d96c5e 100644 --- a/src/browser/Terminal.test.ts +++ b/src/browser/Terminal.test.ts @@ -29,6 +29,7 @@ describe('Terminal', () => { term = new TestTerminal(termOptions); term.refresh = () => { }; (term as any).renderer = new MockRenderer(); + (term as any).viewport = new MockViewport(); (term as any)._compositionHelper = new MockCompositionHelper(); (term as any).element = { classList: { diff --git a/src/vs/base/browser/browser.ts b/src/vs/base/browser/browser.ts index 87db1c573e..a34f4791d2 100644 --- a/src/vs/base/browser/browser.ts +++ b/src/vs/base/browser/browser.ts @@ -98,7 +98,7 @@ export function isFullscreen(targetWindow: Window): boolean { } export const onDidChangeFullscreen = WindowManager.INSTANCE.onDidChangeFullscreen; -const userAgent = navigator.userAgent; +const userAgent = typeof navigator === 'object' ? navigator.userAgent : ''; export const isFirefox = (userAgent.indexOf('Firefox') >= 0); export const isWebKit = (userAgent.indexOf('AppleWebKit') >= 0); diff --git a/src/vs/base/browser/canIUse.ts b/src/vs/base/browser/canIUse.ts index 60261a974d..b5b648f6c5 100644 --- a/src/vs/base/browser/canIUse.ts +++ b/src/vs/base/browser/canIUse.ts @@ -13,6 +13,8 @@ export const enum KeyboardSupport { None } +const safeNavigator = typeof navigator === 'object' ? navigator : {} as { [key: string]: any }; + /** * Browser feature we can support in current platform, browser and environment. */ @@ -21,11 +23,11 @@ export const BrowserFeatures = { writeText: ( platform.isNative || (document.queryCommandSupported && document.queryCommandSupported('copy')) - || !!(navigator && navigator.clipboard && navigator.clipboard.writeText) + || !!(safeNavigator && safeNavigator.clipboard && safeNavigator.clipboard.writeText) ), readText: ( platform.isNative - || !!(navigator && navigator.clipboard && navigator.clipboard.readText) + || !!(safeNavigator && safeNavigator.clipboard && safeNavigator.clipboard.readText) ) }, keyboard: (() => { @@ -33,7 +35,7 @@ export const BrowserFeatures = { return KeyboardSupport.Always; } - if ((navigator).keyboard || browser.isSafari) { + if ((safeNavigator).keyboard || browser.isSafari) { return KeyboardSupport.FullScreen; } @@ -42,6 +44,6 @@ export const BrowserFeatures = { // 'ontouchstart' in window always evaluates to true with typescript's modern typings. This causes `window` to be // `never` later in `window.navigator`. That's why we need the explicit `window as Window` cast - touch: 'ontouchstart' in mainWindow || navigator.maxTouchPoints > 0, + touch: 'ontouchstart' in mainWindow || safeNavigator.maxTouchPoints > 0, pointerEvents: mainWindow.PointerEvent && ('ontouchstart' in mainWindow || navigator.maxTouchPoints > 0) }; diff --git a/src/vs/base/browser/window.ts b/src/vs/base/browser/window.ts index 3351c701d7..3a377a85b8 100644 --- a/src/vs/base/browser/window.ts +++ b/src/vs/base/browser/window.ts @@ -11,4 +11,4 @@ export function ensureCodeWindow(targetWindow: Window, fallbackWindowId: number) } // eslint-disable-next-line no-restricted-globals -export const mainWindow = window as CodeWindow; +export const mainWindow = (typeof window === 'object' ? window : globalThis) as CodeWindow; From d904f2fce56deb0e07dbf7849f13b67b2aa1b55f Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Wed, 10 Jul 2024 06:26:39 -0700 Subject: [PATCH 10/40] Generate source maps so errors report ts files --- bin/esbuild.mjs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/bin/esbuild.mjs b/bin/esbuild.mjs index 57fa356d10..65ebc124ff 100644 --- a/bin/esbuild.mjs +++ b/bin/esbuild.mjs @@ -88,13 +88,15 @@ let bundleConfig = { /** @type {esbuild.BuildOptions} */ let outConfig = { - format: 'cjs' + format: 'cjs', + sourcemap: true, } let skipOut = false; /** @type {esbuild.BuildOptions} */ let outTestConfig = { - format: 'cjs' + format: 'cjs', + sourcemap: true, } let skipOutTest = false; @@ -175,7 +177,7 @@ if (config.addon) { entryPoints: [ `src/**/*.ts` ], - outdir: 'out-esbuild/' + outdir: 'out-esbuild/', }; outTestConfig = { ...outConfig, From 1dada9f256bdbdca33ffb0a8447eb3811dff1a4a Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Wed, 10 Jul 2024 06:27:47 -0700 Subject: [PATCH 11/40] Fix scroll lines API --- src/browser/CoreBrowserTerminal.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/browser/CoreBrowserTerminal.ts b/src/browser/CoreBrowserTerminal.ts index e06afdd4bd..bd0eb4c3fd 100644 --- a/src/browser/CoreBrowserTerminal.ts +++ b/src/browser/CoreBrowserTerminal.ts @@ -870,10 +870,8 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal { } public scrollLines(disp: number, suppressScrollEvent?: boolean, source = ScrollSource.TERMINAL): void { - if (source === ScrollSource.VIEWPORT) { - super.scrollLines(disp, suppressScrollEvent, source); - this.refresh(0, this.rows - 1); - } + super.scrollLines(disp, suppressScrollEvent, source); + this.refresh(0, this.rows - 1); } public paste(data: string): void { From c3c787eaa73d5909888d089436762812dffc90d3 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Wed, 10 Jul 2024 06:29:05 -0700 Subject: [PATCH 12/40] Resolve vs imports in webpacked bundle --- webpack.config.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/webpack.config.js b/webpack.config.js index 3769bfb0fb..123e31dfbb 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -33,7 +33,8 @@ const config = { extensions: [ '.js' ], alias: { common: path.resolve('./out/common'), - browser: path.resolve('./out/browser') + browser: path.resolve('./out/browser'), + vs: path.resolve('./out/vs'), } }, output: { From 345b6906f9a84b50600e52329901709643a38ebf Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Wed, 10 Jul 2024 06:29:32 -0700 Subject: [PATCH 13/40] Remove unused demo webpack config --- demo/webpack.config.js | 62 ------------------------------------------ 1 file changed, 62 deletions(-) delete mode 100644 demo/webpack.config.js diff --git a/demo/webpack.config.js b/demo/webpack.config.js deleted file mode 100644 index 46a853949e..0000000000 --- a/demo/webpack.config.js +++ /dev/null @@ -1,62 +0,0 @@ -/** - * Copyright (c) 2019 The xterm.js authors. All rights reserved. - * @license MIT - */ - -// @ts-check - -const path = require('path'); - -/** - * This webpack config does a production build for xterm.js. It works by taking the output from tsc - * (via `yarn watch` or `yarn prebuild`) which are put into `out/` and webpacks them into a - * production mode umd library module in `lib/`. The aliases are used fix up the absolute paths - * output by tsc (because of `baseUrl` and `paths` in `tsconfig.json`. - * - * @type {import('webpack').Configuration} - */ -const config = { - entry: path.resolve(__dirname, 'client.ts'), - devtool: 'inline-source-map', - module: { - rules: [ - { - test: /\.tsx?$/, - use: 'ts-loader', - exclude: /node_modules/ - }, - { - test: /\.js$/, - use: ["source-map-loader"], - enforce: "pre", - exclude: /node_modules/ - } - ] - }, - resolve: { - modules: [ - 'node_modules', - path.resolve(__dirname, '..'), - path.resolve(__dirname, '../addons') - ], - extensions: [ '.tsx', '.ts', '.js' ], - alias: { - common: path.resolve('./out/common'), - browser: path.resolve('./out/browser') - }, - fallback: { - // The ligature modules contains fallbacks for node environments, we never want to browserify them - stream: false, - util: false, - os: false, - path: false, - fs: false - } - }, - output: { - filename: 'client-bundle.js', - path: path.resolve(__dirname, 'dist') - }, - mode: 'development' -}; -module.exports = config; From 1b0078ad20c7e8b16d76d4279c4a5e023ca4d009 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Wed, 10 Jul 2024 06:42:50 -0700 Subject: [PATCH 14/40] Exclude vs project from coverage --- bin/test_unit.js | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/bin/test_unit.js b/bin/test_unit.js index e1e48bcedb..e1cd8494b4 100644 --- a/bin/test_unit.js +++ b/bin/test_unit.js @@ -34,7 +34,14 @@ const checkCoverage = flagArgs.indexOf('--coverage') >= 0; if (checkCoverage) { flagArgs.splice(flagArgs.indexOf('--coverage'), 1); const executable = npmBinScript('nyc'); - const args = ['--check-coverage', `--lines=${COVERAGE_LINES_THRESHOLD}`, npmBinScript('mocha'), ...testFiles, ...flagArgs]; + const args = [ + '--check-coverage', + `--lines=${COVERAGE_LINES_THRESHOLD}`, + '--exclude=out-esbuild/vs/**', + npmBinScript('mocha'), + ...testFiles, + ...flagArgs + ]; console.info('executable', executable); console.info('args', args); const run = cp.spawnSync( From 522b39be864257384afac01d2ac63a40723b122a Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Wed, 10 Jul 2024 06:55:55 -0700 Subject: [PATCH 15/40] Hide more folders from explorer --- .vscode/settings.json | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 4eb4e6dc69..3bf1c69144 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -4,9 +4,11 @@ }, // Hide output files from the file explorer, comment this out to see the build output "files.exclude": { - // "**/lib": true, - // "**/out": true, - // "**/out-*": true, + "**/.nyc_output": true, + "**/lib": true, + "**/dist": true, + "**/out": true, + "**/out-*": true, }, "typescript.preferences.importModuleSpecifier": "non-relative", "typescript.preferences.quoteStyle": "single", From 8b83f44a46ab49c295867e1566f07a877db0c17d Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Wed, 10 Jul 2024 06:56:56 -0700 Subject: [PATCH 16/40] Tidy up esbuild bundle configs --- bin/esbuild.mjs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/bin/esbuild.mjs b/bin/esbuild.mjs index 65ebc124ff..aa86aa1bcd 100644 --- a/bin/esbuild.mjs +++ b/bin/esbuild.mjs @@ -20,23 +20,22 @@ const config = { /** @type {esbuild.BuildOptions} */ const commonOptions = { + bundle: true, format: 'esm', target: 'es2021', sourcemap: true, + treeShaking: true, logLevel: 'debug', }; /** @type {esbuild.BuildOptions} */ const devOptions = { minify: false, - treeShaking: true, }; /** @type {esbuild.BuildOptions} */ const prodOptions = { minify: true, - treeShaking: true, - logLevel: 'debug', legalComments: 'none', // TODO: Mangling private and protected properties will reduce bundle size quite a bit, we must // make sure we don't cast privates to `any` in order to prevent regressions. @@ -81,7 +80,6 @@ function getAddonEntryPoint(addon) { /** @type {esbuild.BuildOptions} */ let bundleConfig = { - bundle: true, ...commonOptions, ...(config.isProd ? prodOptions : devOptions) }; From c2b90c6daebd49527b117268546a9c116c3ae3cf Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Wed, 10 Jul 2024 06:58:45 -0700 Subject: [PATCH 17/40] Remove missing demo webpack npm script --- package.json | 1 - 1 file changed, 1 deletion(-) diff --git a/package.json b/package.json index e5ae2b2a49..a367d3f0fc 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,6 @@ "presetup": "npm run install-addons", "install-addons": "node ./bin/install-addons.js", "start": "node demo/start", - "build-demo": "webpack --config ./demo/webpack.config.js", "build": "npm run tsc", "watch": "npm run tsc-watch", "tsc": "tsc -b ./tsconfig.all.json", From 416f12fcc3d738eaca1c60cb828ddc9733ec03a1 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Wed, 10 Jul 2024 07:01:44 -0700 Subject: [PATCH 18/40] Remove ScrollSource This isn't used anymore, it was used earlier to workaround recursion between the viewport and the terminal objects --- src/browser/CoreBrowserTerminal.ts | 16 ++++++++-------- src/browser/Terminal.test.ts | 7 +++---- src/common/CoreTerminal.ts | 11 +++++------ src/common/Types.ts | 6 ------ src/common/services/BufferService.ts | 4 ++-- src/common/services/Services.ts | 6 +++--- src/headless/Terminal.ts | 4 ++-- 7 files changed, 23 insertions(+), 31 deletions(-) diff --git a/src/browser/CoreBrowserTerminal.ts b/src/browser/CoreBrowserTerminal.ts index bd0eb4c3fd..7944502cab 100644 --- a/src/browser/CoreBrowserTerminal.ts +++ b/src/browser/CoreBrowserTerminal.ts @@ -21,9 +21,9 @@ * http://linux.die.net/man/7/urxvt */ +import { IDecoration, IDecorationOptions, IDisposable, ILinkProvider, IMarker } from '@xterm/xterm'; import { copyHandler, handlePasteEvent, moveTextAreaUnderMouseCursor, paste, rightClickHandler } from 'browser/Clipboard'; import { addDisposableDomListener } from 'browser/Lifecycle'; -import { Linkifier } from './Linkifier'; import * as Strings from 'browser/LocalizableStrings'; import { OscLinkProvider } from 'browser/OscLinkProvider'; import { CharacterJoinerHandler, CustomKeyEventHandler, CustomWheelEventHandler, IBrowser, IBufferRange, ICompositionHelper, ILinkifier2, ITerminal } from 'browser/Types'; @@ -36,6 +36,7 @@ import { IRenderer } from 'browser/renderer/shared/Types'; import { CharSizeService } from 'browser/services/CharSizeService'; import { CharacterJoinerService } from 'browser/services/CharacterJoinerService'; import { CoreBrowserService } from 'browser/services/CoreBrowserService'; +import { LinkProviderService } from 'browser/services/LinkProviderService'; import { MouseService } from 'browser/services/MouseService'; import { RenderService } from 'browser/services/RenderService'; import { SelectionService } from 'browser/services/SelectionService'; @@ -46,7 +47,7 @@ import { CoreTerminal } from 'common/CoreTerminal'; import { EventEmitter, IEvent, forwardEvent } from 'common/EventEmitter'; import { MutableDisposable, toDisposable } from 'common/Lifecycle'; import * as Browser from 'common/Platform'; -import { ColorRequestType, CoreMouseAction, CoreMouseButton, CoreMouseEventType, IColorEvent, ITerminalOptions, KeyboardResultType, ScrollSource, SpecialColorIndex } from 'common/Types'; +import { ColorRequestType, CoreMouseAction, CoreMouseButton, CoreMouseEventType, IColorEvent, ITerminalOptions, KeyboardResultType, SpecialColorIndex } from 'common/Types'; import { DEFAULT_ATTR_DATA } from 'common/buffer/BufferLine'; import { IBuffer } from 'common/buffer/Types'; import { C0, C1_ESCAPED } from 'common/data/EscapeSequences'; @@ -54,10 +55,9 @@ import { evaluateKeyboardEvent } from 'common/input/Keyboard'; import { toRgbString } from 'common/input/XParseColor'; import { DecorationService } from 'common/services/DecorationService'; import { IDecorationService } from 'common/services/Services'; -import { IDecoration, IDecorationOptions, IDisposable, ILinkProvider, IMarker } from '@xterm/xterm'; import { WindowsOptionsReportType } from '../common/InputHandler'; import { AccessibilityManager } from './AccessibilityManager'; -import { LinkProviderService } from 'browser/services/LinkProviderService'; +import { Linkifier } from './Linkifier'; export class CoreBrowserTerminal extends CoreTerminal implements ITerminal { public textarea: HTMLTextAreaElement | undefined; @@ -506,7 +506,7 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal { this.register(this.onFocus(() => this._renderService!.handleFocus())); const viewport = this.register(this._instantiationService.createInstance(Viewport, this.element, this.screenElement)); - this.register(viewport.onRequestScrollLines(e => this.scrollLines(e, false, ScrollSource.VIEWPORT))); + this.register(viewport.onRequestScrollLines(e => this.scrollLines(e, false))); this._selectionService = this.register(this._instantiationService.createInstance(SelectionService, this.element, @@ -869,8 +869,8 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal { } } - public scrollLines(disp: number, suppressScrollEvent?: boolean, source = ScrollSource.TERMINAL): void { - super.scrollLines(disp, suppressScrollEvent, source); + public scrollLines(disp: number, suppressScrollEvent?: boolean): void { + super.scrollLines(disp, suppressScrollEvent); this.refresh(0, this.rows - 1); } @@ -1220,7 +1220,7 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal { } // IMPORTANT: Fire scroll event before viewport is reset. This ensures embedders get the clear // scroll event and that the viewport's state will be valid for immediate writes. - this._onScroll.fire({ position: this.buffer.ydisp, source: ScrollSource.TERMINAL }); + this._onScroll.fire({ position: this.buffer.ydisp }); // TODO: Reset scrollable element? // this.viewport?.reset(); this.refresh(0, this.rows - 1); diff --git a/src/browser/Terminal.test.ts b/src/browser/Terminal.test.ts index cf09d96c5e..b871058e82 100644 --- a/src/browser/Terminal.test.ts +++ b/src/browser/Terminal.test.ts @@ -3,14 +3,13 @@ * @license MIT */ +import { MockCompositionHelper, MockRenderer, MockViewport, TestTerminal } from 'browser/TestUtils.test'; +import type { IBrowser } from 'browser/Types'; import { assert } from 'chai'; -import { MockViewport, MockCompositionHelper, MockRenderer, TestTerminal } from 'browser/TestUtils.test'; import { DEFAULT_ATTR_DATA } from 'common/buffer/BufferLine'; import { CellData } from 'common/buffer/CellData'; import { MockUnicodeService } from 'common/TestUtils.test'; -import { IMarker, ScrollSource } from 'common/Types'; -import { ICoreService } from 'common/services/Services'; -import type { IBrowser } from 'browser/Types'; +import { IMarker } from 'common/Types'; const INIT_COLS = 80; const INIT_ROWS = 24; diff --git a/src/common/CoreTerminal.ts b/src/common/CoreTerminal.ts index 327b8bc2f7..470cdb3d46 100644 --- a/src/common/CoreTerminal.ts +++ b/src/common/CoreTerminal.ts @@ -27,7 +27,7 @@ import { InstantiationService } from 'common/services/InstantiationService'; import { LogService } from 'common/services/LogService'; import { BufferService, MINIMUM_COLS, MINIMUM_ROWS } from 'common/services/BufferService'; import { OptionsService } from 'common/services/OptionsService'; -import { IDisposable, IAttributeData, ICoreTerminal, IScrollEvent, ScrollSource } from 'common/Types'; +import { IDisposable, IAttributeData, ICoreTerminal, IScrollEvent } from 'common/Types'; import { CoreService } from 'common/services/CoreService'; import { EventEmitter, IEvent, forwardEvent } from 'common/EventEmitter'; import { CoreMouseService } from 'common/services/CoreMouseService'; @@ -134,11 +134,11 @@ export abstract class CoreTerminal extends Disposable implements ICoreTerminal { this.register(this.coreService.onUserInput(() => this._writeBuffer.handleUserInput())); this.register(this.optionsService.onMultipleOptionChange(['windowsMode', 'windowsPty'], () => this._handleWindowsPtyOptionChange())); this.register(this._bufferService.onScroll(event => { - this._onScroll.fire({ position: this._bufferService.buffer.ydisp, source: ScrollSource.TERMINAL }); + this._onScroll.fire({ position: this._bufferService.buffer.ydisp }); this._inputHandler.markRangeDirty(this._bufferService.buffer.scrollTop, this._bufferService.buffer.scrollBottom); })); this.register(this._inputHandler.onScroll(event => { - this._onScroll.fire({ position: this._bufferService.buffer.ydisp, source: ScrollSource.TERMINAL }); + this._onScroll.fire({ position: this._bufferService.buffer.ydisp }); this._inputHandler.markRangeDirty(this._bufferService.buffer.scrollTop, this._bufferService.buffer.scrollBottom); })); @@ -198,10 +198,9 @@ export abstract class CoreTerminal extends Disposable implements ICoreTerminal { * @param suppressScrollEvent Don't emit the scroll event as scrollLines. This is used to avoid * unwanted events being handled by the viewport when the event was triggered from the viewport * originally. - * @param source Which component the event came from. */ - public scrollLines(disp: number, suppressScrollEvent?: boolean, source?: ScrollSource): void { - this._bufferService.scrollLines(disp, suppressScrollEvent, source); + public scrollLines(disp: number, suppressScrollEvent?: boolean): void { + this._bufferService.scrollLines(disp, suppressScrollEvent); } public scrollPages(pageCount: number): void { diff --git a/src/common/Types.ts b/src/common/Types.ts index 8b32067e9c..f98a7d3eb7 100644 --- a/src/common/Types.ts +++ b/src/common/Types.ts @@ -60,12 +60,6 @@ export interface IKeyboardEvent { export interface IScrollEvent { position: number; - source: ScrollSource; -} - -export const enum ScrollSource { - TERMINAL, - VIEWPORT, } export interface ICircularList { diff --git a/src/common/services/BufferService.ts b/src/common/services/BufferService.ts index d20d0ceac0..77af447a69 100644 --- a/src/common/services/BufferService.ts +++ b/src/common/services/BufferService.ts @@ -5,7 +5,7 @@ import { EventEmitter } from 'common/EventEmitter'; import { Disposable } from 'common/Lifecycle'; -import { IAttributeData, IBufferLine, ScrollSource } from 'common/Types'; +import { IAttributeData, IBufferLine } from 'common/Types'; import { BufferSet } from 'common/buffer/BufferSet'; import { IBuffer, IBufferSet } from 'common/buffer/Types'; import { IBufferService, IOptionsService } from 'common/services/Services'; @@ -125,7 +125,7 @@ export class BufferService extends Disposable implements IBufferService { * to avoid unwanted events being handled by the viewport when the event was triggered from the * viewport originally. */ - public scrollLines(disp: number, suppressScrollEvent?: boolean, source?: ScrollSource): void { + public scrollLines(disp: number, suppressScrollEvent?: boolean): void { const buffer = this.buffer; if (disp < 0) { if (buffer.ydisp === 0) { diff --git a/src/common/services/Services.ts b/src/common/services/Services.ts index 210a0afb08..842d482c5e 100644 --- a/src/common/services/Services.ts +++ b/src/common/services/Services.ts @@ -3,11 +3,11 @@ * @license MIT */ +import { IDecoration, IDecorationOptions, ILinkHandler, ILogger, IWindowsPty } from '@xterm/xterm'; import { IEvent, IEventEmitter } from 'common/EventEmitter'; +import { CoreMouseEncoding, CoreMouseEventType, CursorInactiveStyle, CursorStyle, IAttributeData, ICharset, IColor, ICoreMouseEvent, ICoreMouseProtocol, IDecPrivateModes, IDisposable, IModes, IOscLinkData, IWindowOptions } from 'common/Types'; import { IBuffer, IBufferSet } from 'common/buffer/Types'; -import { IDecPrivateModes, ICoreMouseEvent, CoreMouseEncoding, ICoreMouseProtocol, CoreMouseEventType, ICharset, IWindowOptions, IModes, IAttributeData, ScrollSource, IDisposable, IColor, CursorStyle, CursorInactiveStyle, IOscLinkData } from 'common/Types'; import { createDecorator } from 'common/services/ServiceRegistry'; -import { IDecorationOptions, IDecoration, ILinkHandler, IWindowsPty, ILogger } from '@xterm/xterm'; export const IBufferService = createDecorator('BufferService'); export interface IBufferService { @@ -21,7 +21,7 @@ export interface IBufferService { onResize: IEvent<{ cols: number, rows: number }>; onScroll: IEvent; scroll(eraseAttr: IAttributeData, isWrapped?: boolean): void; - scrollLines(disp: number, suppressScrollEvent?: boolean, source?: ScrollSource): void; + scrollLines(disp: number, suppressScrollEvent?: boolean): void; resize(cols: number, rows: number): void; reset(): void; } diff --git a/src/headless/Terminal.ts b/src/headless/Terminal.ts index 66040756f6..0b078ba8f6 100644 --- a/src/headless/Terminal.ts +++ b/src/headless/Terminal.ts @@ -25,7 +25,7 @@ import { DEFAULT_ATTR_DATA } from 'common/buffer/BufferLine'; import { IBuffer } from 'common/buffer/Types'; import { CoreTerminal } from 'common/CoreTerminal'; import { EventEmitter, forwardEvent } from 'common/EventEmitter'; -import { IMarker, ITerminalOptions, ScrollSource } from 'common/Types'; +import { IMarker, ITerminalOptions } from 'common/Types'; export class Terminal extends CoreTerminal { private readonly _onBell = this.register(new EventEmitter()); @@ -115,7 +115,7 @@ export class Terminal extends CoreTerminal { for (let i = 1; i < this.rows; i++) { this.buffer.lines.push(this.buffer.getBlankLine(DEFAULT_ATTR_DATA)); } - this._onScroll.fire({ position: this.buffer.ydisp, source: ScrollSource.TERMINAL }); + this._onScroll.fire({ position: this.buffer.ydisp }); } /** From 75bf192af579510a757cceff8c9b30a2f2f4d6e2 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Wed, 10 Jul 2024 07:03:51 -0700 Subject: [PATCH 19/40] Have scroll bar bg react to theme change --- src/browser/Viewport.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/browser/Viewport.ts b/src/browser/Viewport.ts index 211d7504f2..62415a4206 100644 --- a/src/browser/Viewport.ts +++ b/src/browser/Viewport.ts @@ -4,7 +4,7 @@ */ import { IRenderService, IThemeService } from 'browser/services/Services'; -import { EventEmitter } from 'common/EventEmitter'; +import { EventEmitter, runAndSubscribe } from 'common/EventEmitter'; import { Disposable } from 'common/Lifecycle'; import { IBufferService, IOptionsService } from 'common/services/Services'; import { DomScrollableElement } from 'vs/base/browser/ui/scrollbar/scrollableElement'; @@ -52,7 +52,9 @@ export class Viewport extends Disposable{ ], () => this._scrollableElement.updateOptions(this._getMutableOptions()))); this._scrollableElement.setScrollDimensions({ height: 0, scrollHeight: 0 }); - this._scrollableElement.getDomNode().style.backgroundColor = themeService.colors.background.css; + this.register(runAndSubscribe(themeService.onChangeColors, () => { + this._scrollableElement.getDomNode().style.backgroundColor = themeService.colors.background.css; + })); element.appendChild(this._scrollableElement.getDomNode()); this.register(this._bufferService.onResize(() => this._queueSync())); From 9b26bb44366b7629c908cacebcda7780bd25515a Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Wed, 10 Jul 2024 07:30:38 -0700 Subject: [PATCH 20/40] Support theming of overview ruler border --- demo/client.ts | 1 + src/browser/Types.ts | 1 + src/browser/Viewport.ts | 1 - src/browser/decorations/OverviewRulerRenderer.ts | 7 ++++--- src/browser/renderer/shared/CharAtlasUtils.ts | 1 + src/browser/services/ThemeService.ts | 3 +++ src/common/services/Services.ts | 1 + typings/xterm.d.ts | 6 ++++++ 8 files changed, 17 insertions(+), 4 deletions(-) diff --git a/demo/client.ts b/demo/client.ts index 0e794f3ed4..6ad9782f62 100644 --- a/demo/client.ts +++ b/demo/client.ts @@ -575,6 +575,7 @@ function initOptions(term: Terminal): void { cursor: '#333333', cursorAccent: '#ffffff', selectionBackground: '#add6ff', + overviewRulerBorder: '#aaaaaa', black: '#000000', blue: '#0451a5', brightBlack: '#666666', diff --git a/src/browser/Types.ts b/src/browser/Types.ts index fd0bd9515d..7000c9e343 100644 --- a/src/browser/Types.ts +++ b/src/browser/Types.ts @@ -66,6 +66,7 @@ export interface IColorSet { selectionBackgroundOpaque: IColor; selectionInactiveBackgroundTransparent: IColor; selectionInactiveBackgroundOpaque: IColor; + overviewRulerBorder: IColor; ansi: IColor[]; /** Maps original colors to colors that respect minimum contrast ratio. */ contrastCache: IColorContrastCache; diff --git a/src/browser/Viewport.ts b/src/browser/Viewport.ts index 62415a4206..3e0b507991 100644 --- a/src/browser/Viewport.ts +++ b/src/browser/Viewport.ts @@ -36,7 +36,6 @@ export class Viewport extends Disposable{ // TODO: Support smooth scroll // TODO: Support fastScrollModifier? - // TODO: overviewRulerWidth should deprecated in favor of scrollBarWidth? this._scrollableElement = this.register(new DomScrollableElement(screenElement, { vertical: ScrollbarVisibility.Auto, diff --git a/src/browser/decorations/OverviewRulerRenderer.ts b/src/browser/decorations/OverviewRulerRenderer.ts index ee000dfcf1..7206ec36fe 100644 --- a/src/browser/decorations/OverviewRulerRenderer.ts +++ b/src/browser/decorations/OverviewRulerRenderer.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { ColorZoneStore, IColorZone, IColorZoneStore } from 'browser/decorations/ColorZoneStore'; -import { ICoreBrowserService, IRenderService } from 'browser/services/Services'; +import { ICoreBrowserService, IRenderService, IThemeService } from 'browser/services/Services'; import { Disposable, toDisposable } from 'common/Lifecycle'; import { IBufferService, IDecorationService, IOptionsService } from 'common/services/Services'; @@ -51,6 +51,7 @@ export class OverviewRulerRenderer extends Disposable { @IDecorationService private readonly _decorationService: IDecorationService, @IRenderService private readonly _renderService: IRenderService, @IOptionsService private readonly _optionsService: IOptionsService, + @IThemeService private readonly _themeService: IThemeService, @ICoreBrowserService private readonly _coreBrowserService: ICoreBrowserService ) { super(); @@ -67,6 +68,7 @@ export class OverviewRulerRenderer extends Disposable { this._registerDecorationListeners(); this._registerBufferChangeListeners(); this._registerDimensionChangeListeners(); + this.register(this._themeService.onChangeColors(() => this._queueRefresh())); this.register(toDisposable(() => { this._canvas?.remove(); })); @@ -190,8 +192,7 @@ export class OverviewRulerRenderer extends Disposable { } private _renderRulerOutline(): void { - // TODO: Support customizing the color - this._ctx.fillStyle = '#000'; + this._ctx.fillStyle = this._themeService.colors.overviewRulerBorder.css; this._ctx.fillRect(0, 0, 1, this._canvas.height); } diff --git a/src/browser/renderer/shared/CharAtlasUtils.ts b/src/browser/renderer/shared/CharAtlasUtils.ts index f8fe9104fb..d3460fce2c 100644 --- a/src/browser/renderer/shared/CharAtlasUtils.ts +++ b/src/browser/renderer/shared/CharAtlasUtils.ts @@ -21,6 +21,7 @@ export function generateConfig(deviceCellWidth: number, deviceCellHeight: number selectionBackgroundOpaque: NULL_COLOR, selectionInactiveBackgroundTransparent: NULL_COLOR, selectionInactiveBackgroundOpaque: NULL_COLOR, + overviewRulerBorder: NULL_COLOR, // For the static char atlas, we only use the first 16 colors, but we need all 256 for the // dynamic character atlas. ansi: colors.ansi.slice(), diff --git a/src/browser/services/ThemeService.ts b/src/browser/services/ThemeService.ts index 31b1192c82..605f020d50 100644 --- a/src/browser/services/ThemeService.ts +++ b/src/browser/services/ThemeService.ts @@ -28,6 +28,7 @@ const DEFAULT_SELECTION = { css: 'rgba(255, 255, 255, 0.3)', rgba: 0xFFFFFF4D }; +const DEFAULT_OVERVIEW_RULER_BORDER = css.toColor('#000000'); export class ThemeService extends Disposable implements IThemeService { public serviceBrand: undefined; @@ -57,6 +58,7 @@ export class ThemeService extends Disposable implements IThemeService { selectionBackgroundOpaque: color.blend(DEFAULT_BACKGROUND, DEFAULT_SELECTION), selectionInactiveBackgroundTransparent: DEFAULT_SELECTION, selectionInactiveBackgroundOpaque: color.blend(DEFAULT_BACKGROUND, DEFAULT_SELECTION), + overviewRulerBorder: DEFAULT_OVERVIEW_RULER_BORDER, ansi: DEFAULT_ANSI_COLORS.slice(), contrastCache: this._contrastCache, halfContrastCache: this._halfContrastCache @@ -100,6 +102,7 @@ export class ThemeService extends Disposable implements IThemeService { const opacity = 0.3; colors.selectionInactiveBackgroundTransparent = color.opacity(colors.selectionInactiveBackgroundTransparent, opacity); } + colors.overviewRulerBorder = parseColor(theme.overviewRulerBorder, DEFAULT_OVERVIEW_RULER_BORDER); colors.ansi = DEFAULT_ANSI_COLORS.slice(); colors.ansi[0] = parseColor(theme.black, DEFAULT_ANSI_COLORS[0]); colors.ansi[1] = parseColor(theme.red, DEFAULT_ANSI_COLORS[1]); diff --git a/src/common/services/Services.ts b/src/common/services/Services.ts index 842d482c5e..e8922e4fa3 100644 --- a/src/common/services/Services.ts +++ b/src/common/services/Services.ts @@ -263,6 +263,7 @@ export interface ITheme { selectionForeground?: string; selectionBackground?: string; selectionInactiveBackground?: string; + overviewRulerBorder?: string; black?: string; red?: string; green?: string; diff --git a/typings/xterm.d.ts b/typings/xterm.d.ts index b64702757c..e83ee5dbf2 100644 --- a/typings/xterm.d.ts +++ b/typings/xterm.d.ts @@ -368,6 +368,12 @@ declare module '@xterm/xterm' { * be transparent) */ selectionInactiveBackground?: string; + /** + * The border color of the overview ruler. This visually separates the terminal from the scroll + * bar when {@link ITerminalOptions.overviewRulerWidth overviewRulerWidth} is set. When this is + * not set it defaults to black (#000000). + */ + overviewRulerBorder?: string; /** ANSI black (eg. `\x1b[30m`) */ black?: string; /** ANSI red (eg. `\x1b[31m`) */ From c7f80986c8407ce93f23a43760b20f0ab828b7b2 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Wed, 10 Jul 2024 07:33:53 -0700 Subject: [PATCH 21/40] Inline some functions --- .../decorations/OverviewRulerRenderer.ts | 36 ++++--------------- 1 file changed, 7 insertions(+), 29 deletions(-) diff --git a/src/browser/decorations/OverviewRulerRenderer.ts b/src/browser/decorations/OverviewRulerRenderer.ts index 7206ec36fe..dbdf4e9c33 100644 --- a/src/browser/decorations/OverviewRulerRenderer.ts +++ b/src/browser/decorations/OverviewRulerRenderer.ts @@ -59,34 +59,18 @@ export class OverviewRulerRenderer extends Disposable { this._canvas.classList.add('xterm-decoration-overview-ruler'); this._refreshCanvasDimensions(); this._viewportElement.parentElement?.insertBefore(this._canvas, this._viewportElement); + this.register(toDisposable(() => this._canvas?.remove())); + const ctx = this._canvas.getContext('2d'); if (!ctx) { throw new Error('Ctx cannot be null'); } else { this._ctx = ctx; } - this._registerDecorationListeners(); - this._registerBufferChangeListeners(); - this._registerDimensionChangeListeners(); - this.register(this._themeService.onChangeColors(() => this._queueRefresh())); - this.register(toDisposable(() => { - this._canvas?.remove(); - })); - } - /** - * On decoration add or remove, redraw - */ - private _registerDecorationListeners(): void { this.register(this._decorationService.onDecorationRegistered(() => this._queueRefresh(undefined, true))); this.register(this._decorationService.onDecorationRemoved(() => this._queueRefresh(undefined, true))); - } - /** - * On buffer change, redraw - * and hide the canvas if the alt buffer is active - */ - private _registerBufferChangeListeners(): void { this.register(this._renderService.onRenderedViewportChange(() => this._queueRefresh())); this.register(this._bufferService.buffers.onBufferActivate(() => { this._canvas!.style.display = this._bufferService.buffer === this._bufferService.buffers.alt ? 'none' : 'block'; @@ -97,24 +81,18 @@ export class OverviewRulerRenderer extends Disposable { this._refreshColorZonePadding(); } })); - } - /** - * On dimension change, update canvas dimensions - * and then redraw - */ - private _registerDimensionChangeListeners(): void { - // container height changed + + // Container height changed this.register(this._renderService.onRender((): void => { if (!this._containerHeight || this._containerHeight !== this._screenElement.clientHeight) { this._queueRefresh(true); this._containerHeight = this._screenElement.clientHeight; } })); - // overview ruler width changed - this.register(this._optionsService.onSpecificOptionChange('overviewRulerWidth', () => this._queueRefresh(true))); - // device pixel ratio changed + this.register(this._coreBrowserService.onDprChange(() => this._queueRefresh(true))); - // set the canvas dimensions + this.register(this._optionsService.onSpecificOptionChange('overviewRulerWidth', () => this._queueRefresh(true))); + this.register(this._themeService.onChangeColors(() => this._queueRefresh())); this._queueRefresh(true); } From 884141cd8f981015b47f50f784a15f6f29f580ea Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Wed, 10 Jul 2024 07:54:39 -0700 Subject: [PATCH 22/40] Support theming of scroll bar slider --- css/xterm.css | 12 ---------- src/browser/Types.ts | 3 +++ src/browser/Viewport.ts | 24 +++++++++++++++++-- src/browser/renderer/shared/CharAtlasUtils.ts | 3 +++ src/browser/services/ThemeService.ts | 6 +++++ src/common/services/Services.ts | 3 +++ typings/xterm.d.ts | 15 ++++++++++++ 7 files changed, 52 insertions(+), 14 deletions(-) diff --git a/css/xterm.css b/css/xterm.css index 3f413d9507..b3aa9e0850 100644 --- a/css/xterm.css +++ b/css/xterm.css @@ -283,15 +283,3 @@ .xterm .monaco-scrollable-element > .shadow.top.left { box-shadow: var(--vscode-scrollbar-shadow, #000) 6px 0 6px -6px inset; } - -.xterm .monaco-scrollable-element > .scrollbar > .slider { - background: var(--vscode-scrollbarSlider-background, #79797966); -} - -.xterm .monaco-scrollable-element > .scrollbar > .slider:hover { - background: var(--vscode-scrollbarSlider-hoverBackground, #646464b3); -} - -.xterm .monaco-scrollable-element > .scrollbar > .slider.active { - background: var(--vscode-scrollbarSlider-activeBackground, #bfbfbf66); -} diff --git a/src/browser/Types.ts b/src/browser/Types.ts index 7000c9e343..43e1f9e265 100644 --- a/src/browser/Types.ts +++ b/src/browser/Types.ts @@ -66,6 +66,9 @@ export interface IColorSet { selectionBackgroundOpaque: IColor; selectionInactiveBackgroundTransparent: IColor; selectionInactiveBackgroundOpaque: IColor; + scrollbarSliderBackground: IColor; + scrollbarSliderHoverBackground: IColor; + scrollbarSliderActiveBackground: IColor; overviewRulerBorder: IColor; ansi: IColor[]; /** Maps original colors to colors that respect minimum contrast ratio. */ diff --git a/src/browser/Viewport.ts b/src/browser/Viewport.ts index 3e0b507991..bdfa1ac78e 100644 --- a/src/browser/Viewport.ts +++ b/src/browser/Viewport.ts @@ -3,9 +3,9 @@ * @license MIT */ -import { IRenderService, IThemeService } from 'browser/services/Services'; +import { ICoreBrowserService, IRenderService, IThemeService } from 'browser/services/Services'; import { EventEmitter, runAndSubscribe } from 'common/EventEmitter'; -import { Disposable } from 'common/Lifecycle'; +import { Disposable, toDisposable } from 'common/Lifecycle'; import { IBufferService, IOptionsService } from 'common/services/Services'; import { DomScrollableElement } from 'vs/base/browser/ui/scrollbar/scrollableElement'; import type { ScrollableElementChangeOptions } from 'vs/base/browser/ui/scrollbar/scrollableElementOptions'; @@ -17,6 +17,7 @@ export class Viewport extends Disposable{ public readonly onRequestScrollLines = this._onRequestScrollLines.event; private _scrollableElement: DomScrollableElement; + private _styleElement: HTMLStyleElement; private _queuedAnimationFrame?: number; private _latestYDisp?: number; @@ -28,6 +29,7 @@ export class Viewport extends Disposable{ element: HTMLElement, screenElement: HTMLElement, @IBufferService private readonly _bufferService: IBufferService, + @ICoreBrowserService coreBrowserService: ICoreBrowserService, @IThemeService themeService: IThemeService, @IOptionsService private readonly _optionsService: IOptionsService, @IRenderService private readonly _renderService: IRenderService @@ -55,6 +57,24 @@ export class Viewport extends Disposable{ this._scrollableElement.getDomNode().style.backgroundColor = themeService.colors.background.css; })); element.appendChild(this._scrollableElement.getDomNode()); + this.register(toDisposable(() => this._scrollableElement.getDomNode().remove())); + + this._styleElement = coreBrowserService.window.document.createElement('style'); + screenElement.appendChild(this._styleElement); + this.register(toDisposable(() => this._styleElement.remove())); + this.register(runAndSubscribe(themeService.onChangeColors, () => { + this._styleElement.textContent = [ + `.xterm .monaco-scrollable-element > .scrollbar > .slider {`, + ` background: ${themeService.colors.scrollbarSliderBackground.css};`, + `}`, + `.xterm .monaco-scrollable-element > .scrollbar > .slider:hover {`, + ` background: ${themeService.colors.scrollbarSliderHoverBackground.css};`, + `}`, + `.xterm .monaco-scrollable-element > .scrollbar > .slider.active {`, + ` background: ${themeService.colors.scrollbarSliderActiveBackground.css};`, + `}` + ].join('\n'); + })); this.register(this._bufferService.onResize(() => this._queueSync())); this.register(this._bufferService.onScroll(ydisp => this._queueSync(ydisp))); diff --git a/src/browser/renderer/shared/CharAtlasUtils.ts b/src/browser/renderer/shared/CharAtlasUtils.ts index d3460fce2c..fb3fae88f4 100644 --- a/src/browser/renderer/shared/CharAtlasUtils.ts +++ b/src/browser/renderer/shared/CharAtlasUtils.ts @@ -22,6 +22,9 @@ export function generateConfig(deviceCellWidth: number, deviceCellHeight: number selectionInactiveBackgroundTransparent: NULL_COLOR, selectionInactiveBackgroundOpaque: NULL_COLOR, overviewRulerBorder: NULL_COLOR, + scrollbarSliderBackground: NULL_COLOR, + scrollbarSliderHoverBackground: NULL_COLOR, + scrollbarSliderActiveBackground: NULL_COLOR, // For the static char atlas, we only use the first 16 colors, but we need all 256 for the // dynamic character atlas. ansi: colors.ansi.slice(), diff --git a/src/browser/services/ThemeService.ts b/src/browser/services/ThemeService.ts index 605f020d50..645777b9bb 100644 --- a/src/browser/services/ThemeService.ts +++ b/src/browser/services/ThemeService.ts @@ -58,6 +58,9 @@ export class ThemeService extends Disposable implements IThemeService { selectionBackgroundOpaque: color.blend(DEFAULT_BACKGROUND, DEFAULT_SELECTION), selectionInactiveBackgroundTransparent: DEFAULT_SELECTION, selectionInactiveBackgroundOpaque: color.blend(DEFAULT_BACKGROUND, DEFAULT_SELECTION), + scrollbarSliderBackground: color.opacity(DEFAULT_FOREGROUND, 0.2), + scrollbarSliderHoverBackground: color.opacity(DEFAULT_FOREGROUND, 0.4), + scrollbarSliderActiveBackground: color.opacity(DEFAULT_FOREGROUND, 0.5), overviewRulerBorder: DEFAULT_OVERVIEW_RULER_BORDER, ansi: DEFAULT_ANSI_COLORS.slice(), contrastCache: this._contrastCache, @@ -102,6 +105,9 @@ export class ThemeService extends Disposable implements IThemeService { const opacity = 0.3; colors.selectionInactiveBackgroundTransparent = color.opacity(colors.selectionInactiveBackgroundTransparent, opacity); } + colors.scrollbarSliderBackground = parseColor(theme.scrollbarSliderBackground, color.opacity(colors.foreground, 0.2)); + colors.scrollbarSliderHoverBackground = parseColor(theme.scrollbarSliderHoverBackground, color.opacity(colors.foreground, 0.4)); + colors.scrollbarSliderActiveBackground = parseColor(theme.scrollbarSliderActiveBackground, color.opacity(colors.foreground, 0.5)); colors.overviewRulerBorder = parseColor(theme.overviewRulerBorder, DEFAULT_OVERVIEW_RULER_BORDER); colors.ansi = DEFAULT_ANSI_COLORS.slice(); colors.ansi[0] = parseColor(theme.black, DEFAULT_ANSI_COLORS[0]); diff --git a/src/common/services/Services.ts b/src/common/services/Services.ts index e8922e4fa3..cd046bb87f 100644 --- a/src/common/services/Services.ts +++ b/src/common/services/Services.ts @@ -263,6 +263,9 @@ export interface ITheme { selectionForeground?: string; selectionBackground?: string; selectionInactiveBackground?: string; + scrollbarSliderBackground?: string; + scrollbarSliderHoverBackground?: string; + scrollbarSliderActiveBackground?: string; overviewRulerBorder?: string; black?: string; red?: string; diff --git a/typings/xterm.d.ts b/typings/xterm.d.ts index e83ee5dbf2..a4ad28c086 100644 --- a/typings/xterm.d.ts +++ b/typings/xterm.d.ts @@ -368,6 +368,21 @@ declare module '@xterm/xterm' { * be transparent) */ selectionInactiveBackground?: string; + /** + * The scrollbar slider background color. Defaults to + * {@link ITerminalOptions.foreground foreground} with 20% opacity. + */ + scrollbarSliderBackground?: string; + /** + * The scrollbar slider background color when hovered. Defaults to + * {@link ITerminalOptions.foreground foreground} with 40% opacity. + */ + scrollbarSliderHoverBackground?: string; + /** + * The scrollbar slider background color when clicked. Defaults to + * {@link ITerminalOptions.foreground foreground} with 50% opacity. + */ + scrollbarSliderActiveBackground?: string; /** * The border color of the overview ruler. This visually separates the terminal from the scroll * bar when {@link ITerminalOptions.overviewRulerWidth overviewRulerWidth} is set. When this is From eca0800b9dda3738ffc28feea60c6d652cb83b44 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Wed, 10 Jul 2024 07:55:22 -0700 Subject: [PATCH 23/40] Fix api lint --- typings/xterm.d.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/typings/xterm.d.ts b/typings/xterm.d.ts index a4ad28c086..4cb1d7f944 100644 --- a/typings/xterm.d.ts +++ b/typings/xterm.d.ts @@ -384,9 +384,10 @@ declare module '@xterm/xterm' { */ scrollbarSliderActiveBackground?: string; /** - * The border color of the overview ruler. This visually separates the terminal from the scroll - * bar when {@link ITerminalOptions.overviewRulerWidth overviewRulerWidth} is set. When this is - * not set it defaults to black (#000000). + * The border color of the overview ruler. This visually separates the + * terminal from the scroll bar when + * {@link ITerminalOptions.overviewRulerWidth overviewRulerWidth} is set. + * When this is not set it defaults to black (`#000000`). */ overviewRulerBorder?: string; /** ANSI black (eg. `\x1b[30m`) */ From ade5591d609a718b6119a69db61df4b76ad0251a Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Wed, 10 Jul 2024 07:58:29 -0700 Subject: [PATCH 24/40] Const enum over magic number --- addons/addon-fit/src/FitAddon.ts | 7 ++++++- src/browser/Viewport.ts | 8 ++++++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/addons/addon-fit/src/FitAddon.ts b/addons/addon-fit/src/FitAddon.ts index 9cef244e5e..3d7dc0a0ae 100644 --- a/addons/addon-fit/src/FitAddon.ts +++ b/addons/addon-fit/src/FitAddon.ts @@ -22,6 +22,11 @@ interface ITerminalDimensions { const MINIMUM_COLS = 2; const MINIMUM_ROWS = 1; +// Must remain in sync with the value in core's viewport +const enum Constants { + DEFAULT_SCROLL_BAR_WIDTH = 14 +} + export class FitAddon implements ITerminalAddon , IFitApi { private _terminal: Terminal | undefined; @@ -66,7 +71,7 @@ export class FitAddon implements ITerminalAddon , IFitApi { const scrollbarWidth = (this._terminal.options.scrollback === 0 ? 0 - : (this._terminal.options.overviewRulerWidth || 14)); + : (this._terminal.options.overviewRulerWidth || Constants.DEFAULT_SCROLL_BAR_WIDTH)); const parentElementStyle = window.getComputedStyle(this._terminal.element.parentElement); const parentElementHeight = parseInt(parentElementStyle.getPropertyValue('height')); diff --git a/src/browser/Viewport.ts b/src/browser/Viewport.ts index bdfa1ac78e..cc7bd31ec1 100644 --- a/src/browser/Viewport.ts +++ b/src/browser/Viewport.ts @@ -11,7 +11,11 @@ import { DomScrollableElement } from 'vs/base/browser/ui/scrollbar/scrollableEle import type { ScrollableElementChangeOptions } from 'vs/base/browser/ui/scrollbar/scrollableElementOptions'; import { ScrollbarVisibility, type ScrollEvent } from 'vs/base/common/scrollable'; -export class Viewport extends Disposable{ +const enum Constants { + DEFAULT_SCROLL_BAR_WIDTH = 14 +} + +export class Viewport extends Disposable { protected _onRequestScrollLines = this.register(new EventEmitter()); public readonly onRequestScrollLines = this._onRequestScrollLines.event; @@ -86,7 +90,7 @@ export class Viewport extends Disposable{ return { mouseWheelScrollSensitivity: this._optionsService.rawOptions.scrollSensitivity, fastScrollSensitivity: this._optionsService.rawOptions.fastScrollSensitivity, - verticalScrollbarSize: this._optionsService.rawOptions.overviewRulerWidth || 14 + verticalScrollbarSize: this._optionsService.rawOptions.overviewRulerWidth || Constants.DEFAULT_SCROLL_BAR_WIDTH }; } From d1d25a5bf7f04356e4fd37a914e7c62f2c43f3b0 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Wed, 10 Jul 2024 08:08:02 -0700 Subject: [PATCH 25/40] Polish all files (excl. CoreBrowserTerminal/Viewport) --- bin/esbuild.mjs | 6 ++---- bin/vs_base_find_unused.js | 1 + package.json | 1 - .../decorations/OverviewRulerRenderer.ts | 18 +++++++++++------- src/browser/services/ThemeService.ts | 6 +++--- 5 files changed, 17 insertions(+), 15 deletions(-) diff --git a/bin/esbuild.mjs b/bin/esbuild.mjs index aa86aa1bcd..6d2a4063c2 100644 --- a/bin/esbuild.mjs +++ b/bin/esbuild.mjs @@ -172,10 +172,8 @@ if (config.addon) { }; outConfig = { ...outConfig, - entryPoints: [ - `src/**/*.ts` - ], - outdir: 'out-esbuild/', + entryPoints: ['src/**/*.ts'], + outdir: 'out-esbuild/' }; outTestConfig = { ...outConfig, diff --git a/bin/vs_base_find_unused.js b/bin/vs_base_find_unused.js index 572cca09ad..df54aab59a 100644 --- a/bin/vs_base_find_unused.js +++ b/bin/vs_base_find_unused.js @@ -25,6 +25,7 @@ function findUnusedSymbols( const allFilesInBase = ( fs.readdirSync('src/vs/base', { recursive: true, withFileTypes: true }) .filter(e => e.isFile()) + // @ts-ignore HACK: This is only available in Node 20 .map(e => `${e.parentPath}/${e.name}`.replace(/\\/g, '/')) ); const unusedFilesInBase = allFilesInBase.filter(e => !usedFilesInBase.includes(e)); diff --git a/package.json b/package.json index a367d3f0fc..894ec18a09 100644 --- a/package.json +++ b/package.json @@ -97,7 +97,6 @@ "mustache": "^4.2.0", "node-pty": "1.1.0-beta5", "nyc": "^15.1.0", - "semver": "^7.6.2", "source-map-loader": "^3.0.0", "source-map-support": "^0.5.20", "ts-loader": "^9.3.1", diff --git a/src/browser/decorations/OverviewRulerRenderer.ts b/src/browser/decorations/OverviewRulerRenderer.ts index dbdf4e9c33..8009522220 100644 --- a/src/browser/decorations/OverviewRulerRenderer.ts +++ b/src/browser/decorations/OverviewRulerRenderer.ts @@ -8,6 +8,10 @@ import { ICoreBrowserService, IRenderService, IThemeService } from 'browser/serv import { Disposable, toDisposable } from 'common/Lifecycle'; import { IBufferService, IDecorationService, IOptionsService } from 'common/services/Services'; +const enum Constants { + OVERVIEW_RULER_BORDER_WIDTH = 1 +} + // Helper objects to avoid excessive calculation and garbage collection during rendering. These are // static values for each render and can be accessed using the decoration position as the key. const drawHeight = { @@ -98,8 +102,8 @@ export class OverviewRulerRenderer extends Disposable { private _refreshDrawConstants(): void { // width - const outerWidth = Math.floor((this._canvas.width - 1) / 3); - const innerWidth = Math.ceil((this._canvas.width - 1) / 3); + const outerWidth = Math.floor((this._canvas.width - Constants.OVERVIEW_RULER_BORDER_WIDTH) / 3); + const innerWidth = Math.ceil((this._canvas.width - Constants.OVERVIEW_RULER_BORDER_WIDTH) / 3); drawWidth.full = this._canvas.width; drawWidth.left = outerWidth; drawWidth.center = innerWidth; @@ -107,10 +111,10 @@ export class OverviewRulerRenderer extends Disposable { // height this._refreshDrawHeightConstants(); // x - drawX.full = 1; - drawX.left = 1; - drawX.center = 1 + drawWidth.left; - drawX.right = 1 + drawWidth.left + drawWidth.center; + drawX.full = Constants.OVERVIEW_RULER_BORDER_WIDTH; + drawX.left = Constants.OVERVIEW_RULER_BORDER_WIDTH; + drawX.center = Constants.OVERVIEW_RULER_BORDER_WIDTH + drawWidth.left; + drawX.right = Constants.OVERVIEW_RULER_BORDER_WIDTH + drawWidth.left + drawWidth.center; } private _refreshDrawHeightConstants(): void { @@ -171,7 +175,7 @@ export class OverviewRulerRenderer extends Disposable { private _renderRulerOutline(): void { this._ctx.fillStyle = this._themeService.colors.overviewRulerBorder.css; - this._ctx.fillRect(0, 0, 1, this._canvas.height); + this._ctx.fillRect(0, 0, Constants.OVERVIEW_RULER_BORDER_WIDTH, this._canvas.height); } private _renderColorZone(zone: IColorZone): void { diff --git a/src/browser/services/ThemeService.ts b/src/browser/services/ThemeService.ts index 645777b9bb..3e6dd4811c 100644 --- a/src/browser/services/ThemeService.ts +++ b/src/browser/services/ThemeService.ts @@ -23,12 +23,12 @@ interface IRestoreColorSet { const DEFAULT_FOREGROUND = css.toColor('#ffffff'); const DEFAULT_BACKGROUND = css.toColor('#000000'); const DEFAULT_CURSOR = css.toColor('#ffffff'); -const DEFAULT_CURSOR_ACCENT = css.toColor('#000000'); +const DEFAULT_CURSOR_ACCENT = DEFAULT_BACKGROUND; const DEFAULT_SELECTION = { css: 'rgba(255, 255, 255, 0.3)', rgba: 0xFFFFFF4D }; -const DEFAULT_OVERVIEW_RULER_BORDER = css.toColor('#000000'); +const DEFAULT_OVERVIEW_RULER_BORDER = DEFAULT_FOREGROUND; export class ThemeService extends Disposable implements IThemeService { public serviceBrand: undefined; @@ -61,7 +61,7 @@ export class ThemeService extends Disposable implements IThemeService { scrollbarSliderBackground: color.opacity(DEFAULT_FOREGROUND, 0.2), scrollbarSliderHoverBackground: color.opacity(DEFAULT_FOREGROUND, 0.4), scrollbarSliderActiveBackground: color.opacity(DEFAULT_FOREGROUND, 0.5), - overviewRulerBorder: DEFAULT_OVERVIEW_RULER_BORDER, + overviewRulerBorder: DEFAULT_FOREGROUND, ansi: DEFAULT_ANSI_COLORS.slice(), contrastCache: this._contrastCache, halfContrastCache: this._halfContrastCache From 2b8ccb5e516f47164b3325a56ab10bc7af499351 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Wed, 10 Jul 2024 08:30:32 -0700 Subject: [PATCH 26/40] Deprecate fast scroll modifier as it's no longer possible Not possible via the component we import. We could add this but I doubt it was being used anyway. --- demo/client.ts | 5 +++-- src/browser/Viewport.ts | 1 - src/common/services/Services.ts | 1 + typings/xterm-headless.d.ts | 2 ++ typings/xterm.d.ts | 4 +++- 5 files changed, 9 insertions(+), 4 deletions(-) diff --git a/demo/client.ts b/demo/client.ts index 6ad9782f62..57208184a9 100644 --- a/demo/client.ts +++ b/demo/client.ts @@ -437,12 +437,13 @@ function initOptions(term: Terminal): void { 'logger', 'theme', 'windowOptions', - 'windowsPty' + 'windowsPty', + // Deprecated + 'fastScrollModifier' ]; const stringOptions = { cursorStyle: ['block', 'underline', 'bar'], cursorInactiveStyle: ['outline', 'block', 'bar', 'underline', 'none'], - fastScrollModifier: ['none', 'alt', 'ctrl', 'shift'], fontFamily: null, fontWeight: ['normal', 'bold', '100', '200', '300', '400', '500', '600', '700', '800', '900'], fontWeightBold: ['normal', 'bold', '100', '200', '300', '400', '500', '600', '700', '800', '900'], diff --git a/src/browser/Viewport.ts b/src/browser/Viewport.ts index cc7bd31ec1..c247dab8ff 100644 --- a/src/browser/Viewport.ts +++ b/src/browser/Viewport.ts @@ -41,7 +41,6 @@ export class Viewport extends Disposable { super(); // TODO: Support smooth scroll - // TODO: Support fastScrollModifier? this._scrollableElement = this.register(new DomScrollableElement(screenElement, { vertical: ScrollbarVisibility.Auto, diff --git a/src/common/services/Services.ts b/src/common/services/Services.ts index cd046bb87f..39da2680e0 100644 --- a/src/common/services/Services.ts +++ b/src/common/services/Services.ts @@ -219,6 +219,7 @@ export interface ITerminalOptions { disableStdin?: boolean; documentOverride?: any | null; drawBoldTextInBrightColors?: boolean; + /** @deprecated No longer supported */ fastScrollModifier?: 'none' | 'alt' | 'ctrl' | 'shift'; fastScrollSensitivity?: number; fontSize?: number; diff --git a/typings/xterm-headless.d.ts b/typings/xterm-headless.d.ts index 2d3329edf0..d38d486b6b 100644 --- a/typings/xterm-headless.d.ts +++ b/typings/xterm-headless.d.ts @@ -83,6 +83,8 @@ declare module '@xterm/headless' { /** * The modifier key hold to multiply scroll speed. + * @deprecated This option is no longer available and will always use alt. Setting this will be + * ignored. */ fastScrollModifier?: 'none' | 'alt' | 'ctrl' | 'shift'; diff --git a/typings/xterm.d.ts b/typings/xterm.d.ts index 4cb1d7f944..5dfaf1befe 100644 --- a/typings/xterm.d.ts +++ b/typings/xterm.d.ts @@ -109,11 +109,13 @@ declare module '@xterm/xterm' { /** * The modifier key hold to multiply scroll speed. + * @deprecated This option is no longer available and will always use alt. Setting this will be + * ignored. */ fastScrollModifier?: 'none' | 'alt' | 'ctrl' | 'shift'; /** - * The scroll speed multiplier used for fast scrolling. + * The scroll speed multiplier used for fast scrolling when `Alt` is held. */ fastScrollSensitivity?: number; From 55c627572c187cc171a819a88dd27f4698f85ad2 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Wed, 10 Jul 2024 12:32:55 -0700 Subject: [PATCH 27/40] Support smooth scroll --- src/browser/CoreBrowserTerminal.ts | 31 ++++++++++++++++-- src/browser/Viewport.ts | 52 ++++++++++++++++++++++++------ src/common/CoreTerminal.ts | 7 +--- 3 files changed, 72 insertions(+), 18 deletions(-) diff --git a/src/browser/CoreBrowserTerminal.ts b/src/browser/CoreBrowserTerminal.ts index 7944502cab..23e4a560d8 100644 --- a/src/browser/CoreBrowserTerminal.ts +++ b/src/browser/CoreBrowserTerminal.ts @@ -71,6 +71,7 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal { public linkifier: ILinkifier2 | undefined; private _overviewRulerRenderer: OverviewRulerRenderer | undefined; + private _viewport: Viewport | undefined; public browser: IBrowser = Browser as any; @@ -505,8 +506,8 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal { this.register(this.onBlur(() => this._renderService!.handleBlur())); this.register(this.onFocus(() => this._renderService!.handleFocus())); - const viewport = this.register(this._instantiationService.createInstance(Viewport, this.element, this.screenElement)); - this.register(viewport.onRequestScrollLines(e => this.scrollLines(e, false))); + this._viewport = this.register(this._instantiationService.createInstance(Viewport, this.element, this.screenElement)); + this.register(this._viewport.onRequestScrollLines(e => super.scrollLines(e, false))); this._selectionService = this.register(this._instantiationService.createInstance(SelectionService, this.element, @@ -870,10 +871,34 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal { } public scrollLines(disp: number, suppressScrollEvent?: boolean): void { - super.scrollLines(disp, suppressScrollEvent); + // All scrollLines methods need to go via the viewport in order to support smooth scroll + if (this._viewport) { + this._viewport.scrollLines(disp); + } else { + super.scrollLines(disp, suppressScrollEvent); + } this.refresh(0, this.rows - 1); } + public scrollPages(pageCount: number): void { + this.scrollLines(pageCount * (this.rows - 1)); + } + + public scrollToTop(): void { + this.scrollLines(-this._bufferService.buffer.ydisp); + } + + public scrollToBottom(): void { + this.scrollLines(this._bufferService.buffer.ybase - this._bufferService.buffer.ydisp); + } + + public scrollToLine(line: number): void { + const scrollAmount = line - this._bufferService.buffer.ydisp; + if (scrollAmount !== 0) { + this.scrollLines(scrollAmount); + } + } + public paste(data: string): void { paste(data, this.textarea!, this.coreService, this.optionsService); } diff --git a/src/browser/Viewport.ts b/src/browser/Viewport.ts index c247dab8ff..f1c031e401 100644 --- a/src/browser/Viewport.ts +++ b/src/browser/Viewport.ts @@ -7,9 +7,10 @@ import { ICoreBrowserService, IRenderService, IThemeService } from 'browser/serv import { EventEmitter, runAndSubscribe } from 'common/EventEmitter'; import { Disposable, toDisposable } from 'common/Lifecycle'; import { IBufferService, IOptionsService } from 'common/services/Services'; -import { DomScrollableElement } from 'vs/base/browser/ui/scrollbar/scrollableElement'; +import { scheduleAtNextAnimationFrame } from 'vs/base/browser/dom'; +import { SmoothScrollableElement } from 'vs/base/browser/ui/scrollbar/scrollableElement'; import type { ScrollableElementChangeOptions } from 'vs/base/browser/ui/scrollbar/scrollableElementOptions'; -import { ScrollbarVisibility, type ScrollEvent } from 'vs/base/common/scrollable'; +import { Scrollable, ScrollbarVisibility, type ScrollEvent } from 'vs/base/common/scrollable'; const enum Constants { DEFAULT_SCROLL_BAR_WIDTH = 14 @@ -20,7 +21,7 @@ export class Viewport extends Disposable { protected _onRequestScrollLines = this.register(new EventEmitter()); public readonly onRequestScrollLines = this._onRequestScrollLines.event; - private _scrollableElement: DomScrollableElement; + private _scrollableElement: SmoothScrollableElement; private _styleElement: HTMLStyleElement; private _queuedAnimationFrame?: number; @@ -42,13 +43,23 @@ export class Viewport extends Disposable { // TODO: Support smooth scroll - this._scrollableElement = this.register(new DomScrollableElement(screenElement, { + const scrollable = this.register(new Scrollable({ + forceIntegerValues: false, + smoothScrollDuration: this._optionsService.rawOptions.smoothScrollDuration, + // This is used over `IRenderService.addRefreshCallback` since it can be canceled + scheduleAtNextAnimationFrame: cb => scheduleAtNextAnimationFrame(coreBrowserService.window, cb) + })); + this.register(this._optionsService.onSpecificOptionChange('smoothScrollDuration', () => { + scrollable.setSmoothScrollDuration(this._optionsService.rawOptions.smoothScrollDuration); + })); + + this._scrollableElement = this.register(new SmoothScrollableElement(screenElement, { vertical: ScrollbarVisibility.Auto, horizontal: ScrollbarVisibility.Hidden, useShadows: false, mouseWheelSmoothScroll: true, ...this._getMutableOptions() - })); + }, scrollable)); this.register(this._optionsService.onMultipleOptionChange([ 'scrollSensitivity', 'fastScrollSensitivity', @@ -80,11 +91,29 @@ export class Viewport extends Disposable { })); this.register(this._bufferService.onResize(() => this._queueSync())); - this.register(this._bufferService.onScroll(ydisp => this._queueSync(ydisp))); + this.register(this._bufferService.onScroll(() => this._sync())); this.register(this._scrollableElement.onScroll(e => this._handleScroll(e))); } + public scrollLines(disp: number): void { + const pos = this._scrollableElement.getScrollPosition(); + this._scrollableElement.setScrollPosition({ + reuseAnimation: true, + scrollTop: pos.scrollTop + disp * this._renderService.dimensions.css.cell.height + }); + } + + public scrollToLine(line: number, disableSmoothScroll?: boolean): void { + if (!disableSmoothScroll) { + this._latestYDisp = line; + } + this._scrollableElement.setScrollPosition({ + reuseAnimation: !disableSmoothScroll, + scrollTop: line * this._renderService.dimensions.css.cell.height + }); + } + private _getMutableOptions(): ScrollableElementChangeOptions { return { mouseWheelScrollSensitivity: this._optionsService.rawOptions.scrollSensitivity, @@ -123,9 +152,13 @@ export class Viewport extends Disposable { }); this._suppressOnScrollHandler = false; - this._scrollableElement.setScrollPosition({ - scrollTop: ydisp * this._renderService.dimensions.css.cell.height - }); + // If ydisp has been changed by some other copmonent (input/buffer), then stop animating smooth + // scroll and scroll there immediately. + if (ydisp !== this._latestYDisp) { + this._scrollableElement.setScrollPosition({ + scrollTop: ydisp * this._renderService.dimensions.css.cell.height + }); + } this._isSyncing = false; } @@ -141,6 +174,7 @@ export class Viewport extends Disposable { const newRow = Math.round(e.scrollTop / this._renderService.dimensions.css.cell.height); const diff = newRow - this._bufferService.buffer.ydisp; if (diff !== 0) { + this._latestYDisp = newRow; this._onRequestScrollLines.fire(diff); } this._isHandlingScroll = false; diff --git a/src/common/CoreTerminal.ts b/src/common/CoreTerminal.ts index 470cdb3d46..10f3bc182e 100644 --- a/src/common/CoreTerminal.ts +++ b/src/common/CoreTerminal.ts @@ -133,15 +133,10 @@ export abstract class CoreTerminal extends Disposable implements ICoreTerminal { this.register(this.coreService.onRequestScrollToBottom(() => this.scrollToBottom())); this.register(this.coreService.onUserInput(() => this._writeBuffer.handleUserInput())); this.register(this.optionsService.onMultipleOptionChange(['windowsMode', 'windowsPty'], () => this._handleWindowsPtyOptionChange())); - this.register(this._bufferService.onScroll(event => { + this.register(this._bufferService.onScroll(() => { this._onScroll.fire({ position: this._bufferService.buffer.ydisp }); this._inputHandler.markRangeDirty(this._bufferService.buffer.scrollTop, this._bufferService.buffer.scrollBottom); })); - this.register(this._inputHandler.onScroll(event => { - this._onScroll.fire({ position: this._bufferService.buffer.ydisp }); - this._inputHandler.markRangeDirty(this._bufferService.buffer.scrollTop, this._bufferService.buffer.scrollBottom); - })); - // Setup WriteBuffer this._writeBuffer = this.register(new WriteBuffer((data, promiseResult) => this._inputHandler.parse(data, promiseResult))); this.register(forwardEvent(this._writeBuffer.onWriteParsed, this._onWriteParsed)); From d6f7133fa6355667976039a962f36d3fa25d602f Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Wed, 10 Jul 2024 12:36:08 -0700 Subject: [PATCH 28/40] Fix api lint --- typings/xterm-headless.d.ts | 4 ++-- typings/xterm.d.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/typings/xterm-headless.d.ts b/typings/xterm-headless.d.ts index d38d486b6b..3cbde44b2b 100644 --- a/typings/xterm-headless.d.ts +++ b/typings/xterm-headless.d.ts @@ -83,8 +83,8 @@ declare module '@xterm/headless' { /** * The modifier key hold to multiply scroll speed. - * @deprecated This option is no longer available and will always use alt. Setting this will be - * ignored. + * @deprecated This option is no longer available and will always use alt. + * Setting this will be ignored. */ fastScrollModifier?: 'none' | 'alt' | 'ctrl' | 'shift'; diff --git a/typings/xterm.d.ts b/typings/xterm.d.ts index 5dfaf1befe..3aa4b1910d 100644 --- a/typings/xterm.d.ts +++ b/typings/xterm.d.ts @@ -109,8 +109,8 @@ declare module '@xterm/xterm' { /** * The modifier key hold to multiply scroll speed. - * @deprecated This option is no longer available and will always use alt. Setting this will be - * ignored. + * @deprecated This option is no longer available and will always use alt. + * Setting this will be ignored. */ fastScrollModifier?: 'none' | 'alt' | 'ctrl' | 'shift'; From 1096b7f17fc7266f8e630d1d5f672fe2a67c252a Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Wed, 10 Jul 2024 12:40:02 -0700 Subject: [PATCH 29/40] Create shared file for viewport constants --- addons/addon-fit/src/FitAddon.ts | 8 ++------ src/browser/Viewport.ts | 7 ++----- src/browser/shared/Constants.ts | 8 ++++++++ 3 files changed, 12 insertions(+), 11 deletions(-) create mode 100644 src/browser/shared/Constants.ts diff --git a/addons/addon-fit/src/FitAddon.ts b/addons/addon-fit/src/FitAddon.ts index 3d7dc0a0ae..283af35457 100644 --- a/addons/addon-fit/src/FitAddon.ts +++ b/addons/addon-fit/src/FitAddon.ts @@ -6,6 +6,7 @@ import type { Terminal, ITerminalAddon } from '@xterm/xterm'; import type { FitAddon as IFitApi } from '@xterm/addon-fit'; import { IRenderDimensions } from 'browser/renderer/shared/Types'; +import { ViewportConstants } from 'browser/shared/Constants'; interface ITerminalDimensions { /** @@ -22,11 +23,6 @@ interface ITerminalDimensions { const MINIMUM_COLS = 2; const MINIMUM_ROWS = 1; -// Must remain in sync with the value in core's viewport -const enum Constants { - DEFAULT_SCROLL_BAR_WIDTH = 14 -} - export class FitAddon implements ITerminalAddon , IFitApi { private _terminal: Terminal | undefined; @@ -71,7 +67,7 @@ export class FitAddon implements ITerminalAddon , IFitApi { const scrollbarWidth = (this._terminal.options.scrollback === 0 ? 0 - : (this._terminal.options.overviewRulerWidth || Constants.DEFAULT_SCROLL_BAR_WIDTH)); + : (this._terminal.options.overviewRulerWidth || ViewportConstants.DEFAULT_SCROLL_BAR_WIDTH)); const parentElementStyle = window.getComputedStyle(this._terminal.element.parentElement); const parentElementHeight = parseInt(parentElementStyle.getPropertyValue('height')); diff --git a/src/browser/Viewport.ts b/src/browser/Viewport.ts index f1c031e401..423a85bb04 100644 --- a/src/browser/Viewport.ts +++ b/src/browser/Viewport.ts @@ -4,6 +4,7 @@ */ import { ICoreBrowserService, IRenderService, IThemeService } from 'browser/services/Services'; +import { ViewportConstants } from 'browser/shared/Constants'; import { EventEmitter, runAndSubscribe } from 'common/EventEmitter'; import { Disposable, toDisposable } from 'common/Lifecycle'; import { IBufferService, IOptionsService } from 'common/services/Services'; @@ -12,10 +13,6 @@ import { SmoothScrollableElement } from 'vs/base/browser/ui/scrollbar/scrollable import type { ScrollableElementChangeOptions } from 'vs/base/browser/ui/scrollbar/scrollableElementOptions'; import { Scrollable, ScrollbarVisibility, type ScrollEvent } from 'vs/base/common/scrollable'; -const enum Constants { - DEFAULT_SCROLL_BAR_WIDTH = 14 -} - export class Viewport extends Disposable { protected _onRequestScrollLines = this.register(new EventEmitter()); @@ -118,7 +115,7 @@ export class Viewport extends Disposable { return { mouseWheelScrollSensitivity: this._optionsService.rawOptions.scrollSensitivity, fastScrollSensitivity: this._optionsService.rawOptions.fastScrollSensitivity, - verticalScrollbarSize: this._optionsService.rawOptions.overviewRulerWidth || Constants.DEFAULT_SCROLL_BAR_WIDTH + verticalScrollbarSize: this._optionsService.rawOptions.overviewRulerWidth || ViewportConstants.DEFAULT_SCROLL_BAR_WIDTH }; } diff --git a/src/browser/shared/Constants.ts b/src/browser/shared/Constants.ts new file mode 100644 index 0000000000..58b7d2f73a --- /dev/null +++ b/src/browser/shared/Constants.ts @@ -0,0 +1,8 @@ +/** + * Copyright (c) 2024 The xterm.js authors. All rights reserved. + * @license MIT + */ + +export const enum ViewportConstants { + DEFAULT_SCROLL_BAR_WIDTH = 14 +} From 59a8f0cf8e5ad8332e652f8cd9a45cbabdd044f3 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Wed, 10 Jul 2024 12:47:47 -0700 Subject: [PATCH 30/40] _getMutableOptions -> _getChangeOptions --- src/browser/Viewport.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/browser/Viewport.ts b/src/browser/Viewport.ts index 423a85bb04..212ed49cc3 100644 --- a/src/browser/Viewport.ts +++ b/src/browser/Viewport.ts @@ -55,13 +55,13 @@ export class Viewport extends Disposable { horizontal: ScrollbarVisibility.Hidden, useShadows: false, mouseWheelSmoothScroll: true, - ...this._getMutableOptions() + ...this._getChangeOptions() }, scrollable)); this.register(this._optionsService.onMultipleOptionChange([ 'scrollSensitivity', 'fastScrollSensitivity', 'overviewRulerWidth' - ], () => this._scrollableElement.updateOptions(this._getMutableOptions()))); + ], () => this._scrollableElement.updateOptions(this._getChangeOptions()))); this._scrollableElement.setScrollDimensions({ height: 0, scrollHeight: 0 }); this.register(runAndSubscribe(themeService.onChangeColors, () => { @@ -111,7 +111,7 @@ export class Viewport extends Disposable { }); } - private _getMutableOptions(): ScrollableElementChangeOptions { + private _getChangeOptions(): ScrollableElementChangeOptions { return { mouseWheelScrollSensitivity: this._optionsService.rawOptions.scrollSensitivity, fastScrollSensitivity: this._optionsService.rawOptions.fastScrollSensitivity, From f155d67c451daf5bc87957298af9fa6fee093e7a Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Wed, 10 Jul 2024 13:32:30 -0700 Subject: [PATCH 31/40] Replace monaco- with xterm- in the update script --- bin/vs_base_update.ps1 | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/bin/vs_base_update.ps1 b/bin/vs_base_update.ps1 index 234bb92f4c..f483fd9655 100644 --- a/bin/vs_base_update.ps1 +++ b/bin/vs_base_update.ps1 @@ -18,7 +18,7 @@ Write-Host "`e[32m> Copying base`e[0m" Copy-Item -Path "src/vs/temp/src/vs/base" -Destination "src/vs/base" -Recurse # Comment out any CSS imports -Write-Host "`e[32m> Commenting out CSS imports`e[0m" -NoNewline +Write-Host "`e[32m> Commenting out CSS imports" -NoNewline $baseFiles = Get-ChildItem -Path "src/vs/base" -Recurse -File $count = 0 foreach ($file in $baseFiles) { @@ -34,7 +34,23 @@ foreach ($file in $baseFiles) { } $updatedContent | Set-Content -Path $file.FullName } -Write-Host " $count files patched" +Write-Host " $count files patched`e[0m" + +# Replace `monaco-*` with `xterm-*`, this will help avoid any styling conflicts when monaco and +# xterm.js are used in the same project. +Write-Host "`e[32m> Replacing monaco-* class names with xterm-* `e[0m" -NoNewline +$baseFiles = Get-ChildItem -Path "src/vs/base" -Recurse -File +$count = 0 +foreach ($file in $baseFiles) { + $content = Get-Content -Path $file.FullName + if ($content -match "monaco-([a-zA-Z\-]+)") { + $updatedContent = $content -replace "monaco-([a-zA-Z\-]+)", 'xterm-$1' + Write-Host "`e[32m." -NoNewline + $count++ + $updatedContent | Set-Content -Path $file.FullName + } +} +Write-Host " $count files patched`e[0m" # Copy typings Write-Host "`e[32m> Copying typings`e[0m" From 444c07fd81dae769ac12d5deb3a3fae071620275 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Wed, 10 Jul 2024 13:33:43 -0700 Subject: [PATCH 32/40] Replace monaco- with xterm- in code/css files --- css/xterm.css | 20 +++++++++---------- src/browser/Viewport.ts | 6 +++--- src/vs/base/browser/touch.ts | 10 +++++----- .../browser/ui/scrollbar/scrollableElement.ts | 4 ++-- yarn.lock | 2 +- 5 files changed, 21 insertions(+), 21 deletions(-) diff --git a/css/xterm.css b/css/xterm.css index b3aa9e0850..819654e453 100644 --- a/css/xterm.css +++ b/css/xterm.css @@ -224,17 +224,17 @@ /* Derived from vs/base/browser/ui/scrollbar/media/scrollbar.css */ /* xterm.js customization: Override xterm's cursor style */ -.xterm .monaco-scrollable-element > .scrollbar { +.xterm .xterm-scrollable-element > .scrollbar { cursor: default; } /* Arrows */ -.xterm .monaco-scrollable-element > .scrollbar > .scra { +.xterm .xterm-scrollable-element > .scrollbar > .scra { cursor: pointer; font-size: 11px !important; } -.xterm .monaco-scrollable-element > .visible { +.xterm .xterm-scrollable-element > .visible { opacity: 1; /* Background rule added for IE9 - to allow clicks on dom node */ @@ -244,20 +244,20 @@ /* In front of peek view */ z-index: 11; } -.xterm .monaco-scrollable-element > .invisible { +.xterm .xterm-scrollable-element > .invisible { opacity: 0; pointer-events: none; } -.xterm .monaco-scrollable-element > .invisible.fade { +.xterm .xterm-scrollable-element > .invisible.fade { transition: opacity 800ms linear; } /* Scrollable Content Inset Shadow */ -.xterm .monaco-scrollable-element > .shadow { +.xterm .xterm-scrollable-element > .shadow { position: absolute; display: none; } -.xterm .monaco-scrollable-element > .shadow.top { +.xterm .xterm-scrollable-element > .shadow.top { display: block; top: 0; left: 3px; @@ -265,7 +265,7 @@ width: 100%; box-shadow: var(--vscode-scrollbar-shadow, #000) 0 6px 6px -6px inset; } -.xterm .monaco-scrollable-element > .shadow.left { +.xterm .xterm-scrollable-element > .shadow.left { display: block; top: 3px; left: 0; @@ -273,13 +273,13 @@ width: 3px; box-shadow: var(--vscode-scrollbar-shadow, #000) 6px 0 6px -6px inset; } -.xterm .monaco-scrollable-element > .shadow.top-left-corner { +.xterm .xterm-scrollable-element > .shadow.top-left-corner { display: block; top: 0; left: 0; height: 3px; width: 3px; } -.xterm .monaco-scrollable-element > .shadow.top.left { +.xterm .xterm-scrollable-element > .shadow.top.left { box-shadow: var(--vscode-scrollbar-shadow, #000) 6px 0 6px -6px inset; } diff --git a/src/browser/Viewport.ts b/src/browser/Viewport.ts index 212ed49cc3..9deed71e21 100644 --- a/src/browser/Viewport.ts +++ b/src/browser/Viewport.ts @@ -75,13 +75,13 @@ export class Viewport extends Disposable { this.register(toDisposable(() => this._styleElement.remove())); this.register(runAndSubscribe(themeService.onChangeColors, () => { this._styleElement.textContent = [ - `.xterm .monaco-scrollable-element > .scrollbar > .slider {`, + `.xterm .xterm-scrollable-element > .scrollbar > .slider {`, ` background: ${themeService.colors.scrollbarSliderBackground.css};`, `}`, - `.xterm .monaco-scrollable-element > .scrollbar > .slider:hover {`, + `.xterm .xterm-scrollable-element > .scrollbar > .slider:hover {`, ` background: ${themeService.colors.scrollbarSliderHoverBackground.css};`, `}`, - `.xterm .monaco-scrollable-element > .scrollbar > .slider.active {`, + `.xterm .xterm-scrollable-element > .scrollbar > .slider.active {`, ` background: ${themeService.colors.scrollbarSliderActiveBackground.css};`, `}` ].join('\n'); diff --git a/src/vs/base/browser/touch.ts b/src/vs/base/browser/touch.ts index c85416054f..a4e8dffaad 100644 --- a/src/vs/base/browser/touch.ts +++ b/src/vs/base/browser/touch.ts @@ -12,11 +12,11 @@ import { Disposable, IDisposable, markAsSingleton, toDisposable } from 'vs/base/ import { LinkedList } from 'vs/base/common/linkedList'; export namespace EventType { - export const Tap = '-monaco-gesturetap'; - export const Change = '-monaco-gesturechange'; - export const Start = '-monaco-gesturestart'; - export const End = '-monaco-gesturesend'; - export const Contextmenu = '-monaco-gesturecontextmenu'; + export const Tap = '-xterm-gesturetap'; + export const Change = '-xterm-gesturechange'; + export const Start = '-xterm-gesturestart'; + export const End = '-xterm-gesturesend'; + export const Contextmenu = '-xterm-gesturecontextmenu'; } interface TouchData { diff --git a/src/vs/base/browser/ui/scrollbar/scrollableElement.ts b/src/vs/base/browser/ui/scrollbar/scrollableElement.ts index 14edb37102..d7d77e6f4b 100644 --- a/src/vs/base/browser/ui/scrollbar/scrollableElement.ts +++ b/src/vs/base/browser/ui/scrollbar/scrollableElement.ts @@ -225,7 +225,7 @@ export abstract class AbstractScrollableElement extends Widget { this._horizontalScrollbar = this._register(new HorizontalScrollbar(this._scrollable, this._options, scrollbarHost)); this._domNode = document.createElement('div'); - this._domNode.className = 'monaco-scrollable-element ' + this._options.className; + this._domNode.className = 'xterm-scrollable-element ' + this._options.className; this._domNode.setAttribute('role', 'presentation'); this._domNode.style.position = 'relative'; this._domNode.style.overflow = 'hidden'; @@ -312,7 +312,7 @@ export abstract class AbstractScrollableElement extends Widget { if (platform.isMacintosh) { this._options.className += ' mac'; } - this._domNode.className = 'monaco-scrollable-element ' + this._options.className; + this._domNode.className = 'xterm-scrollable-element ' + this._options.className; } /** diff --git a/yarn.lock b/yarn.lock index e353d8ab3b..5925b86dae 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3677,7 +3677,7 @@ semver@^7.3.4, semver@^7.5.3, semver@^7.5.4: dependencies: lru-cache "^6.0.0" -semver@^7.6.0, semver@^7.6.2: +semver@^7.6.0: version "7.6.2" resolved "https://registry.yarnpkg.com/semver/-/semver-7.6.2.tgz#1e3b34759f896e8f14d6134732ce798aeb0c6e13" integrity sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w== From 31e0af745df556aa9081df6ca824a841f9fd9e2a Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Wed, 10 Jul 2024 13:42:14 -0700 Subject: [PATCH 33/40] Fix out-esbuild build --- bin/esbuild.mjs | 8 +++++++- src/browser/tsconfig.json | 3 ++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/bin/esbuild.mjs b/bin/esbuild.mjs index 6d2a4063c2..331279fae1 100644 --- a/bin/esbuild.mjs +++ b/bin/esbuild.mjs @@ -172,7 +172,13 @@ if (config.addon) { }; outConfig = { ...outConfig, - entryPoints: ['src/**/*.ts'], + entryPoints: [ + 'src/browser/*.ts', + 'src/common/*.ts', + 'src/headless/*.ts', + 'src/vs/base/*.ts', + 'src/vs/patches/*.ts' + ], outdir: 'out-esbuild/' }; outTestConfig = { diff --git a/src/browser/tsconfig.json b/src/browser/tsconfig.json index d8f3a189aa..38854e260f 100644 --- a/src/browser/tsconfig.json +++ b/src/browser/tsconfig.json @@ -7,7 +7,8 @@ ], "outDir": "../../out", "types": [ - "../../node_modules/@types/mocha" + "../../node_modules/@types/mocha", + "../vs/typings/thenable.d.ts" ], "baseUrl": "..", "paths": { From d532a15bf22dc377a9fab6d5486c544dad76a1f9 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Wed, 10 Jul 2024 13:46:04 -0700 Subject: [PATCH 34/40] Include all files, not just entry root folder --- bin/esbuild.mjs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/bin/esbuild.mjs b/bin/esbuild.mjs index 331279fae1..a8ccf48f01 100644 --- a/bin/esbuild.mjs +++ b/bin/esbuild.mjs @@ -173,11 +173,11 @@ if (config.addon) { outConfig = { ...outConfig, entryPoints: [ - 'src/browser/*.ts', - 'src/common/*.ts', - 'src/headless/*.ts', - 'src/vs/base/*.ts', - 'src/vs/patches/*.ts' + 'src/browser/**/*.ts', + 'src/common/**/*.ts', + 'src/headless/**/*.ts', + 'src/vs/base/**/*.ts', + 'src/vs/patches/**/*.ts' ], outdir: 'out-esbuild/' }; From a644b5d0682cc53b6deffb2de4c3704220c123e9 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Wed, 10 Jul 2024 13:50:07 -0700 Subject: [PATCH 35/40] Clean up dealing with animation frame --- src/browser/Viewport.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/browser/Viewport.ts b/src/browser/Viewport.ts index 9deed71e21..f339d9690b 100644 --- a/src/browser/Viewport.ts +++ b/src/browser/Viewport.ts @@ -129,9 +129,10 @@ export class Viewport extends Disposable { if (this._queuedAnimationFrame !== undefined) { return; } - this._queuedAnimationFrame = this._renderService.addRefreshCallback(() => this._sync(this._latestYDisp)); - this._latestYDisp = undefined; - this._queuedAnimationFrame = undefined; + this._queuedAnimationFrame = this._renderService.addRefreshCallback(() => { + this._queuedAnimationFrame = undefined; + this._sync(this._latestYDisp); + }); } private _sync(ydisp: number = this._bufferService.buffer.ydisp): void { From 2da5eca384e9865ab13f020670c1d655febe836c Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Thu, 11 Jul 2024 08:29:29 -0700 Subject: [PATCH 36/40] Fix mouse events --- src/browser/CoreBrowserTerminal.ts | 12 ++++-------- src/browser/Viewport.ts | 10 +++++++++- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/src/browser/CoreBrowserTerminal.ts b/src/browser/CoreBrowserTerminal.ts index 23e4a560d8..c6f12b5255 100644 --- a/src/browser/CoreBrowserTerminal.ts +++ b/src/browser/CoreBrowserTerminal.ts @@ -630,15 +630,11 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal { if (self._customWheelEventHandler && self._customWheelEventHandler(ev as WheelEvent) === false) { return false; } - // TODO: Implement - const amount = 0; - // const amount = self.viewport!.getLinesScrolled(ev as WheelEvent); - - if (amount === 0) { + const deltaY = (ev as WheelEvent).deltaY; + if (deltaY === 0) { return false; } - - action = (ev as WheelEvent).deltaY < 0 ? CoreMouseAction.UP : CoreMouseAction.DOWN; + action = deltaY < 0 ? CoreMouseAction.UP : CoreMouseAction.DOWN; but = CoreMouseButton.WHEEL; break; default: @@ -798,7 +794,7 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal { if (!this.buffer.hasScrollback) { // Convert wheel events into up/down events when the buffer does not have scrollback, this // enables scrolling in apps hosted in the alt buffer such as vim or tmux. - // TODSO: Impl + // TODO: Impl const amount = 0; // this.viewport!.getLinesScrolled(ev); // Do nothing if there's no vertical scroll diff --git a/src/browser/Viewport.ts b/src/browser/Viewport.ts index f339d9690b..0bf51f0fc5 100644 --- a/src/browser/Viewport.ts +++ b/src/browser/Viewport.ts @@ -7,7 +7,8 @@ import { ICoreBrowserService, IRenderService, IThemeService } from 'browser/serv import { ViewportConstants } from 'browser/shared/Constants'; import { EventEmitter, runAndSubscribe } from 'common/EventEmitter'; import { Disposable, toDisposable } from 'common/Lifecycle'; -import { IBufferService, IOptionsService } from 'common/services/Services'; +import { IBufferService, ICoreMouseService, IOptionsService } from 'common/services/Services'; +import { CoreMouseEventType } from 'common/Types'; import { scheduleAtNextAnimationFrame } from 'vs/base/browser/dom'; import { SmoothScrollableElement } from 'vs/base/browser/ui/scrollbar/scrollableElement'; import type { ScrollableElementChangeOptions } from 'vs/base/browser/ui/scrollbar/scrollableElementOptions'; @@ -32,6 +33,7 @@ export class Viewport extends Disposable { screenElement: HTMLElement, @IBufferService private readonly _bufferService: IBufferService, @ICoreBrowserService coreBrowserService: ICoreBrowserService, + @ICoreMouseService coreMouseService: ICoreMouseService, @IThemeService themeService: IThemeService, @IOptionsService private readonly _optionsService: IOptionsService, @IRenderService private readonly _renderService: IRenderService @@ -62,6 +64,12 @@ export class Viewport extends Disposable { 'fastScrollSensitivity', 'overviewRulerWidth' ], () => this._scrollableElement.updateOptions(this._getChangeOptions()))); + // Don't handle mouse wheel if wheel events are supported by the current mouse prototcol + this.register(coreMouseService.onProtocolChange(type => { + this._scrollableElement.updateOptions({ + handleMouseWheel: !(type & CoreMouseEventType.WHEEL) + }); + })); this._scrollableElement.setScrollDimensions({ height: 0, scrollHeight: 0 }); this.register(runAndSubscribe(themeService.onChangeColors, () => { From 721d483cc33189150b28c8090aea97610e2e207b Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Thu, 11 Jul 2024 08:35:42 -0700 Subject: [PATCH 37/40] Implement zero scrollback+no mouse event wheel handling --- src/browser/CoreBrowserTerminal.ts | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/src/browser/CoreBrowserTerminal.ts b/src/browser/CoreBrowserTerminal.ts index c6f12b5255..2858e68844 100644 --- a/src/browser/CoreBrowserTerminal.ts +++ b/src/browser/CoreBrowserTerminal.ts @@ -793,22 +793,21 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal { if (!this.buffer.hasScrollback) { // Convert wheel events into up/down events when the buffer does not have scrollback, this - // enables scrolling in apps hosted in the alt buffer such as vim or tmux. - // TODO: Impl - const amount = 0; // this.viewport!.getLinesScrolled(ev); + // enables scrolling in apps hosted in the alt buffer such as vim or tmux even when mouse + // events are not enabled. + // This used implementation used get the actual lines/partial lines scrolled from the + // viewport but since moving to the new viewport implementation has been simplified to + // simply send a single up or down sequence. // Do nothing if there's no vertical scroll - if (amount === 0) { - return; + const deltaY = (ev as WheelEvent).deltaY; + if (deltaY === 0) { + return false; } // Construct and send sequences const sequence = C0.ESC + (this.coreService.decPrivateModes.applicationCursorKeys ? 'O' : '[') + (ev.deltaY < 0 ? 'A' : 'B'); - let data = ''; - for (let i = 0; i < Math.abs(amount); i++) { - data += sequence; - } - this.coreService.triggerDataEvent(data, true); + this.coreService.triggerDataEvent(sequence, true); return this.cancel(ev, true); } From 0f373512d2cc802317da25e2b64b6bf406cbc084 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Thu, 11 Jul 2024 08:37:48 -0700 Subject: [PATCH 38/40] Fix ICoreMouseService injecting --- src/common/TestUtils.test.ts | 1 + src/common/services/CoreMouseService.ts | 2 ++ src/common/services/Services.ts | 2 ++ 3 files changed, 5 insertions(+) diff --git a/src/common/TestUtils.test.ts b/src/common/TestUtils.test.ts index 39e13fbac3..fbf3ebcd35 100644 --- a/src/common/TestUtils.test.ts +++ b/src/common/TestUtils.test.ts @@ -54,6 +54,7 @@ export class MockBufferService implements IBufferService { } export class MockCoreMouseService implements ICoreMouseService { + public serviceBrand: any; public areMouseEventsActive: boolean = false; public activeEncoding: string = ''; public activeProtocol: string = ''; diff --git a/src/common/services/CoreMouseService.ts b/src/common/services/CoreMouseService.ts index fd880a5d94..052353bcab 100644 --- a/src/common/services/CoreMouseService.ts +++ b/src/common/services/CoreMouseService.ts @@ -167,6 +167,8 @@ const DEFAULT_ENCODINGS: { [key: string]: CoreMouseEncoding } = { * To send a mouse event call `triggerMouseEvent`. */ export class CoreMouseService extends Disposable implements ICoreMouseService { + public serviceBrand: any; + private _protocols: { [name: string]: ICoreMouseProtocol } = {}; private _encodings: { [name: string]: CoreMouseEncoding } = {}; private _activeProtocol: string = ''; diff --git a/src/common/services/Services.ts b/src/common/services/Services.ts index 39da2680e0..4b3c4e3f1a 100644 --- a/src/common/services/Services.ts +++ b/src/common/services/Services.ts @@ -28,6 +28,8 @@ export interface IBufferService { export const ICoreMouseService = createDecorator('CoreMouseService'); export interface ICoreMouseService { + serviceBrand: undefined; + activeProtocol: string; activeEncoding: string; areMouseEventsActive: boolean; From 73ee951807ea1b5ebfa7598ea30dac34b241667f Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Thu, 11 Jul 2024 08:43:54 -0700 Subject: [PATCH 39/40] Resolve more todos --- src/browser/CoreBrowserTerminal.ts | 24 ------------------------ src/browser/Viewport.ts | 2 -- 2 files changed, 26 deletions(-) diff --git a/src/browser/CoreBrowserTerminal.ts b/src/browser/CoreBrowserTerminal.ts index 2858e68844..ea64855717 100644 --- a/src/browser/CoreBrowserTerminal.ts +++ b/src/browser/CoreBrowserTerminal.ts @@ -810,27 +810,7 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal { this.coreService.triggerDataEvent(sequence, true); return this.cancel(ev, true); } - - // normal viewport scrolling - // conditionally stop event, if the viewport still had rows to scroll within - // if (this.viewport!.handleWheel(ev)) { - // return this.cancel(ev); - // } }, { passive: false })); - - // TODO: Make sure this.coreMouseService.areMouseEventsActive still works - // this.register(addDisposableDomListener(el, 'touchstart', (ev: TouchEvent) => { - // if (this.coreMouseService.areMouseEventsActive) return; - // this.viewport!.handleTouchStart(ev); - // return this.cancel(ev); - // }, { passive: true })); - - // this.register(addDisposableDomListener(el, 'touchmove', (ev: TouchEvent) => { - // if (this.coreMouseService.areMouseEventsActive) return; - // if (!this.viewport!.handleTouchMove(ev)) { - // return this.cancel(ev); - // } - // }, { passive: false })); } @@ -1241,8 +1221,6 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal { // IMPORTANT: Fire scroll event before viewport is reset. This ensures embedders get the clear // scroll event and that the viewport's state will be valid for immediate writes. this._onScroll.fire({ position: this.buffer.ydisp }); - // TODO: Reset scrollable element? - // this.viewport?.reset(); this.refresh(0, this.rows - 1); } @@ -1267,8 +1245,6 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal { super.reset(); this._selectionService?.reset(); this._decorationService.reset(); - // TODO: Reset scrollable element? - // this.viewport?.reset(); // reattach this._customKeyEventHandler = customKeyEventHandler; diff --git a/src/browser/Viewport.ts b/src/browser/Viewport.ts index 0bf51f0fc5..01c6bd9aa3 100644 --- a/src/browser/Viewport.ts +++ b/src/browser/Viewport.ts @@ -40,8 +40,6 @@ export class Viewport extends Disposable { ) { super(); - // TODO: Support smooth scroll - const scrollable = this.register(new Scrollable({ forceIntegerValues: false, smoothScrollDuration: this._optionsService.rawOptions.smoothScrollDuration, From 90a1da28bd81b2760936d9d301fb0fbff4fa693c Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Thu, 11 Jul 2024 09:36:22 -0700 Subject: [PATCH 40/40] Scroll immediately to bottom when scrollOnUserInput is enabled See microsoft/vscode#211199 --- src/browser/CoreBrowserTerminal.ts | 10 +++++++--- src/browser/Viewport.ts | 2 +- src/common/CoreTerminal.ts | 4 ++-- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/src/browser/CoreBrowserTerminal.ts b/src/browser/CoreBrowserTerminal.ts index ea64855717..6755006a0f 100644 --- a/src/browser/CoreBrowserTerminal.ts +++ b/src/browser/CoreBrowserTerminal.ts @@ -863,8 +863,12 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal { this.scrollLines(-this._bufferService.buffer.ydisp); } - public scrollToBottom(): void { - this.scrollLines(this._bufferService.buffer.ybase - this._bufferService.buffer.ydisp); + public scrollToBottom(disableSmoothScroll?: boolean): void { + if (disableSmoothScroll && this._viewport) { + this._viewport.scrollToLine(this.buffer.ybase, true); + } else { + this.scrollLines(this._bufferService.buffer.ybase - this._bufferService.buffer.ydisp); + } } public scrollToLine(line: number): void { @@ -998,7 +1002,7 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal { if (!shouldIgnoreComposition && !this._compositionHelper!.keydown(event)) { if (this.options.scrollOnUserInput && this.buffer.ybase !== this.buffer.ydisp) { - this.scrollToBottom(); + this.scrollToBottom(true); } return false; } diff --git a/src/browser/Viewport.ts b/src/browser/Viewport.ts index 01c6bd9aa3..64fcba2c88 100644 --- a/src/browser/Viewport.ts +++ b/src/browser/Viewport.ts @@ -108,7 +108,7 @@ export class Viewport extends Disposable { } public scrollToLine(line: number, disableSmoothScroll?: boolean): void { - if (!disableSmoothScroll) { + if (disableSmoothScroll) { this._latestYDisp = line; } this._scrollableElement.setScrollPosition({ diff --git a/src/common/CoreTerminal.ts b/src/common/CoreTerminal.ts index 10f3bc182e..132be05bcb 100644 --- a/src/common/CoreTerminal.ts +++ b/src/common/CoreTerminal.ts @@ -130,7 +130,7 @@ export abstract class CoreTerminal extends Disposable implements ICoreTerminal { this.register(forwardEvent(this._bufferService.onResize, this._onResize)); this.register(forwardEvent(this.coreService.onData, this._onData)); this.register(forwardEvent(this.coreService.onBinary, this._onBinary)); - this.register(this.coreService.onRequestScrollToBottom(() => this.scrollToBottom())); + this.register(this.coreService.onRequestScrollToBottom(() => this.scrollToBottom(true))); this.register(this.coreService.onUserInput(() => this._writeBuffer.handleUserInput())); this.register(this.optionsService.onMultipleOptionChange(['windowsMode', 'windowsPty'], () => this._handleWindowsPtyOptionChange())); this.register(this._bufferService.onScroll(() => { @@ -206,7 +206,7 @@ export abstract class CoreTerminal extends Disposable implements ICoreTerminal { this.scrollLines(-this._bufferService.buffer.ydisp); } - public scrollToBottom(): void { + public scrollToBottom(disableSmoothScroll?: boolean): void { this.scrollLines(this._bufferService.buffer.ybase - this._bufferService.buffer.ydisp); }