├── icon.png ├── images ├── init.gif ├── fossil.png ├── fossil-auth.png ├── fossil-diff.gif ├── fossil-root.png ├── fossil-uri.png ├── fossil-user.png ├── change-branch.gif ├── fossil-commands.png └── fossil-ssl-fail.png ├── .github ├── release.yml ├── issue_template.md ├── ISSUE_TEMPLATE │ ├── feature_request.yml │ └── bug_report.yml └── workflows │ └── fossil.yml ├── .prettierrc ├── .vscode ├── extensions.json ├── tasks.json ├── launch.json └── settings.json ├── docs ├── dev │ ├── pikchr.md │ ├── build.md │ ├── release.md │ ├── scenarios.md │ └── api.md └── cloning.md ├── .nycrc ├── .vscodeignore ├── pikchr ├── assets │ └── pikchr.svg ├── language-configuration.json ├── test │ ├── numproperty.test.pikchr │ ├── place.test.pikchr │ ├── textposition.test.pikchr │ ├── nth.test.pikchr │ ├── comments.test.pikchr │ ├── position.test.pikchr │ ├── homepage.example.test.pikchr │ ├── boolproperty.test.pikchr │ ├── attribute.test.pikchr │ ├── statements.test.pikchr │ ├── macro.test.pikchr │ └── advanced.test.pikchr ├── codeblock.pikchr.tmLanguage.json └── syntax_tree.py ├── resources └── icons │ ├── dark │ ├── status-added.svg │ ├── status-conflict.svg │ ├── status-deleted.svg │ ├── status-ignored.svg │ ├── status-missing.svg │ ├── status-modified.svg │ ├── status-renamed.svg │ ├── status-untracked.svg │ ├── open-change.svg │ └── status-clean.svg │ └── light │ ├── status-added.svg │ ├── status-conflict.svg │ ├── status-deleted.svg │ ├── status-ignored.svg │ ├── status-missing.svg │ ├── status-modified.svg │ ├── status-renamed.svg │ ├── status-untracked.svg │ ├── open-change.svg │ └── status-clean.svg ├── media ├── tsconfig.json ├── preview.css └── preview.ts ├── .gitignore ├── tsconfig.json ├── src ├── typings │ └── refs.d.ts ├── uri.ts ├── test │ ├── suite │ │ ├── index.ts │ │ ├── extension.test.ts │ │ ├── Infrastructure.test.ts │ │ ├── timelineSuite.ts │ │ ├── revertSuite.ts │ │ ├── branchSuite.ts │ │ ├── renameSuite.ts │ │ └── utilitiesSuite.ts │ ├── runTest.ts │ └── summaryAsMarkdown.ts ├── iterators.ts ├── throttlingQueue.ts ├── fossilFinder.ts ├── config.ts ├── main.ts ├── util.ts ├── revert.ts ├── humanise.ts ├── decorators.ts ├── resourceGroups.ts ├── statusBar.ts ├── praise.ts └── fileSystemProvider.ts ├── esbuild.config.js ├── LICENSE.txt ├── .eslintrc.json ├── package.nls.json └── README.md /icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/koog1000/vscode-fossil/HEAD/icon.png -------------------------------------------------------------------------------- /images/init.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/koog1000/vscode-fossil/HEAD/images/init.gif -------------------------------------------------------------------------------- /images/fossil.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/koog1000/vscode-fossil/HEAD/images/fossil.png -------------------------------------------------------------------------------- /images/fossil-auth.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/koog1000/vscode-fossil/HEAD/images/fossil-auth.png -------------------------------------------------------------------------------- /images/fossil-diff.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/koog1000/vscode-fossil/HEAD/images/fossil-diff.gif -------------------------------------------------------------------------------- /images/fossil-root.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/koog1000/vscode-fossil/HEAD/images/fossil-root.png -------------------------------------------------------------------------------- /images/fossil-uri.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/koog1000/vscode-fossil/HEAD/images/fossil-uri.png -------------------------------------------------------------------------------- /images/fossil-user.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/koog1000/vscode-fossil/HEAD/images/fossil-user.png -------------------------------------------------------------------------------- /images/change-branch.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/koog1000/vscode-fossil/HEAD/images/change-branch.gif -------------------------------------------------------------------------------- /images/fossil-commands.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/koog1000/vscode-fossil/HEAD/images/fossil-commands.png -------------------------------------------------------------------------------- /images/fossil-ssl-fail.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/koog1000/vscode-fossil/HEAD/images/fossil-ssl-fail.png -------------------------------------------------------------------------------- /.github/release.yml: -------------------------------------------------------------------------------- 1 | changelog: 2 | categories: 3 | - title: All Changes 4 | labels: 5 | - "*" 6 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "singleQuote": true, 4 | "arrowParens": "avoid", 5 | "tabWidth": 4 6 | } 7 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "RedCMD.tmlanguage-syntax-highlighter", 4 | "pflannery.vscode-versionlens", 5 | ] 6 | } -------------------------------------------------------------------------------- /.github/issue_template.md: -------------------------------------------------------------------------------- 1 | Fossil version: x.x.x 2 | 3 | Operating system: ??? 4 | 5 | Vscode/Vscodium version: x.x.x 6 | 7 | I want this to be fixed/implemented (on a scale 1-10): # 8 | -------------------------------------------------------------------------------- /docs/dev/pikchr.md: -------------------------------------------------------------------------------- 1 | # Debugging TextMate rules 2 | 3 | ## Test rules 4 | 5 | 1) Clone `git@github.com:microsoft/vscode-textmate.git` 6 | 2) Execute `npm run inspect pikchr/pikchr.tmLanguage.json example.pikchr` 7 | -------------------------------------------------------------------------------- /.nycrc: -------------------------------------------------------------------------------- 1 | { 2 | "lines": 92, 3 | "branches": 85, 4 | "statements": 92, 5 | "watermarks": { 6 | "lines": [80, 95], 7 | "functions": [80, 95], 8 | "branches": [80, 95], 9 | "statements": [80, 95] 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /.vscodeignore: -------------------------------------------------------------------------------- 1 | .github/ 2 | .mocharc.js 3 | esbuild.config.js 4 | .nycrc 5 | .vscode 6 | **/.gitignore 7 | **/*.map 8 | **/*.ts 9 | **/tsconfig.json 10 | coverage/ 11 | docs/ 12 | images/ 13 | node_modules 14 | src/ 15 | pikchr/test/* 16 | pikchr/*.py 17 | -------------------------------------------------------------------------------- /pikchr/assets/pikchr.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /resources/icons/dark/status-added.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | A 5 | 6 | -------------------------------------------------------------------------------- /resources/icons/dark/status-conflict.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | C 5 | 6 | -------------------------------------------------------------------------------- /resources/icons/dark/status-deleted.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | D 5 | 6 | -------------------------------------------------------------------------------- /resources/icons/dark/status-ignored.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | I 5 | 6 | -------------------------------------------------------------------------------- /resources/icons/dark/status-missing.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | ! 5 | 6 | -------------------------------------------------------------------------------- /resources/icons/dark/status-modified.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | M 5 | 6 | -------------------------------------------------------------------------------- /resources/icons/dark/status-renamed.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | R 5 | 6 | -------------------------------------------------------------------------------- /resources/icons/dark/status-untracked.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | ? 5 | 6 | -------------------------------------------------------------------------------- /resources/icons/light/status-added.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | A 5 | 6 | -------------------------------------------------------------------------------- /resources/icons/light/status-conflict.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | C 5 | 6 | -------------------------------------------------------------------------------- /resources/icons/light/status-deleted.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | D 5 | 6 | -------------------------------------------------------------------------------- /resources/icons/light/status-ignored.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | I 5 | 6 | -------------------------------------------------------------------------------- /resources/icons/light/status-missing.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | ! 5 | 6 | -------------------------------------------------------------------------------- /resources/icons/light/status-modified.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | M 5 | 6 | -------------------------------------------------------------------------------- /resources/icons/light/status-renamed.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | R 5 | 6 | -------------------------------------------------------------------------------- /resources/icons/light/status-untracked.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | U 5 | 6 | -------------------------------------------------------------------------------- /media/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2020", 4 | "outDir": "./out/media", 5 | "jsx": "react", 6 | "esModuleInterop": true, 7 | "lib": [ 8 | "es2018", 9 | "DOM", 10 | "DOM.Iterable" 11 | ], 12 | "composite": true 13 | 14 | }, 15 | "include": [ 16 | "./*.ts" 17 | ], 18 | "typeAcquisition": { 19 | "include": [ 20 | "@types/vscode-webview" 21 | ] 22 | }, 23 | } -------------------------------------------------------------------------------- /resources/icons/dark/open-change.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /resources/icons/light/open-change.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pikchr/language-configuration.json: -------------------------------------------------------------------------------- 1 | { 2 | "comments": { 3 | "lineComment": "//", 4 | "blockComment": [ "/*", "*/" ] 5 | }, 6 | "brackets": [ 7 | ["{", "}"], 8 | ["[", "]"], 9 | ["(", ")"] 10 | ], 11 | "autoClosingPairs": [ 12 | { "open": "{", "close": "}", "notIn": ["string"] }, 13 | { "open": "[", "close": "]", "notIn": ["string"] }, 14 | { "open": "(", "close": ")", "notIn": ["string"] }, 15 | { "open": "\"", "close": "\"", "notIn": ["string", "comment"] } 16 | ], 17 | "indentationRules": { 18 | "decreaseIndentPattern": "^\\s", 19 | "increaseIndentPattern": "^\\S.*\\\\$" 20 | } 21 | } -------------------------------------------------------------------------------- /pikchr/test/numproperty.test.pikchr: -------------------------------------------------------------------------------- 1 | // SYNTAX TEST "source.pikchr" "all statements must work with commants" 2 | 3 | box/**/width/**/10px 4 | // <--- storage.type.class.pikchr 5 | // ^^^^ ^^^^ comment.block.pikchr 6 | // ^^^^ support.constant.property-value.pikchr 7 | // ^^^^ constant.numeric.pikchr 8 | 9 | box diameter 1 ht 1 height 1 rad 1 radius 1 thickness 1 width 1 wid 1 10 | // <--- storage.type.class.pikchr 11 | // ^^^^^^^^ ^^ ^^^^^^ ^^^ ^^^^^^ ^^^^^^^^^ ^^^^^ ^^^ support.constant.property-value.pikchr 12 | // ^ ^ ^ ^ ^ ^ ^ ^ constant.numeric.pikchr 13 | -------------------------------------------------------------------------------- /pikchr/test/place.test.pikchr: -------------------------------------------------------------------------------- 1 | // SYNTAX TEST "source.pikchr" "place node" 2 | 3 | arrow from last dot to X0 4 | // ^^^^ keyword.pikchr 5 | // ^^^^ constant.language.pikchr 6 | // ^^^ entity.name.function.pikchr 7 | // ^^ keyword.pikchr 8 | // ^^ variable.language.place.pikchr 9 | circle at end of first arrow 10 | // ^^ keyword.pikchr 11 | // ^^^ support.constant.edge.pikchr 12 | // ^^ keyword.control.of.pikchr 13 | // ^ -keyword.control.of.pikchr 14 | // ^^^^^ constant.language.pikchr 15 | // ^^^^^ entity.name.function.pikchr 16 | -------------------------------------------------------------------------------- /docs/dev/build.md: -------------------------------------------------------------------------------- 1 | # Building Extension from Source 2 | **Note:** The official way to install vscode-fossil is from within 3 | [VSCode Extensions](https://code.visualstudio.com/docs/editor/extension-gallery#_browse-for-extensions) 4 | 5 | 6 | 7 | ### Dependencies 8 | You will need to install [Node.js](https://nodejs.org/en/download/) 9 | on your computer and add it to your `$PATH`. 10 | 11 | ### Build Steps 12 | 1. `git clone` repository anywhere on your filesystem. 13 | 2. `npm install` from clone directory to install local dependencies. 14 | 3. `npm run compile` to build extension. 15 | 4. press `F5` to run the extension or run the tests. 16 | 17 | See more commands in `package.json` 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | # compilation output 3 | out*/** 4 | media/out*/** 5 | # all js files are compiled 6 | *.js 7 | # except this one 8 | !esbuild.config.js 9 | # compiled package, generated by `npm run package`: 10 | *.vsix 11 | # ignore GIMP files: 12 | *.xcf 13 | # cache directory, generated by `npm run test`: 14 | .vscode-test 15 | # yarn stuff for when someone is testing yarn: 16 | yarn.lock 17 | yarn-error.log 18 | # temporary directory for preparing ./images: 19 | images/production 20 | # generated by `npm run coverage`: 21 | /coverage 22 | # graphviz files generated by pikchr/syntax_tree.py: 23 | *.gv 24 | # pikchr/syntax_tree.py in pdf format: 25 | *.pdf 26 | # eslint result: 27 | *.sarif 28 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compileOnSave": true, 3 | "compilerOptions": { 4 | "target": "es2020", 5 | "lib": [ 6 | "es2020" 7 | ], 8 | "noImplicitOverride": true, 9 | "noImplicitThis": true, 10 | "module": "commonjs", 11 | "outDir": "out", 12 | "strict": true, 13 | "noImplicitReturns": true, 14 | "noFallthroughCasesInSwitch": true, 15 | //"noUnusedParameters": true, 16 | "removeComments": true, 17 | "sourceMap": true, 18 | "strictNullChecks": true, 19 | "experimentalDecorators": true, 20 | }, 21 | "include": [ 22 | "src/**/*", 23 | ], 24 | "references": [ 25 | {"path": "./media"} 26 | ] 27 | } -------------------------------------------------------------------------------- /pikchr/test/textposition.test.pikchr: -------------------------------------------------------------------------------- 1 | // SYNTAX TEST "source.pikchr" "everything related to using text" 2 | 3 | box "" above 4 | // ^^^^^ keyword.pikchr 5 | 6 | box "" aligned 7 | // ^^^^^^^ keyword.pikchr 8 | 9 | box "" below 10 | // ^^^^^ keyword.pikchr 11 | 12 | box "" big 13 | // ^^^ keyword.pikchr 14 | 15 | box "" bold 16 | // ^^^^ keyword.pikchr 17 | 18 | box "" mono 19 | // ^^^^ keyword.pikchr 20 | 21 | box "" monospace 22 | // ^^^^^^^^^ keyword.pikchr 23 | 24 | box "" center 25 | // ^^^^^^ keyword.pikchr 26 | 27 | box "" italic 28 | // ^^^^^^ keyword.pikchr 29 | 30 | box "" ljust 31 | // ^^^^^ keyword.pikchr 32 | 33 | box "" rjust 34 | // ^^^^^ keyword.pikchr 35 | 36 | box "" small 37 | // ^^^^^ keyword.pikchr 38 | 39 | box "" small bold 40 | // ^^^^^ ^^^^ keyword.pikchr 41 | -------------------------------------------------------------------------------- /src/typings/refs.d.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------------------------------------------- 2 | * Copyright (c) Ben Crowl. All rights reserved. 3 | * Original Copyright (c) Microsoft Corporation. All rights reserved. 4 | * Licensed under the MIT License. See LICENSE.txt in the project root for license information. 5 | *--------------------------------------------------------------------------------------------*/ 6 | 7 | /// 8 | 9 | declare namespace TSReset { 10 | type NonFalsy = T extends false | 0 | '' | null | undefined | 0n 11 | ? never 12 | : T; 13 | } 14 | 15 | interface Array { 16 | filter(predicate: BooleanConstructor, thisArg?: any): TSReset.NonFalsy[]; 17 | } 18 | 19 | interface ReadonlyArray { 20 | filter(predicate: BooleanConstructor, thisArg?: any): TSReset.NonFalsy[]; 21 | } 22 | -------------------------------------------------------------------------------- /pikchr/test/nth.test.pikchr: -------------------------------------------------------------------------------- 1 | // SYNTAX TEST "source.pikchr" "all checks for 'nth' tree" 2 | 3 | < 4 | < all checks are `attribute -> at -> position -> place -> place2 -> nth -> ...` 5 | < 6 | 7 | < 8 | < `nth -> NTH -> classname` 9 | < 10 | box at 1st box 11 | // ^^ keyword.pikchr 12 | // ^^^ constant.language.pikchr 13 | // ^^^ entity.name.function.pikchr 14 | 15 | < 16 | < `nth -> NTH -> last|previous -> classname` 17 | < 18 | box at 2nd previous box 19 | // ^^ keyword.pikchr 20 | // ^^^ constant.language.pikchr 21 | // ^^^^^^^^ constant.language.pikchr 22 | // ^^^ entity.name.function.pikchr 23 | 24 | < 25 | < `nth -> last|previous -> classname 26 | < 27 | 28 | box at last box 29 | // ^^ keyword.pikchr 30 | // ^^^^ constant.language.pikchr 31 | // ^^^ entity.name.function.pikchr 32 | box at previous box 33 | // ^^^^^^^^ constant.language.pikchr 34 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: ⭐ Feature request 3 | description: Suggest an idea. 4 | labels: [enhancement] 5 | body: 6 | - type: dropdown 7 | id: desire 8 | attributes: 9 | label: Desire 10 | description: How much do you want this feature? 11 | options: 12 | - 1 13 | - 2 14 | - 3 15 | - 4 16 | - 5 17 | - 6 18 | - 7 19 | - 8 20 | - 9 21 | - 10 22 | validations: 23 | required: true 24 | - type: checkboxes 25 | id: help 26 | attributes: 27 | label: Help 28 | description: How much are you willing to help?. Its okay if you choose none. 29 | options: 30 | - label: Discuss 31 | - label: Test 32 | - label: Code 33 | - type: textarea 34 | attributes: 35 | label: Description 36 | validations: 37 | required: true 38 | -------------------------------------------------------------------------------- /resources/icons/dark/status-clean.svg: -------------------------------------------------------------------------------- 1 | 3 | 5 | 6 | 8 | 10 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /resources/icons/light/status-clean.svg: -------------------------------------------------------------------------------- 1 | 3 | 5 | 6 | 8 | 10 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /docs/dev/release.md: -------------------------------------------------------------------------------- 1 | # How to release vscode-fossil extension 2 | 3 | ## Describe the changes 4 | 1. Update version in `package.json` 5 | 1. Update `CHANGELOG.md` 6 | 7 | 8 | ## Ensure everything is working 9 | 1. Run tests: `npm run test` 10 | 1. Remove out directory: `rm -rf out` 11 | 1. Create package (.vsix file): `npm run package` 12 | 1. Ensure all files are there: `unzip -l fossil-#.#.#.vsix`. There should be FOUR .js files. 13 | 14 | 15 | ## Make commits 16 | 17 | 1. Create a brunch `git switch --create $USER-release-#.#.#` 18 | 1. Make a commit 'release: #.#.#' 19 | 1. Make a pull request 20 | 1. "Merge and rebase" on a successful pull request 21 | 1. Switch to `master` 22 | 1. Tag `git tag v#.#.# && git push origin $_` 23 | 24 | 25 | ## Release 26 | 1. Download .vsix file from github "Releases"* 27 | 1. Upload it to https://marketplace.visualstudio.com/manage/publishers/koog1000 28 | 1. Upload it to https://open-vsx.org/extension/koog1000/fossil 29 | 30 | Storing tokens -------------------------------------------------------------------------------- /pikchr/test/comments.test.pikchr: -------------------------------------------------------------------------------- 1 | !! SYNTAX TEST "source.pikchr" "all statements must work with commants" 2 | 3 | /**/box 4 | !! <---- comment.block.pikchr 5 | !! ^^^ storage.type.class.pikchr 6 | 7 | /* 8 | !! <-- comment.block.pikchr 9 | 10 | test 11 | !! <---- comment.block.pikchr 12 | 13 | */ 14 | !! <-- comment.block.pikchr 15 | 16 | box//test 17 | !! <--- storage.type.class.pikchr 18 | !! ^^^^^^ comment.line.pikchr 19 | 20 | #! 21 | !! <-- comment.line.pikchr 22 | # box 23 | !! ^^^^^ comment.line.pikchr 24 | // TEST: 25 | !! ^^^^^^^^ comment.line.pikchr 26 | /* TEST 27 | !! ^^^^^^^ comment.block.pikchr 28 | */ 29 | !! ^^ comment.block.pikchr 30 | 31 | [] // test 32 | !! <- punctuation.bracket.square.begin.pikchr 33 | !! <~- punctuation.bracket.square.end.pikchr 34 | !! ^^^^^^^ comment.line.pikchr 35 | 36 | $hello = 1 // world 37 | !! <------ variable.language.pikchr 38 | !! ^ keyword.operator.assignment.pikchr 39 | !! ^ constant.numeric.pikchr 40 | !! ^^^^^^^^ comment.line.pikchr 41 | -------------------------------------------------------------------------------- /pikchr/codeblock.pikchr.tmLanguage.json: -------------------------------------------------------------------------------- 1 | { 2 | "comment": "idea from extensions/markdown-basics/syntaxes/markdown.tmLanguage.json and https://github.com/mjbvz/vscode-fenced-code-block-grammar-injection-example", 3 | "fileTypes": [], 4 | "injectionSelector": "L:text.html.markdown", 5 | "patterns": [ 6 | { 7 | "begin": "(^|\\G)(\\s*)(\\`{3,}|~{3,})\\s*(?i:(pikchr)(\\s+[^`~]*)?$)", 8 | "name": "markup.fenced_code.block.markdown", 9 | "end": "(^|\\G)(\\2|\\s{0,3})(\\3)\\s*$", 10 | "patterns": [ 11 | { 12 | "begin": "(^|\\G)(\\s*)(.*)", 13 | "while": "(^|\\G)(?!\\s*([`~]{3,})\\s*$)", 14 | "contentName": "meta.embedded.block.pikchr", 15 | "patterns": [{"include": "source.pikchr"}] 16 | } 17 | ], 18 | "beginCaptures": { 19 | "3": {"name": "punctuation.definition.markdown"}, 20 | "4": {"name": "fenced_code.block.language.markdown"}, 21 | "5": {"name": "fenced_code.block.language.attributes.markdown"} 22 | }, 23 | "endCaptures": {"1": {"name": "punctuation.definition.markdown"}} 24 | } 25 | ], 26 | "scopeName": "markdown.pikchr.codeblock" 27 | } -------------------------------------------------------------------------------- /esbuild.config.js: -------------------------------------------------------------------------------- 1 | const esbuild = require('esbuild'); 2 | const minify = process.argv.includes('--minify'); 3 | const sourcemap = process.argv.includes('--sourcemap'); 4 | 5 | function buildConfig(entryPoint, outfile) { 6 | return { 7 | minify, 8 | entryPoints: [entryPoint], 9 | bundle: true, 10 | platform: 'node', 11 | sourcemap, 12 | target: 'node18', 13 | format: 'cjs', 14 | external: ['vscode', './gitExport', './praise'], 15 | ...(outfile ? {outfile} : {}), 16 | } 17 | } 18 | 19 | async function main() { 20 | const results = await Promise.all([ 21 | esbuild.build( 22 | {...buildConfig('./src/main.ts', 'out/main.js')} 23 | ), 24 | esbuild.build( 25 | {...buildConfig('./src/praise.ts', 'out/praise.js'), external: ['vscode']} 26 | ), 27 | esbuild.build( 28 | {...buildConfig('./src/gitExport.ts', 'out/gitExport.js')} 29 | ), 30 | esbuild.build( 31 | {...buildConfig('./media/preview.ts', 'media/preview.js'), platform:'browser', format: 'iife'} 32 | ), 33 | ]) 34 | console.log('fossil extension js files are ready') 35 | } 36 | 37 | main() 38 | -------------------------------------------------------------------------------- /src/uri.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------------------------------------------- 2 | * Copyright (c) Ben Crowl. All rights reserved. 3 | * Original Copyright (c) Microsoft Corporation. All rights reserved. 4 | * Licensed under the MIT License. See LICENSE.txt in the project root for license information. 5 | *--------------------------------------------------------------------------------------------*/ 6 | 7 | 'use strict'; 8 | 9 | import { Uri } from 'vscode'; 10 | import { FossilCheckin } from './openedRepository'; 11 | 12 | export interface FossilUriParams { 13 | // full filesystem path 14 | path: string; 15 | checkin: FossilCheckin; 16 | } 17 | 18 | export function fromFossilUri(uri: Uri): FossilUriParams { 19 | return JSON.parse(uri.query); 20 | } 21 | 22 | export function toFossilUri(uri: Uri, checkin: FossilCheckin = 'current'): Uri { 23 | const params: FossilUriParams = { 24 | path: uri.fsPath, 25 | checkin: checkin, 26 | }; 27 | return uri.with({ 28 | scheme: 'fossil', 29 | path: uri.path, 30 | query: JSON.stringify(params), 31 | }); 32 | } 33 | -------------------------------------------------------------------------------- /pikchr/test/position.test.pikchr: -------------------------------------------------------------------------------- 1 | // SYNTAX TEST "source.pikchr" "all checks for 'position' tree" 2 | 3 | < 4 | < test for `position -> expr -> ',' -> expr` 5 | < 6 | box at 1 , 1 7 | // ^^ keyword.pikchr 8 | // ^ ^ constant.numeric.pikchr 9 | // ^ punctuation.separator.pikchr 10 | 11 | < 12 | < `position -> place` 13 | < 14 | box at 1st box 15 | // ^^ keyword.pikchr 16 | // ^^^ constant.language.pikchr 17 | // ^^^ entity.name.function.pikchr 18 | 19 | < 20 | < `position -> place -> + -> expr -> ',' -> expr` 21 | < 22 | box at 3rd box + 0.1,9.3 23 | // ^^ keyword.pikchr 24 | // ^^^ constant.language.pikchr 25 | // ^^^ entity.name.function.pikchr 26 | // ^ keyword.operator.arithmetic.pikchr 27 | // ^^^ ^^^ constant.numeric.pikchr 28 | // ^ punctuation.separator.pikchr 29 | 30 | < 31 | < `position -> ( -> position -> )` 32 | < 33 | circle at (-0.055, -0.25) 34 | // ^^ keyword.pikchr 35 | // ^ punctuation.parenthesis.begin.pikchr 36 | // ^ ^ keyword.operator.arithmetic.pikchr 37 | // ^^^^^ ^^^^ constant.numeric.pikchr 38 | // ^ punctuation.parenthesis.end.pikchr 39 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 - present Keenan Kugler 4 | Original Copyright (c) 2017 - present Ben Crowl 5 | 6 | All rights reserved. 7 | 8 | Permission is hereby granted, free of charge, to any person obtaining a copy 9 | of this software and associated documentation files (the "Software"), to deal 10 | in the Software without restriction, including without limitation the rights 11 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 12 | copies of the Software, and to permit persons to whom the Software is 13 | furnished to do so, subject to the following conditions: 14 | 15 | The above copyright notice and this permission notice shall be included in all 16 | copies or substantial portions of the Software. 17 | 18 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 19 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 20 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 21 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 22 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 23 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 24 | SOFTWARE. 25 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | // Available variables which can be used inside of strings. 2 | // ${workspaceRoot}: the root folder of the team 3 | // ${file}: the current opened file 4 | // ${fileBasename}: the current opened file's basename 5 | // ${fileDirname}: the current opened file's dirname 6 | // ${fileExtname}: the current opened file's extension 7 | // ${cwd}: the current working directory of the spawned process 8 | 9 | // A task runner that calls a custom npm script that compiles the extension. 10 | { 11 | "version": "2.0.0", 12 | 13 | // we want to run npm 14 | "command": "npm", 15 | 16 | // the command is a shell script 17 | "type":"shell", 18 | 19 | // show the output window always. 20 | "presentation": { 21 | "echo": true, 22 | "reveal": "always", 23 | "focus": false, 24 | "panel": "shared", 25 | "showReuseMessage": true, 26 | "clear": false 27 | }, 28 | 29 | // we run the custom script "compile" as defined in package.json 30 | "args": ["run", "compile", "--loglevel", "info"], 31 | 32 | // The tsc compiler is started in watching mode 33 | "isBackground": true, 34 | 35 | // use the standard tsc in watch mode problem matcher to find compile problems in the output. 36 | "problemMatcher": "$tsc-watch" 37 | } -------------------------------------------------------------------------------- /src/test/suite/index.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | import * as Mocha from 'mocha'; 3 | import * as fs from 'fs'; 4 | 5 | export function run(testsRoot: string): Promise { 6 | // Create the mocha test 7 | const mocha = new Mocha({ 8 | ui: 'tdd', 9 | }); 10 | 11 | return new Promise((c, e) => { 12 | fs.readdir(testsRoot, { withFileTypes: true }, (err, files) => { 13 | /* c8 ignore next 3 */ 14 | if (err) { 15 | return e(err); 16 | } 17 | 18 | // Add files to the test suite 19 | files 20 | .filter(f => f.isFile() && f.name.endsWith('.test.js')) 21 | .forEach(f => mocha.addFile(path.resolve(testsRoot, f.name))); 22 | 23 | try { 24 | // Run the mocha test 25 | mocha.run(failures => { 26 | /* c8 ignore next 2 */ 27 | if (failures > 0) { 28 | e(new Error(`${failures} tests failed.`)); 29 | } else { 30 | c(); 31 | } 32 | }); 33 | /* c8 ignore next 3 */ 34 | } catch (err) { 35 | e(err); 36 | } 37 | }); 38 | }); 39 | } 40 | -------------------------------------------------------------------------------- /pikchr/test/homepage.example.test.pikchr: -------------------------------------------------------------------------------- 1 | // SYNTAX TEST "source.pikchr" "https://pikchr.org/home/doc/trunk/homepage.md" 2 | 3 | arrow right 200% "Markdown" "Source" 4 | // <----- storage.type.class.pikchr 5 | // ^^^^^ support.constant.edge.pikchr 6 | // ^^^ constant.numeric.pikchr 7 | // ^ keyword.other.unit.percentage.pikchr 8 | // ^^^^^^^^^^ ^^^^^^^^ string.quoted.double.pikchr 9 | 10 | box rad 10px "Markdown" "Formatter" "(markdown.c)" fit 11 | // <--- storage.type.class.pikchr 12 | // ^ -storage.type.class.pikchr 13 | // ^^^ support.constant.property-value.pikchr 14 | // ^^^^ constant.numeric.pikchr 15 | // ^^^^^^^^^ ^^^^^^^^^^^ ^^^^^^^^^^^^^ string.quoted.double.pikchr 16 | // ^^^ entity.name.tag.pikchr 17 | 18 | arrow <-> down 70% from last box.s 19 | // <--- storage.type.class.pikchr 20 | // ^^^ entity.name.tag.pikchr 21 | // ^^^^ support.constant.edge.pikchr 22 | // ^^ constant.numeric.pikchr 23 | // ^ keyword.other.unit.percentage.pikchr 24 | // ^^^^ keyword.pikchr 25 | // ^^^^ constant.language.pikchr 26 | // ^^^ entity.name.function.pikchr 27 | // ^ punctuation.separator.period.pikchr 28 | // ^ constant.language.pikchr 29 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "plugins": [ 5 | "@typescript-eslint", 6 | "prettier" 7 | ], 8 | "extends": [ 9 | "eslint:recommended", 10 | "plugin:@typescript-eslint/eslint-recommended", 11 | "plugin:@typescript-eslint/recommended" //, 12 | //"prettier/@typescript-eslint", 13 | //"plugin:prettier/recommended" 14 | ], 15 | "rules": { 16 | "prettier/prettier": "error", 17 | "no-irregular-whitespace": [ 18 | "error", 19 | { 20 | "skipTemplates": true 21 | } 22 | ], 23 | "@typescript-eslint/no-unused-vars": [ 24 | "error", 25 | { 26 | "varsIgnorePattern": "^_|^toString$", 27 | "argsIgnorePattern": "^_" 28 | } 29 | ], 30 | "no-cond-assign": 2, 31 | "no-constant-condition": 0, 32 | "no-inner-declarations": 2, 33 | "no-prototype-builtins": 0, 34 | "@typescript-eslint/no-explicit-any": 0, 35 | "@typescript-eslint/no-non-null-assertion": 0, 36 | "@typescript-eslint/ban-types": 0 37 | }, 38 | "ignorePatterns": [ 39 | "node_modules/", 40 | "out/", 41 | "coverage/", 42 | "resources/", 43 | ".vscode/" 44 | ] 45 | } -------------------------------------------------------------------------------- /pikchr/test/boolproperty.test.pikchr: -------------------------------------------------------------------------------- 1 | // SYNTAX TEST "source.pikchr" "Properties with no argument" 2 | 3 | arrow cw 4 | // ^^ source.pikchr entity.name.tag.pikchr 5 | box ccw 6 | // ^^^ source.pikchr entity.name.tag.pikchr 7 | box <- 8 | // ^^ source.pikchr entity.name.tag.pikchr 9 | box -> 10 | // ^^ source.pikchr entity.name.tag.pikchr 11 | box <-> 12 | // ^^^ source.pikchr entity.name.tag.pikchr 13 | arrow invis 14 | // ^^^^^ source.pikchr entity.name.tag.pikchr 15 | box invisible 16 | // ^^^^^^^^^ source.pikchr entity.name.tag.pikchr 17 | box thick 18 | // ^^^^^ source.pikchr entity.name.tag.pikchr 19 | box thin 20 | // ^^^^ source.pikchr entity.name.tag.pikchr 21 | arrow solid 22 | // ^^^^^ source.pikchr entity.name.tag.pikchr 23 | cylinder same 24 | // <-------- storage.type.class.pikchr 25 | // ^ source.pikchr 26 | // ^^^^ source.pikchr entity.name.tag.pikchr 27 | box same; 28 | // <--- storage.type.class.pikchr 29 | // ^^^^ source.pikchr entity.name.tag.pikchr 30 | arrow → 31 | // ^^^^^^ entity.name.tag.pikchr 32 | arrow → 33 | // ^^^^^^^^^^^^ entity.name.tag.pikchr 34 | arrow ← 35 | // ^^^^^^ entity.name.tag.pikchr 36 | arrow ← 37 | // ^^^^^^^^^^^ entity.name.tag.pikchr 38 | arrow ↔ 39 | // ^^^^^^^^^^^^^^^^ entity.name.tag.pikchr 40 | arrow ← 41 | // ^ entity.name.tag.pikchr 42 | arrow → 43 | // ^ entity.name.tag.pikchr 44 | arrow ↔ 45 | // ^ entity.name.tag.pikchr 46 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: 🕷️ Bug report 2 | description: Report errors or unexpected behavior. 3 | labels: [bug] 4 | body: 5 | - type: markdown 6 | attributes: 7 | value: | 8 | Thanks for reporting issues of fossil-scm vscode extension! 9 | 10 | To make it easier for us to help you please enter detailed information below. 11 | - type: dropdown 12 | id: desire 13 | attributes: 14 | label: Desire 15 | description: How much do you want this to be fixed? 16 | options: 17 | - 1 18 | - 2 19 | - 3 20 | - 4 21 | - 5 22 | - 6 23 | - 7 24 | - 8 25 | - 9 26 | - 10 27 | validations: 28 | required: true 29 | - type: checkboxes 30 | id: help 31 | attributes: 32 | label: Help 33 | description: How much are you willing to help?. Its okay if you choose none. 34 | options: 35 | - label: Discuss 36 | - label: Test 37 | - label: Code 38 | - type: textarea 39 | attributes: 40 | label: Steps to reproduce 41 | placeholder: | 42 | 1. 43 | 2. 44 | 3. 45 | - type: textarea 46 | attributes: 47 | label: Description 48 | validations: 49 | required: true 50 | - type: input 51 | attributes: 52 | label: OS 53 | - type: input 54 | attributes: 55 | label: Fossil version 56 | - type: input 57 | attributes: 58 | label: Vscode/Vscodium version 59 | -------------------------------------------------------------------------------- /src/test/runTest.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | import * as fs from 'fs/promises'; 3 | import * as os from 'os'; 4 | 5 | import { runTests } from '@vscode/test-electron'; 6 | 7 | async function main() { 8 | try { 9 | // The folder containing the Extension Manifest package.json 10 | // Passed to `--extensionDevelopmentPath` 11 | const extensionDevelopmentPath = path.resolve(__dirname, '../../'); 12 | 13 | // The path to the extension test runner script 14 | // Passed to --extensionTestsPath 15 | const extensionTestsPath = path.resolve(__dirname, './suite'); 16 | 17 | const testWorkspace = path.resolve(os.tmpdir(), './test_repo'); 18 | await fs.mkdir(testWorkspace, { recursive: true }); 19 | console.log(`testWorkspace: '${testWorkspace}'`); 20 | console.log(`extensionDevelopmentPath: '${extensionDevelopmentPath}'`); 21 | 22 | // Download VS Code, unzip it and run the integration test 23 | await runTests({ 24 | extensionDevelopmentPath, 25 | extensionTestsPath, 26 | launchArgs: [testWorkspace, '--disable-extensions', '--no-sandbox'], 27 | // Fix version to stop tests failing as time goes by. See: 28 | // https://github.com/microsoft/vscode-test/issues/221 29 | version: '1.79.2', 30 | }); 31 | /* c8 ignore next 4 */ 32 | } catch (err) { 33 | console.error('Failed to run tests', err); 34 | process.exit(1); 35 | } 36 | } 37 | 38 | main(); 39 | -------------------------------------------------------------------------------- /.github/workflows/fossil.yml: -------------------------------------------------------------------------------- 1 | name: Fossil 2 | 3 | on: 4 | push: 5 | branches: [ "master" ] 6 | tags: 7 | - v* 8 | pull_request: 9 | branches: [ "master" ] 10 | 11 | jobs: 12 | main: 13 | name: Run Fossil Tests 14 | runs-on: ubuntu-latest 15 | permissions: 16 | contents: write 17 | security-events: write 18 | steps: 19 | - name: Checkout 20 | uses: actions/checkout@v4 21 | - name: Set up Node 22 | uses: actions/setup-node@v4 23 | with: 24 | cache: 'npm' 25 | node-version: 18 26 | - name: Install dependencies 27 | run: npm ci 28 | - name: Run ESLint 29 | run: npm run lint -- 30 | --format @microsoft/eslint-formatter-sarif 31 | --output-file eslint-results.sarif 32 | continue-on-error: true 33 | 34 | - name: Upload analysis results to GitHub 35 | uses: github/codeql-action/upload-sarif@v3 36 | with: 37 | sarif_file: eslint-results.sarif 38 | wait-for-processing: true 39 | 40 | - name: Install fossil 41 | # we need 'xvfb libnss3-dev libgtk-3-dev libasound2' 42 | # when running locally using `gh act` command 43 | run: sudo apt-get install -y fossil 44 | 45 | - name: Run code tests and coverage 46 | run: xvfb-run -a npm run coverage-ci $GITHUB_STEP_SUMMARY 47 | 48 | - name: Run pikchr grammar tests 49 | run: npm run grammar-test 50 | 51 | - name: Package 52 | run: rm -rf out && npm run package 53 | 54 | - name: Release 55 | uses: softprops/action-gh-release@v2 56 | if: startsWith(github.ref, 'refs/tags/v') 57 | with: 58 | files: fossil-*.*.*.vsix 59 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | // A launch configuration that compiles the extension and then opens it inside a new window 2 | { 3 | "version": "0.1.0", 4 | "configurations": [ 5 | { 6 | "name": "Fossil Extension (--disable-extensions)", 7 | "type": "extensionHost", 8 | "request": "launch", 9 | "runtimeExecutable": "${execPath}", 10 | "args": [ 11 | "--extensionDevelopmentPath=${workspaceRoot}", 12 | "--disable-extensions" 13 | ], 14 | "sourceMaps": true, 15 | "outFiles": [ 16 | "${workspaceRoot}/out/*.js" 17 | ], 18 | "preLaunchTask": "npm" 19 | }, 20 | { 21 | "name": "Fossil Extension", 22 | "type": "extensionHost", 23 | "request": "launch", 24 | "runtimeExecutable": "${execPath}", 25 | "args": [ 26 | "--extensionDevelopmentPath=${workspaceRoot}", 27 | ], 28 | "sourceMaps": true, 29 | "outFiles": [ 30 | "${workspaceRoot}/out/*.js" 31 | ], 32 | "preLaunchTask": "npm" 33 | }, 34 | { 35 | "name": "Fossil Tests", 36 | "type": "extensionHost", 37 | "request": "launch", 38 | "runtimeExecutable": "${execPath}", 39 | "args": [ 40 | "--extensionDevelopmentPath=${workspaceFolder}", 41 | "--extensionTestsPath=${workspaceFolder}/out/test/suite", 42 | "${workspaceFolder}/out/test/test_repo", 43 | "--disable-extensions" 44 | ], 45 | "outFiles": [ 46 | "${workspaceFolder}/out/test/**/*.js" 47 | ], 48 | "preLaunchTask": "npm" 49 | } 50 | ] 51 | } -------------------------------------------------------------------------------- /media/preview.css: -------------------------------------------------------------------------------- 1 | html, body { 2 | font-family: var(--markdown-font-family, -apple-system, BlinkMacSystemFont, "Segoe WPC", "Segoe UI", system-ui, "Ubuntu", "Droid Sans", sans-serif); 3 | font-size: var(--markdown-font-size, 14px); 4 | padding: 0 26px; 5 | line-height: var(--markdown-line-height, 22px); 6 | word-wrap: break-word; 7 | } 8 | .oldFossil .pikchr-svg, .oldFossil svg.pikchr { 9 | background-color: white; 10 | color: #333; 11 | } 12 | .pikchr-src { 13 | display: none; 14 | } 15 | 16 | body { 17 | padding-top: 1em; 18 | margin-bottom: calc(100vh - 22px); 19 | } 20 | h1, h2, h3, h4, h5, h6, 21 | p, ol, ul, pre { 22 | margin-top: 0; 23 | } 24 | 25 | h2, h3, h4, h5, h6 { 26 | font-weight: normal; 27 | margin-bottom: 0.2em; 28 | } 29 | select:focus, 30 | textarea:focus { 31 | outline: 1px solid -webkit-focus-ring-color; 32 | outline-offset: -1px; 33 | } 34 | 35 | p { 36 | margin-bottom: 0.7em; 37 | } 38 | 39 | ul, 40 | ol { 41 | margin-bottom: 0.7em; 42 | } 43 | 44 | hr { 45 | border: 0; 46 | height: 2px; 47 | border-bottom: 2px solid; 48 | } 49 | 50 | h1 { 51 | padding-bottom: 0.3em; 52 | line-height: 1.2; 53 | border-bottom-width: 1px; 54 | border-bottom-style: solid; 55 | font-weight: normal; 56 | } 57 | 58 | table { 59 | border-collapse: collapse; 60 | margin-bottom: 0.7em; 61 | } 62 | 63 | th { 64 | text-align: left; 65 | border-bottom: 1px solid; 66 | } 67 | 68 | th, 69 | td { 70 | padding: 5px 10px; 71 | } 72 | 73 | table > tbody > tr + tr > td { 74 | border-top: 1px solid; 75 | } 76 | 77 | blockquote { 78 | margin: 0 7px 0 5px; 79 | padding: 0 16px 0 10px; 80 | border-left-width: 5px; 81 | border-left-style: solid; 82 | } 83 | 84 | code { 85 | font-family: var(--vscode-editor-font-family, "SF Mono", Monaco, Menlo, Consolas, "Ubuntu Mono", "Liberation Mono", "DejaVu Sans Mono", "Courier New", monospace); 86 | font-size: 1em; 87 | line-height: 1.357em; 88 | } 89 | -------------------------------------------------------------------------------- /docs/cloning.md: -------------------------------------------------------------------------------- 1 | # Cloning with Fossil in VS Code 2 | 3 | ![Commands](/images/fossil-commands.png) 4 | 5 | Cloning is possible from the fossil extension through the command palette 6 | (Ctrl-Shift-P). Search for `Fossil: Clone`. 7 | 8 | ### Fossil Repository 9 | You'll first be prompted to enter the repository URI. Enter the entire 10 | URI, including the scheme (ex. `http://` , `file://` , `https://` , etc) 11 | 12 | As an Example: 13 | ![FossilURI](/images/fossil-uri.png) 14 | 15 | Hitting `Esc` will abort the cloning process 16 | 17 | ### Username 18 | ![fossil-user](/images/fossil-user.png) 19 | 20 | You will be prompted for your repository authentication user name. 21 | If you do not have a repository user name leave this blank. 22 | Because a user name is not required, hitting `Esc` at this step does not 23 | abort the cloning process. 24 | 25 | ### User Authentication 26 | ![fossil-auth](/images/fossil-auth.png) 27 | 28 | If you entered a username you will be prompted to enter your user 29 | authentication (password). Aborting here (by hitting `Esc`) does not 30 | abort the cloning process but falls back to an anonymous clone (no 31 | usernname and no authentication). 32 | 33 | ### Parent Directory 34 | ![fossil-root](/images/fossil-root.png) 35 | 36 | Enter the root directory for the cloned repo. If VS Code is opened to a 37 | folder the parent root directory will default to the currently opened 38 | folder, otherwise it will be blank. Hitting `Esc` here will abort the 39 | cloning process. 40 | 41 | ### Input Prompts 42 | Various prompts may come up while cloning. 43 | If these prompts are unclear then abort by hitting `Esc` and run your 44 | `fossil clone` command from the built-in terminal (Ctrl+`). 45 | 46 | Most notably this rather ugly prompt about SSL failure 47 | can be read about on the 48 | [Fossil SSL Certificate](https://fossil-scm.org/home/doc/trunk/www/ssl.wiki#certs) 49 | wikipage: 50 | ![fossil-ssl-fail](/images/fossil-ssl-fail.png) 51 | -------------------------------------------------------------------------------- /src/iterators.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------------------------------------------- 2 | * Copyright (c) Ben Crowl. All rights reserved. 3 | * Original Copyright (c) Microsoft Corporation. All rights reserved. 4 | * Licensed under the MIT License. See LICENSE.txt in the project root for license information. 5 | *--------------------------------------------------------------------------------------------*/ 6 | 7 | function* filter( 8 | it: IterableIterator, 9 | condition: (t: T, i: number) => boolean 10 | ): IterableIterator { 11 | let i = 0; 12 | for (const t of it) { 13 | if (condition(t, i++)) { 14 | yield t; 15 | } 16 | } 17 | } 18 | 19 | function* map( 20 | it: IterableIterator, 21 | fn: (t: T, i: number) => R 22 | ): IterableIterator { 23 | let i = 0; 24 | for (const t of it) { 25 | yield fn(t, i++); 26 | } 27 | } 28 | 29 | export interface FunctionalIterator extends Iterable { 30 | filter(condition: (t: T, i: number) => boolean): FunctionalIterator; 31 | map(fn: (t: T, i: number) => R): FunctionalIterator; 32 | toArray(): T[]; 33 | } 34 | 35 | class FunctionalIteratorImpl implements FunctionalIterator { 36 | constructor(private iterator: IterableIterator) {} 37 | 38 | filter(condition: (t: T, i: number) => boolean): FunctionalIterator { 39 | return new FunctionalIteratorImpl(filter(this.iterator, condition)); 40 | } 41 | 42 | map(fn: (t: T, i: number) => R): FunctionalIterator { 43 | return new FunctionalIteratorImpl(map(this.iterator, fn)); 44 | } 45 | 46 | toArray(): T[] { 47 | return Array.from(this.iterator); 48 | } 49 | 50 | [Symbol.iterator](): IterableIterator { 51 | return this.iterator; 52 | } 53 | } 54 | 55 | export function iterate( 56 | obj: T[] | IterableIterator 57 | ): FunctionalIterator { 58 | if (Array.isArray(obj)) { 59 | return new FunctionalIteratorImpl(obj[Symbol.iterator]()); 60 | } 61 | 62 | return new FunctionalIteratorImpl(obj); 63 | } 64 | -------------------------------------------------------------------------------- /src/test/suite/extension.test.ts: -------------------------------------------------------------------------------- 1 | import { before, afterEach, Suite } from 'mocha'; 2 | import * as sinon from 'sinon'; 3 | import { 4 | TagSuite, 5 | StatusSuite, 6 | CleanSuite, 7 | FileSystemSuite, 8 | DiffSuite, 9 | } from './commandSuites'; 10 | import { MergeSuite } from './mergeSuite'; 11 | import { cleanRoot, fossilInit, fossilOpen } from './common'; 12 | import { utilitiesSuite } from './utilitiesSuite'; 13 | import { resourceActionsSuite } from './resourceActionsSuite'; 14 | import { timelineSuite } from './timelineSuite'; 15 | import { CommitSuite } from './commitSuite'; 16 | import { QualityOfLifeSuite as QualityOfLifeSuite } from './qualityOfLifeSuite'; 17 | import { PatchSuite, StageSuite, StashSuite, UpdateSuite } from './stateSuite'; 18 | import { RenameSuite } from './renameSuite'; 19 | import { BranchSuite } from './branchSuite'; 20 | import { RevertSuite } from './revertSuite'; 21 | import { GitExportSuite } from './gitExportSuite'; 22 | import { StatusBarSuite } from './statusBarSuite'; 23 | import { workspace } from 'vscode'; 24 | 25 | suite('Fossil.OpenedRepo', function (this: Suite) { 26 | this.ctx.sandbox = sinon.createSandbox(); 27 | this.ctx.workspaceUri = workspace.workspaceFolders![0].uri; 28 | 29 | before(async () => { 30 | this.timeout(5555); 31 | await cleanRoot(); 32 | await fossilInit(this.ctx.sandbox); 33 | await fossilOpen(this.ctx.sandbox); 34 | }); 35 | 36 | suite('Utilities', utilitiesSuite); 37 | suite('Update', UpdateSuite); 38 | suite('Status Bar', StatusBarSuite); 39 | suite('Resource Actions', resourceActionsSuite); 40 | suite('Timeline', timelineSuite); 41 | suite('Revert', RevertSuite); 42 | suite('Stash', StashSuite); 43 | suite('Branch', BranchSuite); 44 | suite('Commit', CommitSuite); 45 | suite('Patch', PatchSuite); 46 | suite('Merge', MergeSuite); 47 | suite('Tag', TagSuite); 48 | suite('Status', StatusSuite); 49 | suite('Stage', StageSuite); 50 | suite('Rename', RenameSuite); 51 | suite('Clean', CleanSuite); 52 | suite('FileSystem', FileSystemSuite); 53 | suite('Diff', DiffSuite); 54 | suite('Quality of Life', QualityOfLifeSuite); 55 | suite('Git Export', GitExportSuite); 56 | 57 | afterEach(() => { 58 | this.ctx.sandbox.restore(); 59 | }); 60 | }); 61 | -------------------------------------------------------------------------------- /media/preview.ts: -------------------------------------------------------------------------------- 1 | const $ = (s: string) => document.getElementById(s); 2 | const vscode = acquireVsCodeApi<{ uri: string }>(); 3 | type Nodes = NodeListOf; 4 | 5 | function htmlToElements(html: string): Nodes { 6 | const template = document.createElement('template'); 7 | template.innerHTML = html; 8 | return template.content.childNodes; 9 | } 10 | 11 | function find_diff(olds: Nodes, news: Nodes): ChildNode { 12 | const n = Math.max(olds.length, news.length); 13 | for (let i = 0; i < n; ++i) { 14 | if (olds[i] && news[i]) { 15 | if ( 16 | olds[i].isEqualNode(news[i]) || 17 | news[i].nodeType == Node.COMMENT_NODE 18 | ) { 19 | continue; 20 | } else { 21 | if (!news[i].childNodes.length) { 22 | return news[i]; 23 | } 24 | return find_diff(olds[i].childNodes, news[i].childNodes); 25 | } 26 | } else { 27 | return news[i]; 28 | } 29 | } 30 | return news[0]; 31 | } 32 | 33 | function scrollIntoViewIfNeeded(target: ChildNode): void { 34 | while (target.nodeType != target.ELEMENT_NODE) { 35 | target = target.previousSibling ?? target.parentElement; 36 | } 37 | const rect = (target as Element).getBoundingClientRect(); 38 | if (rect.bottom > window.innerHeight || rect.top < 0) { 39 | (target as Element).scrollIntoView({ 40 | behavior: 'auto', 41 | block: 'center', 42 | inline: 'center', 43 | }); 44 | } 45 | } 46 | 47 | window.addEventListener( 48 | 'message', 49 | (event: MessageEvent<{ html: string; uri: string }>): void => { 50 | const news = htmlToElements(event.data.html); 51 | const content = $('fossil-preview-content'); 52 | const diff_el = find_diff(content.childNodes, news); 53 | content.replaceChildren(...news); 54 | vscode.setState({ uri: event.data.uri }); 55 | if (diff_el) { 56 | scrollIntoViewIfNeeded(diff_el); 57 | } 58 | } 59 | ); 60 | 61 | window.addEventListener('keydown', e => { 62 | if (e.key === 's' && (e.ctrlKey || e.metaKey)) { 63 | vscode.postMessage({ action: 'save' }); 64 | } 65 | }); 66 | 67 | window.addEventListener('load', () => { 68 | vscode.postMessage({ action: 'update' }); 69 | }); 70 | -------------------------------------------------------------------------------- /pikchr/test/attribute.test.pikchr: -------------------------------------------------------------------------------- 1 | // SYNTAX TEST "source.pikchr" "checks for 'attribute' tree" 2 | 3 | arrow from C6 to C3P chop 4 | // ^^^^ ^^ keyword.pikchr 5 | // ^^ ^^^ variable.language.place.pikchr 6 | // ^^^^ entity.name.tag.pikchr 7 | arrow right then down 8 | // ^^^^^ ^^^^ support.constant.edge.pikchr 9 | // ^^^^ keyword.pikchr 10 | // ^ -keyword.pikchr 11 | circle at bot of first arrow 12 | // ^^^ support.constant.edge.pikchr 13 | circle at bottom of first arrow 14 | // ^^^^^^ support.constant.edge.pikchr 15 | circle at c of first arrow 16 | // ^ support.constant.edge.pikchr 17 | circle at center of first arrow 18 | // ^^^^^^ support.constant.edge.pikchr 19 | circle at e of first arrow 20 | // ^ support.constant.edge.pikchr 21 | circle at east of first arrow 22 | // ^^^^ support.constant.edge.pikchr 23 | circle at end of first arrow 24 | // ^^^ support.constant.edge.pikchr 25 | circle at left of first arrow 26 | // ^^^^ support.constant.edge.pikchr 27 | circle at n of first arrow 28 | // ^ support.constant.edge.pikchr 29 | circle at ne of first arrow 30 | // ^^ support.constant.edge.pikchr 31 | circle at north of first arrow 32 | // ^^^^^ support.constant.edge.pikchr 33 | circle at nw of first arrow 34 | // ^^ support.constant.edge.pikchr 35 | circle at right of first arrow 36 | // ^^^^^ support.constant.edge.pikchr 37 | circle at s of first arrow 38 | // ^ support.constant.edge.pikchr 39 | circle at se of first arrow 40 | // ^^ support.constant.edge.pikchr 41 | circle at south of first arrow 42 | // ^^^^^ support.constant.edge.pikchr 43 | circle at start of first arrow 44 | // ^^^^^ support.constant.edge.pikchr 45 | circle at sw of first arrow 46 | // ^^ support.constant.edge.pikchr 47 | circle at t of first arrow 48 | // ^ support.constant.edge.pikchr 49 | circle at top of first arrow 50 | // ^^^ support.constant.edge.pikchr 51 | circle at w of first arrow 52 | // ^ support.constant.edge.pikchr 53 | circle at west of first arrow 54 | // ^^^^ support.constant.edge.pikchr 55 | cylinder "B" at 5cm heading 125 from A 56 | // <-------- storage.type.class.pikchr 57 | // ^^^ string.quoted.double.pikchr 58 | // ^^ ^^^^^^^ ^^^^ keyword.pikchr 59 | // ^^^ ^^^ constant.numeric.pikchr 60 | // ^ variable.language.place.pikchr 61 | -------------------------------------------------------------------------------- /docs/dev/scenarios.md: -------------------------------------------------------------------------------- 1 | # Scenarios for README.md videos and images 2 | 3 | 4 | ## Software 5 | 6 | * Chronicler plugin for VScode is used to capture video 7 | 8 | ```json 9 | "chronicler.recording-defaults": { 10 | "animatedGif": false, 11 | "fps": 5, 12 | "gifScale": 1.0, 13 | "countdown": 1, 14 | "flags": { 15 | "pix_fmt": "rgb24", 16 | "c:v": "png", 17 | "vcodec": "rawvideo", 18 | "vframes": "1" 19 | } 20 | } 21 | ``` 22 | 23 | * Extract frames with `ffmpeg -i fossil-1677169739283.mp4 'out%06d.png'` 24 | * Use professional software to generate `gif` 25 | 26 | 27 | ## Images 28 | 29 | ### Setup 30 | 31 | * Color theme: `Dark+` 32 | * Window size: `1168 x 720` 33 | * Set using you OS or window manager api 34 | * Zoom level: `1` 35 | * Set zoom level using `ctrl+,` 36 | * All extensions except *Chronicler* and *Fossil* are disabled 37 | * Use latest Fossil extension 38 | 39 | ### `fossil.png` 40 | 41 | * Opened official fossil repository 42 | * Source control panel is visible 43 | * Unresolved conflicts: some 44 | * Changes: 45 | * Added: some 46 | * Modified: some 47 | * Untracked files: 48 | * Staged files: 49 | ```bash 50 | #!/usr/bin/env bash 51 | # example 52 | fossil clone https://fossil-scm.org/home fossil.fossil 53 | fossil open fossil.fossil 54 | printf 'modification for fossil.png\n' >> www/quotes.wiki 55 | fossil commit -m "commit for conflict" 56 | fossil up forumpost-locking --nosync 57 | touch autoconfig.h config.log a_new_file.md 58 | fossil add a_new_file.md 59 | printf 'conflict for fossil.png\n' >> www/quotes.wiki 60 | printf 'modification for fossil.png\n' >> BUILD.txt 61 | fossil merge trunk 62 | ``` 63 | * Status bar shows current branch as `trunk+` 64 | * Editor is split horizontally 65 | * Left: 66 | * `pikchr.md` 67 | * `quotes.wiki` 68 | * `fossil_prompt.wiki` 69 | * Right: 70 | * `pikchr.md` - preview with pikchr diagram visible 71 | 72 | ### `fossil-diff.gif` (View file changes) 73 | 74 | 1. Same setup as `fossil.png`, but all files are closed 75 | 2. Click on a file that shows diff in quotes.wiki 76 | 3. Highlight the diff with funny text: 77 | > You should give a try to this fossil VSCode plugin 78 | 4. End 79 | 80 | ### `init.gif` (Initialize a new repo) 81 | 82 | 1. Status: No repository is opened, Explorer view is selected 83 | 2. Source Control panel is opened 84 | 3. Fossil icon is clicked 85 | 4. Fossil file location is selected 86 | 5. Project name is entered 87 | 6. Project description is entered 88 | 7. "Would you like to open the cloned repository?" yes 89 | 8. End 90 | 91 | ### `change-branch.gif` (Update to a branch/tag) 92 | 93 | 1. Status: Fossil repo is opened 94 | 2. Branch name is status bar is clicked 95 | 3. A branch is selected 96 | 4. End 97 | -------------------------------------------------------------------------------- /package.nls.json: -------------------------------------------------------------------------------- 1 | { 2 | "command.clone": "Clone Fossil Repository", 3 | "command.init": "Initialize Fossil Repository", 4 | "command.open": "Open Fossil Repository", 5 | "command.close": "Close Repository", 6 | "command.openUI": "Open web UI", 7 | "command.refresh": "Refresh", 8 | "command.openChange": "Open Changes", 9 | "command.openFile": "Open File", 10 | "command.openFiles": "Open Files", 11 | "command.stage": "Stage Changes", 12 | "command.stageAll": "Stage All Changes", 13 | "command.unstage": "Unstage Changes", 14 | "command.unstageAll": "Unstage All Changes", 15 | "command.clean": "Delete Extras", 16 | "command.revert": "Discard Changes", 17 | "command.revertAll": "Discard All Changes", 18 | "command.commit": "Commit", 19 | "command.commitStaged": "Commit Staged", 20 | "command.commitAll": "Commit All", 21 | "command.commitBranch": "Commit Creating New Branch...", 22 | "command.sync": "Sync", 23 | "command.undo": "Undo", 24 | "command.update": "Update", 25 | "command.redo": "Redo", 26 | "command.branchChange": "Change Branch / Update to...", 27 | "command.branch": "Create Branch...", 28 | "command.pull": "Pull", 29 | "command.push": "Push", 30 | "command.pushTo": "Push to...", 31 | "command.add": "Add Files", 32 | "command.addAll": "Add All Untracked Files", 33 | "command.addFilePicker": "Select Files to Add", 34 | "command.ignore": "Add to ignore-glob", 35 | "command.forget": "Forget Files", 36 | "command.relocate": "Select New File Location", 37 | "command.showOutput": "Show fossil output", 38 | "command.fileLog": "Show file history...", 39 | "command.render": "Preview Using Fossil Renderer", 40 | "command.wikiCreate": "Publish as Fossil Wiki or Technote", 41 | "command.log": "Log...", 42 | "command.merge": "Merge into working directory...", 43 | "command.integrate": "Integrate into working directory...", 44 | "command.cherrypick": "Cherry-pick into working directory...", 45 | "config.path": "Path to the `fossil` executable (only required if auto-detection fails)", 46 | "config.username": "The username associated with each commit (`--user-override` argument, only required if different from the user that originally cloned the repo).", 47 | "config.defaultUsername": "Make the default user be USER (`--user` option)", 48 | "config.autoSyncInterval": "The duration, in seconds, between each background `fossil sync` operation. 0 to disable.", 49 | "config.autoRefresh": "Whether auto refreshing is enabled", 50 | "config.enableRenaming": "Show rename request after a file was renamed in UI", 51 | "config.enableLongCommitWarning": "Whether long commit messages should be warned about", 52 | "submenu.commit": "Commit", 53 | "submenu.merge": "Merge", 54 | "submenu.patch": "Patch", 55 | "submenu.stash": "Stash", 56 | "submenu.timeline": "Timeline" 57 | } -------------------------------------------------------------------------------- /src/throttlingQueue.ts: -------------------------------------------------------------------------------- 1 | type Task = { 2 | action: () => Promise; 3 | key: string; 4 | resolve: (value: any) => void; 5 | reject: (reason?: any) => void; 6 | }; 7 | 8 | class TPromise extends Promise { 9 | public newer?: Promise; 10 | } 11 | 12 | /** 13 | * Run tasks sequentially, allows throttling by key 14 | */ 15 | export class ThrottlingQueue { 16 | private readonly _items: Task[] = []; 17 | private readonly _keys = new Map>(); 18 | private _loopRunning: boolean = false; 19 | 20 | enqueue(action: () => Promise, key: string): Promise { 21 | const existing = this._keys.get(key); 22 | if (existing) { 23 | if (!existing.newer) { 24 | existing.newer = new Promise((resolve, reject) => { 25 | this._items.push({ action, resolve, reject, key }); 26 | }); 27 | } 28 | return existing.newer; 29 | } 30 | const promise = this._new(action, key); 31 | this._keys.set(key, promise); 32 | return promise; 33 | } 34 | 35 | private _new(action: () => Promise, key: string): Promise { 36 | return new Promise((resolve, reject) => { 37 | this._items.push({ action, resolve, reject, key }); 38 | this._task_loop(); 39 | }); 40 | } 41 | 42 | private _delete(key: string): void { 43 | const promise = this._keys.get(key)!; 44 | this._keys.delete(key); 45 | if (promise.newer) { 46 | this._keys.set(key, promise!.newer); 47 | } 48 | } 49 | 50 | private async _task_loop(): Promise { 51 | if (!this._loopRunning) { 52 | const item = this._items.shift(); 53 | 54 | if (item) { 55 | this._loopRunning = true; 56 | try { 57 | const payload = await item.action(); 58 | // this._loopRunning = false; 59 | item.resolve(payload); 60 | } catch (reason) { 61 | // this._loopRunning = false; 62 | item.reject(reason); 63 | } finally { 64 | this._delete(item.key); 65 | this._loopRunning = false; 66 | this._task_loop(); 67 | } 68 | } 69 | } 70 | } 71 | } 72 | 73 | type ObjectOfType = { 74 | [K in keyof T]: T[K] extends U ? K : never; 75 | }; 76 | 77 | type KeysOfType = ObjectOfType[keyof T]; 78 | 79 | export function queue>( 80 | queue_name: KeysOfType, 81 | queueKey: string 82 | ) { 83 | return function ( 84 | target: This, 85 | key: string, 86 | descriptor: TypedPropertyDescriptor< 87 | (this: This, ...args: any[]) => Promise 88 | > 89 | ) { 90 | const fn = descriptor.value!; 91 | 92 | descriptor.value = function (this: This, ...args: any[]): Promise { 93 | return this[queue_name].enqueue(fn.bind(this, ...args), queueKey); 94 | }; 95 | }; 96 | } 97 | -------------------------------------------------------------------------------- /src/fossilFinder.ts: -------------------------------------------------------------------------------- 1 | import * as cp from 'child_process'; 2 | import type { Distinct } from './openedRepository'; 3 | import type { 4 | FossilExecutablePath, 5 | FossilStdOut, 6 | FossilVersion, 7 | } from './fossilExecutable'; 8 | import { localize } from './main'; 9 | import { LogOutputChannel } from 'vscode'; 10 | 11 | export type UnvalidatedFossilExecutablePath = Distinct< 12 | string, 13 | 'unvalidated fossil executable path' | 'fossil executable path' 14 | >; 15 | 16 | export interface FossilExecutableInfo { 17 | path: FossilExecutablePath; 18 | version: FossilVersion; 19 | } 20 | 21 | function getVersion( 22 | path: UnvalidatedFossilExecutablePath 23 | ): Promise { 24 | return new Promise((c, e) => { 25 | const buffers: Buffer[] = []; 26 | const child = cp.spawn(path, ['version']); 27 | child.stdout.on('data', (b: Buffer) => buffers.push(b)); 28 | child.on('error', e); 29 | child.on('close', code => { 30 | if (!code) { 31 | return c( 32 | Buffer.concat(buffers).toString('utf8') as FossilStdOut 33 | ); 34 | } 35 | return e(new Error('Not found')); 36 | }); 37 | }); 38 | } 39 | 40 | export async function findFossil( 41 | hint: UnvalidatedFossilExecutablePath, 42 | outputChannel: LogOutputChannel 43 | ): Promise { 44 | for (const [path, isHint] of [ 45 | [hint, 1], 46 | ['fossil' as UnvalidatedFossilExecutablePath, 0], 47 | ] as const) { 48 | if (path) { 49 | let stdout: string; 50 | try { 51 | stdout = await getVersion(path); 52 | } catch (e: unknown) { 53 | if (isHint) { 54 | outputChannel.warn( 55 | `\`fossil.path\` '${path}' is unavailable (${e}). Will try 'fossil' as the path` 56 | ); 57 | } else { 58 | outputChannel.error( 59 | `'${path}' is unavailable (${e}). Fossil extension commands will be disabled` 60 | ); 61 | } 62 | continue; 63 | } 64 | 65 | const match = stdout.match(/version (.+)\[/); 66 | let version = [0] as FossilVersion; 67 | if (match) { 68 | version = match[1] 69 | .split('.') 70 | .map(s => parseInt(s)) as FossilVersion; 71 | } else { 72 | outputChannel.error( 73 | `Failed to parse fossil version from output: '${stdout}'` 74 | ); 75 | } 76 | 77 | outputChannel.info( 78 | localize( 79 | 'using fossil', 80 | 'Using fossil {0} from {1}', 81 | version.join('.'), 82 | path 83 | ) 84 | ); 85 | return { 86 | path: path as FossilExecutablePath, 87 | version: version, 88 | }; 89 | } 90 | } 91 | return undefined; 92 | } 93 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | import { workspace } from 'vscode'; 2 | import type { FossilUsername, Distinct } from './openedRepository'; 3 | import type { UnvalidatedFossilExecutablePath } from './fossilFinder'; 4 | 5 | export type AutoSyncIntervalMs = Distinct; 6 | 7 | interface ConfigScheme { 8 | ignoreMissingFossilWarning: boolean; 9 | path: UnvalidatedFossilExecutablePath; 10 | autoSyncInterval: number; 11 | username: FossilUsername; // must be ignored when empty 12 | defaultUsername: FossilUsername; // must be ignored when empty 13 | autoRefresh: boolean; 14 | enableRenaming: boolean; 15 | confirmGitExport: 'Automatically' | 'Never' | null; 16 | globalArgs: string[]; 17 | commitArgs: string[]; 18 | } 19 | 20 | class Config { 21 | private get config() { 22 | return workspace.getConfiguration('fossil'); 23 | } 24 | 25 | private get( 26 | name: TName 27 | ): ConfigScheme[TName] { 28 | // for keys existing in packages.json this function 29 | // will not return `undefined` 30 | return this.config.get(name)!; 31 | } 32 | 33 | get path(): UnvalidatedFossilExecutablePath { 34 | return this.get('path').trim() as UnvalidatedFossilExecutablePath; 35 | } 36 | 37 | /** 38 | * Enables automatic refreshing of Source Control tab and badge 39 | * counter when files within the project change. 40 | */ 41 | get autoRefresh(): boolean { 42 | return this.get('autoRefresh'); 43 | } 44 | 45 | get autoSyncIntervalMs(): AutoSyncIntervalMs { 46 | return (this.get('autoSyncInterval') * 1000) as AutoSyncIntervalMs; 47 | } 48 | 49 | get enableRenaming(): boolean { 50 | return this.get('enableRenaming'); 51 | } 52 | 53 | get ignoreMissingFossilWarning(): boolean { 54 | return this.get('ignoreMissingFossilWarning'); 55 | } 56 | 57 | disableMissingFossilWarning() { 58 | return this.config.update('ignoreMissingFossilWarning', true, false); 59 | } 60 | 61 | /** 62 | * * Specifies an explicit user to use for fossil commits. 63 | * * This should only be used if the user is different 64 | * than the fossil default user. 65 | */ 66 | get username(): FossilUsername { 67 | return this.get('username'); 68 | } 69 | 70 | disableRenaming() { 71 | return this.config.update('enableRenaming', false, false); 72 | } 73 | 74 | setGitExport(how: NonNullable) { 75 | return this.config.update('confirmGitExport', how, false); 76 | } 77 | 78 | get gitExport() { 79 | return this.get('confirmGitExport'); 80 | } 81 | 82 | get globalArgs() { 83 | const defaultUsername = this.get('defaultUsername'); 84 | const globalArgs = this.get('globalArgs'); 85 | if (defaultUsername) { 86 | // Return a copy to avoid modifying `globalArgs` 87 | return [...globalArgs, '--user', defaultUsername]; 88 | } 89 | return globalArgs; 90 | } 91 | get commitArgs() { 92 | return this.get('commitArgs'); 93 | } 94 | } 95 | 96 | const typedConfig = new Config(); 97 | export default typedConfig; 98 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------------------------------------------- 2 | * Copyright (c) Ben Crowl. All rights reserved. 3 | * Original Copyright (c) Microsoft Corporation. All rights reserved. 4 | * Licensed under the MIT License. See LICENSE.txt in the project root for license information. 5 | *--------------------------------------------------------------------------------------------*/ 6 | 7 | // based on https://github.com/Microsoft/vscode/commit/41f0ff15d7327da30fdae73aa04ca570ce34fa0a 8 | 9 | import { ExtensionContext, window, Disposable, commands, Uri } from 'vscode'; 10 | import { Model } from './model'; 11 | import { CommandCenter } from './commands'; 12 | import { FossilFileSystemProvider } from './fileSystemProvider'; 13 | import * as nls from 'vscode-nls'; 14 | import typedConfig from './config'; 15 | import { findFossil } from './fossilFinder'; 16 | import { FossilExecutable } from './fossilExecutable'; 17 | 18 | export const localize = nls.loadMessageBundle(); 19 | 20 | async function init(context: ExtensionContext): Promise { 21 | const disposables: Disposable[] = []; 22 | context.subscriptions.push( 23 | new Disposable(() => Disposable.from(...disposables).dispose()) 24 | ); 25 | 26 | const outputChannel = window.createOutputChannel('Fossil', { log: true }); 27 | disposables.push(outputChannel); 28 | 29 | const fossilHist = typedConfig.path; 30 | const fossilInfo = await findFossil(fossilHist, outputChannel); 31 | const executable = new FossilExecutable(outputChannel); 32 | 33 | const model = new Model(executable, fossilHist); 34 | disposables.push(model); 35 | model.foundExecutable(fossilInfo); 36 | if (!fossilInfo && !typedConfig.ignoreMissingFossilWarning) { 37 | const download = localize('downloadFossil', 'Download Fossil'); 38 | const neverShowAgain = localize('neverShowAgain', "Don't Show Again"); 39 | const editPath = localize('editPath', 'Edit "fossil.path"'); 40 | const choice = await window.showWarningMessage( 41 | localize( 42 | 'notfound', 43 | "Fossil was not found. Install it or configure it using the 'fossil.path' setting." 44 | ), 45 | download, 46 | editPath, 47 | neverShowAgain 48 | ); 49 | if (choice === download) { 50 | commands.executeCommand( 51 | 'vscode.open', 52 | Uri.parse('https://www.fossil-scm.org/') 53 | ); 54 | } else if (choice === editPath) { 55 | commands.executeCommand( 56 | 'workbench.action.openSettings', 57 | 'fossil.path' 58 | ); 59 | } else if (choice === neverShowAgain) { 60 | await typedConfig.disableMissingFossilWarning(); 61 | } 62 | } 63 | 64 | const onRepository = () => 65 | commands.executeCommand( 66 | 'setContext', 67 | 'fossilOpenRepositoryCount', 68 | model.repositories.length 69 | ); 70 | model.onDidOpenRepository(onRepository, null, disposables); 71 | model.onDidCloseRepository(onRepository, null, disposables); 72 | onRepository(); 73 | 74 | disposables.push( 75 | new CommandCenter(executable, model, outputChannel, context), 76 | new FossilFileSystemProvider(model) 77 | ); 78 | return model; 79 | } 80 | 81 | export async function activate( 82 | context: ExtensionContext 83 | ): Promise { 84 | return init(context).catch(err => console.error(err)); 85 | } 86 | -------------------------------------------------------------------------------- /src/util.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------------------------------------------- 2 | * Copyright (c) Ben Crowl. All rights reserved. 3 | * Original Copyright (c) Microsoft Corporation. All rights reserved. 4 | * Licensed under the MIT License. See LICENSE.txt in the project root for license information. 5 | *--------------------------------------------------------------------------------------------*/ 6 | 7 | import { Event } from 'vscode'; 8 | 9 | export interface IDisposable { 10 | dispose(): void; 11 | } 12 | 13 | export function dispose( 14 | disposables: Set | T[] 15 | ): void { 16 | disposables.forEach(d => d.dispose()); 17 | if (disposables instanceof Set) { 18 | disposables.clear(); 19 | } else { 20 | disposables.length = 0; 21 | } 22 | } 23 | 24 | export function toDisposable(dispose: () => void): IDisposable { 25 | return { dispose }; 26 | } 27 | 28 | function combinedDisposable(disposables: IDisposable[]): IDisposable { 29 | return toDisposable(() => dispose(disposables)); 30 | } 31 | 32 | export function filterEvent( 33 | event: Event, 34 | filter: (e: T) => boolean 35 | ): Event { 36 | return ( 37 | listener: (e: T) => any, 38 | thisArgs?: any, 39 | disposables?: IDisposable[] 40 | ) => event(e => filter(e) && listener.call(thisArgs, e), null, disposables); 41 | } 42 | 43 | export function anyEvent(...events: Event[]): Event { 44 | return ( 45 | listener: (e: T) => any, 46 | thisArgs?: any, 47 | disposables?: IDisposable[] 48 | ) => { 49 | const result = combinedDisposable( 50 | events.map(event => event(i => listener.call(thisArgs, i))) 51 | ); 52 | disposables?.push(result); 53 | return result; 54 | }; 55 | } 56 | 57 | export function done(promise: Promise): Promise { 58 | return promise.then(() => undefined); 59 | } 60 | 61 | export function once(event: Event): Event { 62 | return ( 63 | listener: (e: T) => any, 64 | thisArgs?: any, 65 | disposables?: IDisposable[] 66 | ) => { 67 | const result = event( 68 | e => { 69 | result.dispose(); 70 | return listener.call(thisArgs, e); 71 | }, 72 | null, 73 | disposables 74 | ); 75 | 76 | return result; 77 | }; 78 | } 79 | 80 | export function eventToPromise(event: Event): Promise { 81 | return new Promise(c => once(event)(c)); 82 | } 83 | 84 | export function groupBy( 85 | arr: T[], 86 | fn: (el: T) => string 87 | ): { [key: string]: T[] } { 88 | return arr.reduce((result, el) => { 89 | const key = fn(el); 90 | result[key] = [...(result[key] || []), el]; 91 | return result; 92 | }, Object.create(null)); 93 | } 94 | 95 | export function partition( 96 | array: T[], 97 | fn: (el: T, i: number, ary: T[]) => boolean 98 | ): [T[], T[]] { 99 | return array.reduce( 100 | (result: [T[], T[]], element: T, i: number) => { 101 | if (fn(element, i, array)) { 102 | result[0].push(element); 103 | } else { 104 | result[1].push(element); 105 | } 106 | return result; 107 | }, 108 | <[T[], T[]]>[[], []] 109 | ); 110 | } 111 | 112 | export const delay = (ms: number): Promise => 113 | new Promise(c => setTimeout(c, ms)); 114 | 115 | // export const isInArray = ( 116 | // item: T, 117 | // array: ReadonlyArray 118 | // ): item is A => array.includes(item as A); 119 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | // Place your settings in this file to overwrite default and user settings. 2 | { 3 | "files.exclude": { 4 | "out": true, 5 | "**/.git": true, 6 | ".fossil-settings/*.no-warn": true, 7 | "**/.DS_Store": true, 8 | "*.vsix": true 9 | }, 10 | "cSpell.words": [ 11 | "aliceblue", 12 | "antiquewhite", 13 | "anypath", 14 | "atof", 15 | "bgcolor", 16 | "blanchedalmond", 17 | "blueviolet", 18 | "boolproperty", 19 | "branchcolor", 20 | "burlywood", 21 | "cadetblue", 22 | "Checkin", 23 | "cherrypick", 24 | "colorname", 25 | "colorproperty", 26 | "cornflowerblue", 27 | "cornsilk", 28 | "Crowl", 29 | "darkcyan", 30 | "darkgoldenrod", 31 | "darkgray", 32 | "darkgreen", 33 | "darkgrey", 34 | "darkkhaki", 35 | "darkmagenta", 36 | "darkolivegreen", 37 | "darkorange", 38 | "darkorchid", 39 | "darkred", 40 | "darksalmon", 41 | "darkseagreen", 42 | "darkslateblue", 43 | "darkslategray", 44 | "darkslategrey", 45 | "darkturquoise", 46 | "darkviolet", 47 | "dashproperty", 48 | "deeppink", 49 | "deepskyblue", 50 | "dimgray", 51 | "dimgrey", 52 | "dodgerblue", 53 | "esbuild", 54 | "floralwhite", 55 | "forestgreen", 56 | "fossilpatch", 57 | "fslckout", 58 | "gainsboro", 59 | "ghostwhite", 60 | "greenyellow", 61 | "hotpink", 62 | "humanise", 63 | "indianred", 64 | "koog1000", 65 | "larr", 66 | "lavenderblush", 67 | "lawngreen", 68 | "leftarrow", 69 | "leftrightarrow", 70 | "lemonchiffon", 71 | "lightcoral", 72 | "lightcyan", 73 | "lightgoldenrodyellow", 74 | "lightgray", 75 | "lightgreen", 76 | "lightpink", 77 | "lightsalmon", 78 | "lightseagreen", 79 | "lightskyblue", 80 | "lightslategray", 81 | "lightslategrey", 82 | "lightsteelblue", 83 | "lightyellow", 84 | "limegreen", 85 | "ljust", 86 | "lvalue", 87 | "mediumaquamarine", 88 | "mediumblue", 89 | "mediumorchid", 90 | "mediumpurple", 91 | "mediumseagreen", 92 | "mediumslateblue", 93 | "mediumspringgreen", 94 | "mediumturquoise", 95 | "mediumvioletred", 96 | "midnightblue", 97 | "mintcream", 98 | "mistyrose", 99 | "navajowhite", 100 | "numproperty", 101 | "numproperty", 102 | "objectname", 103 | "oldlace", 104 | "olivedrab", 105 | "orangered", 106 | "outfile", 107 | "palegoldenrod", 108 | "palegreen", 109 | "paleturquoise", 110 | "palevioletred", 111 | "papayawhip", 112 | "peachpuff", 113 | "pikchr", 114 | "powderblue", 115 | "prlist", 116 | "rarr", 117 | "rebeccapurple", 118 | "repourl", 119 | "rightarrow", 120 | "rjust", 121 | "rosybrown", 122 | "royalblue", 123 | "rvalue", 124 | "saddlebrown", 125 | "sandybrown", 126 | "seagreen", 127 | "sequentialize", 128 | "skyblue", 129 | "slateblue", 130 | "slategray", 131 | "slategrey", 132 | "springgreen", 133 | "steelblue", 134 | "Technote", 135 | "tmgrammar", 136 | "unstage", 137 | "userid", 138 | "whitesmoke", 139 | "yellowgreen" 140 | ], 141 | "tmlanguage-syntax-highlighter.formattingStyle": "tight" 142 | } -------------------------------------------------------------------------------- /pikchr/syntax_tree.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import re 3 | from collections import defaultdict 4 | import graphviz 5 | from argparse import ArgumentParser 6 | 7 | 8 | def parse(filename: str): 9 | with open(filename) as f: 10 | code = f.read() 11 | no_paren = re.compile(r"\(.+\)", re.MULTILINE | re.DOTALL) 12 | spaces = re.compile(r"\s+", re.MULTILINE | re.DOTALL) 13 | for left, right in re.findall( 14 | r"^\s*(\S+)\s*::=(.*?)\.", code, re.MULTILINE | re.DOTALL 15 | ): 16 | left = no_paren.sub("", left) 17 | right = [ 18 | no_paren.sub("", item) for item in spaces.split(right.strip()) 19 | ] 20 | yield left, right 21 | 22 | 23 | subgraphs = ( 24 | "basetype", 25 | "between", 26 | "boolproperty", 27 | "colorproperty", 28 | "dashproperty", 29 | "direction", 30 | "edge", 31 | "even", 32 | "lvalue", 33 | "numproperty", 34 | "print", 35 | "pritem", 36 | "prsep", 37 | "rvalue", 38 | "savelist", 39 | # "unnamed_statement", 40 | "withclause", 41 | "optrelexpr", 42 | # "objectname", recursion 43 | ) 44 | 45 | dbg_count = 0 46 | 47 | 48 | def render( 49 | dot: graphviz.Digraph, 50 | dot_key: str, 51 | items: list[str], 52 | dd: dict[str, list[list[str]]], 53 | ): 54 | global dbg_count 55 | prev = dot_key 56 | replace = { 57 | "EOL": r"\\n|;", 58 | "": r"\", 59 | "EDGEPT": "bot|c|e|east|n|ne|north|nw|s|se|south|sw|w|west", 60 | "LAST": "last|previous", 61 | "PERCENT": "%", 62 | "LP": "(", 63 | "RP": ")", 64 | "LB": "[", 65 | "RB": "]", 66 | "COMMA": ",", 67 | "COLON": ":", 68 | "GT": ">", 69 | "LT": "<", 70 | "EQ": "=", 71 | "PLUS": "+", 72 | "MINUS": "-", 73 | "LRARROW": "\\<-\\>", 74 | "LARROW": "\\<-", 75 | "RARROW": "-\\>", 76 | "PLACENAME": "PLACENAME ([A-Z]+)", 77 | "CLASSNAME": "arc|arrow|box|circle|cylinder|dot|ellipse|file|line|move|oval|spline|text", 78 | "even": r"UNTIL EVEN WITH|EVEN WITH", 79 | } 80 | for orig_value in items: 81 | dbg_count += 1 82 | dot2_key = f"{orig_value}_{dot_key}_{dbg_count}" 83 | value = replace.get(orig_value, orig_value) 84 | if value in subgraphs: 85 | dot2_key = f"{value}_{dot_key}_{dbg_count}" 86 | with dot.subgraph( 87 | name=dot2_key, 88 | comment=value, 89 | node_attr={"shape": "box", "color": "green", "label": value}, 90 | ) as c: 91 | for item in dd[value]: 92 | render(c, dot2_key, item, dd) 93 | else: 94 | if value.isupper() or orig_value in replace: 95 | color = "red" 96 | else: 97 | color = "blue" 98 | dot.node(dot2_key, value, color=color) 99 | dot.edge(prev, dot2_key) 100 | prev = dot2_key 101 | 102 | 103 | def main(): 104 | parser = ArgumentParser() 105 | parser.add_argument("pikchr_y_path") 106 | pikchr_y_path = parser.parse_args().pikchr_y_path 107 | dot = graphviz.Digraph("round-table", comment="Pikchr") 108 | dd = defaultdict[str, list[list[str]]](list) 109 | for left, right in parse(pikchr_y_path): 110 | dd[left].append(right) 111 | 112 | for idx, (key, items) in enumerate(dd.items()): 113 | if key in subgraphs: 114 | continue 115 | dot_key = f"{key}_{idx}" 116 | dot.node(dot_key, key) 117 | for item in items: 118 | render(dot, dot_key, item, dd) 119 | 120 | dot.render(directory="doctest-output", view=True) 121 | 122 | 123 | if __name__ == "__main__": 124 | main() 125 | -------------------------------------------------------------------------------- /src/revert.ts: -------------------------------------------------------------------------------- 1 | import { 2 | TextDocument, 3 | Range, 4 | TextEditor, 5 | WorkspaceEdit, 6 | workspace, 7 | Position, 8 | } from 'vscode'; 9 | import { toFossilUri } from './uri'; 10 | 11 | export interface LineChange { 12 | readonly originalStartLineNumber: number; 13 | readonly originalEndLineNumber: number; 14 | readonly modifiedStartLineNumber: number; 15 | readonly modifiedEndLineNumber: number; 16 | } 17 | 18 | // copy from vscode/extensions/git/src/staging.ts 19 | function applyLineChanges( 20 | original: TextDocument, 21 | modified: TextDocument, 22 | diffs: LineChange[] 23 | ): string { 24 | const result: string[] = []; 25 | let currentLine = 0; 26 | 27 | for (const diff of diffs) { 28 | const isInsertion = diff.originalEndLineNumber === 0; 29 | const isDeletion = diff.modifiedEndLineNumber === 0; 30 | 31 | let endLine = isInsertion 32 | ? diff.originalStartLineNumber 33 | : diff.originalStartLineNumber - 1; 34 | let endCharacter = 0; 35 | 36 | // if this is a deletion at the very end of the document,then we need to account 37 | // for a newline at the end of the last line which may have been deleted 38 | // https://github.com/microsoft/vscode/issues/59670 39 | if (isDeletion && diff.originalEndLineNumber === original.lineCount) { 40 | endLine -= 1; 41 | endCharacter = original.lineAt(endLine).range.end.character; 42 | } 43 | 44 | result.push( 45 | original.getText(new Range(currentLine, 0, endLine, endCharacter)) 46 | ); 47 | 48 | if (!isDeletion) { 49 | let fromLine = diff.modifiedStartLineNumber - 1; 50 | let fromCharacter = 0; 51 | 52 | // if this is an insertion at the very end of the document, 53 | // then we must start the next range after the last character of the 54 | // previous line, in order to take the correct eol 55 | if ( 56 | isInsertion && 57 | diff.originalStartLineNumber === original.lineCount 58 | ) { 59 | fromLine -= 1; 60 | fromCharacter = modified.lineAt(fromLine).range.end.character; 61 | } 62 | 63 | result.push( 64 | modified.getText( 65 | new Range( 66 | fromLine, 67 | fromCharacter, 68 | diff.modifiedEndLineNumber, 69 | 0 70 | ) 71 | ) 72 | ); 73 | } 74 | 75 | currentLine = isInsertion 76 | ? diff.originalStartLineNumber 77 | : diff.originalEndLineNumber; 78 | } 79 | 80 | result.push( 81 | original.getText(new Range(currentLine, 0, original.lineCount, 0)) 82 | ); 83 | 84 | return result.join(''); 85 | } 86 | 87 | // copy from vscode/extensions/git/src/commands.ts (modified) 88 | export async function revertChanges( 89 | textEditor: TextEditor, 90 | changes: LineChange[] 91 | ): Promise { 92 | const modifiedDocument = textEditor.document; 93 | const modifiedUri = modifiedDocument.uri; 94 | 95 | if (modifiedUri.scheme !== 'file') { 96 | return; 97 | } 98 | 99 | const originalUri = toFossilUri(modifiedUri); 100 | const originalDocument = await workspace.openTextDocument(originalUri); 101 | const visibleRangesBeforeRevert = textEditor.visibleRanges; 102 | const result = applyLineChanges( 103 | originalDocument, 104 | modifiedDocument, 105 | changes 106 | ); 107 | 108 | const edit = new WorkspaceEdit(); 109 | edit.replace( 110 | modifiedUri, 111 | new Range( 112 | new Position(0, 0), 113 | modifiedDocument.lineAt(modifiedDocument.lineCount - 1).range.end 114 | ), 115 | result 116 | ); 117 | const success = await workspace.applyEdit(edit); 118 | /* c8 ignore next 3 */ 119 | if (!success) { 120 | throw new Error('failed to revert a range'); 121 | } 122 | 123 | textEditor.revealRange(visibleRangesBeforeRevert[0]); 124 | } 125 | -------------------------------------------------------------------------------- /src/test/summaryAsMarkdown.ts: -------------------------------------------------------------------------------- 1 | //import * as data from '../coverage/coverage-summary.json'; 2 | 3 | import * as fs from 'fs/promises'; 4 | 5 | interface Details { 6 | total: number; 7 | covered: number; 8 | skipped: number; 9 | pct: number; 10 | } 11 | 12 | interface Info { 13 | lines: Details; 14 | statements: Details; 15 | functions: Details; 16 | branches: Details; 17 | branchesTrue?: Details; // total only 18 | } 19 | 20 | interface Summary { 21 | [key: string]: Info; 22 | } 23 | 24 | function addDetails(a: Details, b: Details): Details { 25 | const total = a.total + b.total; 26 | const covered = a.covered + b.covered; 27 | 28 | return { 29 | total, 30 | covered, 31 | skipped: a.skipped + b.skipped, 32 | pct: parseFloat(((covered / total) * 100).toFixed(2)), 33 | }; 34 | } 35 | 36 | function filterSummary(summary: Summary, predicate: (key: string) => boolean) { 37 | const entries = Object.entries(summary); 38 | const filtered = entries.filter( 39 | ([key, _info]) => key !== 'total' && predicate(key) 40 | ); 41 | return Object.fromEntries(filtered); 42 | } 43 | 44 | function calcTotal(summary: Summary): Info { 45 | let branches: Details = { covered: 0, pct: 0, skipped: 0, total: 0 }; 46 | let lines: Details = { covered: 0, pct: 0, skipped: 0, total: 0 }; 47 | let statements: Details = { covered: 0, pct: 0, skipped: 0, total: 0 }; 48 | let functions: Details = { covered: 0, pct: 0, skipped: 0, total: 0 }; 49 | for (const info of Object.values(summary)) { 50 | branches = addDetails(branches, info.branches); 51 | lines = addDetails(lines, info.lines); 52 | statements = addDetails(statements, info.statements); 53 | functions = addDetails(functions, info.functions); 54 | } 55 | return { 56 | branches, 57 | lines, 58 | statements, 59 | functions, 60 | }; 61 | } 62 | 63 | function formatDetails(details: Details): string { 64 | if (!details) { 65 | return '?'; 66 | } 67 | const mark = (() => { 68 | if (details.pct < 66.0) return '🟥'; 69 | else if (details.pct != 100.0) return '🟨'; 70 | else return '🟩'; 71 | })(); 72 | return `${mark} ${details.pct}% (${details.covered}/${details.total})`; 73 | } 74 | 75 | function writeSummary(summary: Summary): string { 76 | const lines: string[] = []; 77 | lines.push('|File|🙈 Lines|🙉 Branches|🙊 Functions|'); 78 | lines.push('|-|-|-|-|'); 79 | const total = calcTotal(filterSummary(summary, Boolean)); 80 | lines.push( 81 | `|total|${formatDetails(total.lines)}|${formatDetails( 82 | total.branches 83 | )}|${formatDetails(total.functions)}|` 84 | ); 85 | for (const [key, info] of Object.entries(summary)) { 86 | const split = key.indexOf('/src/'); 87 | const normalized = split == -1 ? key : key.slice(split + 1); 88 | lines.push( 89 | `|${normalized}|${formatDetails(info.lines)}|${formatDetails( 90 | info.branches 91 | )}|${formatDetails(info.functions)}|` 92 | ); 93 | } 94 | return lines.join('\n'); 95 | } 96 | 97 | async function main( 98 | srcPath = 'coverage/coverage-summary.json', 99 | dstPath?: string 100 | ): Promise { 101 | const raw = await fs.readFile(srcPath); 102 | const obj: unknown = JSON.parse(raw.toString('utf-8')); 103 | if (!(obj instanceof Object)) { 104 | console.log('not an object', Object.prototype.toString.call(obj)); 105 | return 1; 106 | } 107 | const lines: string[] = []; 108 | lines.push('### Code\n'); 109 | lines.push( 110 | writeSummary( 111 | filterSummary(obj as Summary, key => key.indexOf('src/test') == -1) 112 | ) 113 | ); 114 | lines.push('\n### Test\n'); 115 | lines.push( 116 | writeSummary( 117 | filterSummary(obj as Summary, key => key.indexOf('src/test') != -1) 118 | ) 119 | ); 120 | lines.push(''); 121 | 122 | const out = lines.join('\n'); 123 | if (dstPath) { 124 | await fs.writeFile(dstPath, out); 125 | } else { 126 | process.stdout.write(out); 127 | } 128 | return 0; 129 | } 130 | main(...process.argv.slice(2)) 131 | .then(code => process.exit(code)) 132 | .catch(reason => { 133 | console.log(reason); 134 | process.exit(1); 135 | }); 136 | -------------------------------------------------------------------------------- /pikchr/test/statements.test.pikchr: -------------------------------------------------------------------------------- 1 | // SYNTAX TEST "source.pikchr" "first test" 2 | 3 | box 4 | // <--- storage.type.class.pikchr 5 | box 6 | // <--- storage.type.class.pikchr 7 | \ 8 | // <- punctuation.separator.continuation.line.pikchr 9 | cylinder ; 10 | // <-------- storage.type.class.pikchr 11 | // ^ source.pikchr 12 | cylinder; 13 | // <--- storage.type.class.pikchr 14 | // ^ source.pikchr punctuation.separator.delimiter.end.pikchr 15 | cylinder;box 16 | // <--- storage.type.class.pikchr 17 | // ^ source.pikchr punctuation.separator.delimiter.end.pikchr 18 | // ^ storage.type.class.pikchr 19 | 20 | 21 | cylinder ; box 22 | // <--- storage.type.class.pikchr 23 | // ^ source.pikchr 24 | // ^ source.pikchr punctuation.separator.delimiter.end.pikchr 25 | // ^ source.pikchr 26 | // ^ storage.type.class.pikchr 27 | 28 | arrow right 29 | // <--- storage.type.class.pikchr 30 | // ^ -storage.type.class.pikchr 31 | // ^^^^^ support.constant.edge.pikchr 32 | 33 | diamond "hi" 34 | // <--- storage.type.class.pikchr 35 | // ^^^^ string.quoted.double.pikchr 36 | 37 | 38 | Cat: box "box" 39 | // <--- variable.language.place.pikchr 40 | // ^ punctuation.separator.pikchr 41 | // ^^^ storage.type.class.pikchr 42 | // ^^^^^ string.quoted.double.pikchr 43 | Cat : box "box" 44 | // ^^^ variable.language.place.pikchr 45 | // ^ punctuation.separator.pikchr 46 | // ^^^ storage.type.class.pikchr 47 | // ^^^^^ string.quoted.double.pikchr 48 | 49 | box "hello, \"world\"" 50 | // ^^^^^^^^^^^^^^^^^^ string.quoted.double.pikchr 51 | box "hello, \x";;;; 52 | // ^^^^^^^^^^^ source.pikchr string.quoted.double.pikchr 53 | // ^^^^ punctuation.separator.delimiter.end.pikchr 54 | linewid = 0.25 55 | // <--- variable.language.pikchr 56 | // ^ keyword.operator.assignment.pikchr 57 | // ^^^^ constant.numeric.pikchr 58 | $r = 0.2in ; _a=0; @b=-1 59 | // <-- ^^ ^^ variable.language.pikchr 60 | // ^ ^ ^ keyword.operator.assignment.pikchr 61 | // ^^^^^ ^ ^ constant.numeric.pikchr 62 | // ^^ keyword.other.unit.in.pikchr 63 | // ^ ^ punctuation.separator.delimiter.end.pikchr 64 | // ^ keyword.operator.arithmetic.pikchr 65 | 66 | linerad = 0.75*$r 67 | // <------- variable.language.pikchr 68 | // ^ -variable.language.pikchr 69 | // ^ keyword.operator.assignment.pikchr 70 | // ^^^^ constant.numeric.pikchr 71 | // ^ keyword.operator.arithmetic.pikchr 72 | // ^^ variable.language.pikchr 73 | 74 | boxht = .2; boxwid = .3; circlerad = .3; dx = 0.05 75 | // <----- variable.language.pikchr 76 | // ^^^^^^ ^^^^^^^^^ ^^ variable.language.pikchr 77 | // ^ ^ ^ ^ keyword.operator.assignment.pikchr 78 | // ^^ ^^ ^^ ^^^^ constant.numeric.pikchr 79 | // ^ ^ ^ punctuation.separator.delimiter.end.pikchr 80 | 81 | left 82 | // <---- support.constant.direction.pikchr 83 | down with the sickness 84 | // <---- support.constant.direction.pikchr 85 | // ^^^^ ^^^ ^^^^^^^^ invalid.illegal.pikchr 86 | 87 | line dashed 88 | // ^^^^^^ entity.name.tag.pikchr 89 | line dotted 90 | // ^^^^^^ entity.name.tag.pikchr 91 | box :? 3 `` 92 | // ^^ ^^ invalid.illegal.pikchr 93 | print .! 94 | // ^^ invalid.illegal.pikchr 95 | print "t" .! 96 | // ^^^ string.quoted.double.pikchr 97 | // ^^ invalid.illegal.pikchr 98 | d = 43 !^ 99 | // ^^ invalid.illegal.pikchr 100 | define par {} ! 101 | // ^ invalid.illegal.pikchr 102 | assert(1==1), 103 | // ^ invalid.illegal.pikchr 104 | 105 | "splines" 106 | // <--------- string.quoted.double.pikchr 107 | 108 | box ht $1 wid $1 $2 109 | // ^^ ^^^ support.constant.property-value.pikchr 110 | // ^^ ^^ ^^ variable.language.pikchr 111 | 112 | print "Oval at: ",previous.x, ",", previous.y 113 | // <----- keyword.control.directive.define.pikchr 114 | // ^^^^^^^^^^^ ^^^ string.quoted.double.pikchr 115 | // ^ ^ punctuation.separator.pikchr 116 | // ^^^^^^^^ ^^^^^^^^ constant.language.pikchr 117 | // ^ ^ punctuation.separator.period.pikchr 118 | // ^ ^ constant.language.pikchr 119 | 120 | circlerad = previous.radius 121 | // <--------- variable.language.pikchr 122 | // ^^^^^^^^^^^^^^^^^ meta.assignment.pikchr 123 | // ^ keyword.operator.assignment.pikchr 124 | // ^^^^^^^^ constant.language.pikchr 125 | // ^ punctuation.separator.period.pikchr 126 | // ^^^^^^ support.constant.property-value.pikchr 127 | X0: last.e 128 | //> <-- variable.language.pikchr 129 | //>^ punctuation.separator.pikchr 130 | //> ^^^^ variable.language.pikchr 131 | //> ^ punctuation.separator.period.pikchr 132 | //> ^ entity.name.class.pikchr 133 | -------------------------------------------------------------------------------- /pikchr/test/macro.test.pikchr: -------------------------------------------------------------------------------- 1 | // SYNTAX TEST "source.pikchr" "macro definition and expansion" 2 | 3 | 4 | define par {} 5 | // <------ keyword.control.directive.define.pikchr 6 | // ^^^ variable.language.pikchr 7 | // ^ punctuation.section.block.begin.bracket.curly.pikchr 8 | // ^ punctuation.section.block.end.bracket.curly.pikchr 9 | define mymacro {box $1} 10 | // <------ keyword.control.directive.define.pikchr 11 | // ^^^^^^^ variable.language.pikchr 12 | // ^ punctuation.section.block.begin.bracket.curly.pikchr 13 | // ^^^ storage.type.class.pikchr 14 | // ^^ variable.language.pikchr 15 | // ^ punctuation.section.block.end.bracket.curly.pikchr 16 | define keyword {right} 17 | // ^ punctuation.section.block.begin.bracket.curly.pikchr 18 | // ^^^^^ support.constant.direction.pikchr 19 | // ^ punctuation.section.block.end.bracket.curly.pikchr 20 | par 21 | // <--- entity.name.function.pikchr 22 | par() 23 | // <--- entity.name.function.pikchr 24 | // ^ punctuation.parenthesis.begin.pikchr 25 | // ^ punctuation.parenthesis.end.pikchr 26 | par() par() par par 27 | // <--- entity.name.function.pikchr 28 | // ^^^ entity.name.function.pikchr 29 | // ^^^ ^^^ variable.language.pikchr 30 | mymacro( /**/ "hi" /**/ ) 31 | // <------- entity.name.function.pikchr 32 | // ^ punctuation.parenthesis.begin.pikchr 33 | // ^^^^ ^^^^ comment.block.pikchr 34 | // ^^^^ string.quoted.double.pikchr 35 | // ^ punctuation.parenthesis.end.pikchr 36 | define square { box ht $7 wid $8 $9 } 37 | // <------ keyword.control.directive.define.pikchr 38 | // ^^^^^^ variable.language.pikchr 39 | // ^ punctuation.section.block.begin.bracket.curly.pikchr 40 | // ^^^ storage.type.class.pikchr 41 | // ^^ ^^^ support.constant.property-value.pikchr 42 | // ^^ ^^ ^^ variable.language.pikchr 43 | // ^ punctuation.section.block.end.bracket.curly.pikchr 44 | define m01 {m00("m01",$1,$2)} 45 | // <------ keyword.control.directive.define.pikchr 46 | // ^^^ variable.language.pikchr 47 | // ^ punctuation.section.block.begin.bracket.curly.pikchr 48 | // ^^^ entity.name.function.pikchr 49 | // ^ punctuation.parenthesis.begin.pikchr 50 | // ^^^^^ string.quoted.double.pikchr 51 | // ^ ^ punctuation.separator.pikchr 52 | // ^^ ^^ variable.language.pikchr 53 | // ^ punctuation.parenthesis.end.pikchr 54 | // ^ punctuation.section.block.end.bracket.curly.pikchr 55 | define dodef {define $1 {box "Hello"}} 56 | // <------ keyword.control.directive.define.pikchr 57 | // ^^^^^^ keyword.control.directive.define.pikchr 58 | define dodef {mymacro} 59 | // ^ punctuation.section.block.begin.bracket.curly.pikchr 60 | // ^^^^^^^ entity.name.function.pikchr 61 | // ^ punctuation.section.block.end.bracket.curly.pikchr 62 | mymacro("o") mymacro("o") 63 | // <------- entity.name.function.pikchr 64 | // ^^^^^^^ entity.name.function.pikchr 65 | // ^^^ ^^^ string.quoted.double.pikchr 66 | E2: emo at last move.end; 67 | // <-- variable.language.place.pikchr 68 | //^ punctuation.separator.pikchr 69 | // ^^^ variable.language.pikchr 70 | // ^^ keyword.pikchr 71 | // ^^^^ constant.language.pikchr 72 | // ^^^^ entity.name.function.pikchr 73 | // ^ punctuation.separator.period.pikchr 74 | // ^^^ constant.language.pikchr 75 | define place_as_parameter {$1: [C1: circle radius 1;];} 76 | // <------ keyword.control.directive.define.pikchr 77 | // ^^^^^^^^^^^^^^^^^^ variable.language.pikchr 78 | // ^ punctuation.section.block.begin.bracket.curly.pikchr 79 | // ^^ ^^ variable.language.place.pikchr 80 | // ^ punctuation.separator.pikchr 81 | // ^ punctuation.bracket.square.begin.pikchr 82 | // ^ punctuation.separator.pikchr 83 | // ^^^^^^ storage.type.class.pikchr 84 | // ^ ^ punctuation.separator.delimiter.end.pikchr 85 | // ^ punctuation.bracket.square.end.pikchr 86 | // ^ punctuation.section.block.end.bracket.curly.pikchr 87 | define place_as_parameter {$1: [C1: circle radius 1;]} 88 | // ^ punctuation.section.block.end.bracket.curly.pikchr 89 | emo at last move.end; 90 | // <--- entity.name.function.pikchr 91 | // ^^ keyword.pikchr 92 | // ^^^^ constant.language.pikchr 93 | // ^^^^ entity.name.function.pikchr 94 | // ^ punctuation.separator.period.pikchr 95 | // ^^^ constant.language.pikchr 96 | // ^ punctuation.separator.delimiter.end.pikchr 97 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Integrated Fossil source control for Visual Studio Code 2 | 3 | ### Prerequisites 4 | 5 | > This extension leverages your machine's Fossil installation, 6 | so you need to 7 | [install Fossil](https://www.fossil-scm.org/fossil/doc/trunk/www/quickstart.wiki) 8 | first. Also read the [cloning](/docs/cloning.md) documentation for info 9 | about cloning from the extension. 10 | 11 | ![Fossil](images/fossil.png) 12 | 13 | # Features 14 | 15 | * Add files and commit from the source control panel 16 | (i.e. where git normally appears). 17 | 18 | * All the basics: commit, add, revert, update, push and pull. 19 | 20 | * See changes inline within text editor. 21 | 22 | * Interactive log for basic file history and diff. 23 | 24 | * Branch, merge, resolve files. 25 | 26 | * Praise 27 | 28 | * Quickly switch branches, push and pull via status bar. 29 | 30 | * Supports named-branches workflows. 31 | 32 | * Automatic incoming/outgoing counters. 33 | 34 | * Undo/Redo. 35 | 36 | * Preview `md`, `wiki` and `pikchr` files 37 | 38 | * Syntax highlighting for `pikchr` language 39 | 40 | * Use command palette `Ctrl-Shift-P` >> `fossil:` to see all commands. (Not everything has a UI control.) 41 | 42 | 43 | ## View file changes 44 | ![View changes](images/fossil-diff.gif) 45 | 46 | * Click a file see the diff view 47 | * Or open a file by using context menu 48 | 49 | ## Initialize a new repo 50 | 51 | ![Init a repo](images/init.gif) 52 | 53 | * Just click the Fossil icon from the source control title area 54 | * Follow prompts 55 | 56 | ## Update to a branch/tag 57 | 58 | ![Change branches](images/change-branch.gif) 59 | 60 | * The current branch name is shown in the bottom-left corner. 61 | * Click it to see a list of branches and tags that you can update to. 62 | 63 | # How to 64 | 65 | * **Checkout by hash?** 66 | 67 | Use branch menu in the status bar. 68 | 69 | * **Create a new branch?** 70 | 71 | Create a branch with "Commit Creating New Branch..." action in SCM menu or in command palette. 72 | 73 | * **Modify commit message?** 74 | 75 | Use "Fossil log" from command palette and navigate the options till specific checkout. 76 | 77 | * **Get current checkout hash or tags?** 78 | 79 | Hover over current branch name in the status bar 80 | 81 | * **Close/reopen a branch?** 82 | 83 | Use 'Close branch...' and 'Reopen branch...' actions from command palette. 84 | 85 | * **Commit partially** 86 | 87 | 1. Run `Stash Snapshot` command 88 | 2. Manually remove lines that you don't want in the commit 89 | 3. Make a commit 90 | 4. Run `Stash Pop` 91 | 92 | * **Blame** 93 | 94 | Use `Fossil: praise` command from command palette 95 | 96 | # Settings 97 | 98 | `fossil.autoRefresh { boolean }` 99 | 100 | * Enables automatic refreshing of Source Control tab and badge counter 101 | when files within the project change: 102 | `"true"` — enabled 103 | `"false"` — disabled, manual refresh still available. 104 | 105 | `fossil.path { string }` 106 | 107 | * Specifies an explicit `fossil` file path to use. 108 | * This should only be used if `fossil` cannot be found automatically. 109 | * The default behavior is to search for `fossil` on the PATH. 110 | * Takes effect immediately. 111 | 112 | `fossil.username { string }` 113 | 114 | * Specifies an explicit user to use for fossil commits (`--user-override`). 115 | * This should only be used if the user is different than the fossil default user. 116 | * Username could be passed with `fossil.commitArgs` but this is just convenient shortcut. 117 | 118 | `fossil.autoSyncInterval { number }` 119 | * The duration, in seconds, between each background `fossil sync` operation. 120 | * 0 to disable. 121 | 122 | `fossil.globalArgs` 123 | * Extra arguments added to each `fossil` command (see `fossil help -o`) 124 | 125 | `fossil.commitArgs` 126 | * Extra arguments added to `fossil commit` command (see `fossil help commit`) 127 | 128 | 129 | # Troubleshooting 130 | 131 | In general, Fossil designers maintain an abundance of 132 | [documentation](https://fossil-scm.org/home/doc/trunk/www/permutedindex.html). 133 | Reference that documentation as much as possible. 134 | 135 | | Issue | Resolution 136 | --------|---------------------------------------------------------------- 137 | | Unknown certificate authority | Read the [Fossil SSL Documentation](https://fossil-scm.org/home/doc/trunk/www/ssl.wiki#certs) to update fossil with the correct CA | 138 | | inputBox prompt difficult to read | Run the same fossil command on the built-in terminal (Ctrl+`). Unfortunately VS Code strips newlines and tabs from inputBox prompts. | 139 | 140 | 141 | # Feedback & Contributing 142 | 143 | * Please report any bugs, suggestions or documentation requests via the 144 | [Github issues](https://github.com/koog1000/vscode-fossil/issues) 145 | (_yes_, I see the irony). 146 | * Feel free to submit 147 | [pull requests](https://github.com/koog1000/vscode-fossil/pulls). 148 | 149 | 150 | ### For developers 151 | 152 | * [Building and debugging](docs/dev/build.md) 153 | * [Api behavior](docs/dev/api.md) 154 | * [Releasing](docs/dev/release.md) 155 | 156 | # Acknowledgements 157 | 158 | [Ben Crowl](https://github.com/mrcrowl), 159 | [koog1000](https://github.com/koog1000), 160 | [senyai](https://github.com/senyai), 161 | [ajansveld](https://github.com/ajansveld), [hoffmael](https://github.com/hoffmael), [nioh-wiki](https://github.com/nioh-wiki), [joaomoreno](https://github.com/joaomoreno), [nsgundy](https://github.com/nsgundy) 162 | -------------------------------------------------------------------------------- /docs/dev/api.md: -------------------------------------------------------------------------------- 1 | # API 2 | 3 | This documentation is intended for fossil extension developers 4 | to understand all the commands and to not forget what all commands should do. This documentation should also help find bugs. 5 | 6 | _Work in progress_. 7 | 8 | | Command | Name | Where | Expected behavior | 9 | | - | - | - | - | 10 | | fossil.add | Add Files | • Untracked files section entries
• Command palette | 1. Execute `fossil add $(files)`
2. Add files into staged area | 11 | | fossil.addAll | Add All Untracked Files | • Untracked files section
• Command palette | Same as `fossil.add` for all files | 12 | | fossil.branch | Create Branch... | • Command palette | 1. Input new branch name
2. Try execute `fossil branch new $(branch-name)`
3. On error reopen or update branch 13 | | fossil.branchChange | _not needed_ | • Branch name is clicked in status bar | 1. Pick branch name
2. Execute `fossil update $(branch-name)` | 14 | | fossil.cherrypick | Cherry-pick into working directory... | • Main SCM menu
• Command palette | 15 | | fossil.clean | Delete Extras | 16 | | fossil.clone | Clone Fossil Repository | Source control header | 17 | | fossil.close | Close Repository | | Execute `fossil close` 18 | | fossil.closeBranch | Close branch... | | 1. Pick a branch
2. Execute `fossil tag add --raw closed $(branch-name)` 19 | | fossil.commit | Commit | 20 | | fossil.commitAll | Commit All | • Main SCM menu
• Command palette | 21 | | fossil.commitBranch | Commit Creating New Branch... | • Main SCM menu
• Command palette | 22 | | fossil.commitStaged | Commit Staged | • Main SCM menu
• Command palette | 23 | | fossil.commitWithInput | _not needed_ | • Standard commit input box | 24 | | fossil.deleteFile | Delete Untracked File | 25 | | fossil.deleteFiles | Delete All Untracked Files | 26 | | fossil.fileLog | Show file history... | 27 | | fossil.forget | Forget Files | 28 | | fossil.ignore | Add to ignore-glob | • Command palette
• Untracked submenu | 1. Modify `/.fossil-settings/ignore-glob`
2. Add `ignore-glob` to the current checkout (not staging)
3. Show ignore-glob file 29 | | fossil.init | Initialize Fossil Repository | • Main SCM menu
• Command palette | 1. Ask `.fossil` path
2. Ask project name
3. Ask project description
4. Run `fossil init`
5. Ask to open repository 30 | | fossil.integrate | Integrate into working directory... | 31 | | fossil.log | Log... | 32 | | fossil.merge | Merge into working directory... | 33 | | fossil.open | Open Fossil Repository | 34 | | fossil.openChange | Open Changes | 35 | | fossil.openChangeFromUri | Open Changes | • Editor bar | Switch editor to diff mode 36 | | fossil.openFile | Open File | 37 | | fossil.openFileFromUri | Open File | • Command palette
• Editor bar | When in diff view, there's a special button to show local file 38 | | fossil.openFiles | Open Files | 39 | | fossil.openResource| _not needed_ | •  Source Control panel | Resource in opened in a diff view if possible | 40 | | fossil.openUI | Open web UI | • Command palette | execute `fossil ui` in VSCode terminal | 41 | | fossil.patchApply | Apply Patch | • Main SCM menu
• Command palette | 1. Select path
2. Execute `fossil patch apply $(path)` 42 | | fossil.patchCreate | Create Patch | • Main SCM menu
• Command palette | 1. Select path
2. Execute `fossil patch create $(path)` 43 | | fossil.pull | Pull | • Main SCM menu
• Command palette | 1. Select remote URI if many
2. Execute `fossil pull URI` 44 | | fossil.push | Push | • Main SCM menu
• Command palette | 1. Execute `fossil push` 45 | | fossil.pushTo | Push to... | • Main SCM menu
• Command palette | 1. Pick remote if > 1
2. Execute `fossil push URI` 46 | | fossil.redo | Redo | • Main SCM menu
• Command palette | execute `fossil redo` 47 | | fossil.refresh | Refresh | • Source control header | 1. Execute `fossil status`
2. Update related information 48 | | fossil.render | Preview Using Fossil Renderer | • Main SCM menu for any document
• Navigation bar for `.{md, wiki, pikchr}` files or any document with "pikchr" language or any untitled document in a project with fossil repository opened | 1. Create `webview` panel of `fossil.renderPanel` view type.
2. Execute `fossil pikchr`, `fossil test-wiki-render` or `fossil test-markdown-render` 49 | | fossil.reopenBranch | Reopen branch... | | 1. Pick branch
2. execute `fossil tag cancel --raw closed $(branch-name)` 50 | | fossil.revert | Discard Changes | 51 | | fossil.revertAll | Discard All Changes | • SCM group header
• Command palette | 1. When called from Command palette, revert "conflict" and "changes" groups
2. When called from SCM group header, revert all files in that group. 52 | | fossil.revertChange | Revert Change | • Inline diff window | 53 | | fossil.showOutput | Show fossil output | • Main SCM menu
• Command palette | Reveal `outputChannel` channel in the UI 54 | | fossil.stage | Stage Changes | 55 | | fossil.stageAll | Stage All Changes | 56 | | fossil.stashApply | Stash Apply | • Main SCM menu
• Command palette | 57 | | fossil.stashDrop | Stash Drop | • Main SCM menu
• Command palette | 58 | | fossil.stashPop | Stash Pop | • Main SCM menu
• Command palette | 59 | | fossil.stashSave | Stash Push | • Main SCM menu
• Command palette | 60 | | fossil.stashSnapshot | Stash Snapshot | • Main SCM menu
• Command palette | 61 | | fossil.sync | Sync | • Main SCM menu
• Command palette | execute `fossil sync` 62 | | fossil.undo | Undo | • Main SCM menu
• Command palette | execute `fossil undo` 63 | | fossil.unstage | Unstage Changes | 64 | | fossil.unstageAll | Unstage All Changes | 65 | | fossil.update | Update | • Main SCM menu
• Command palette
• Status bar item | 1. Execute `fossil update` 66 | | fossil.wikiCreate | Publish as Fossil Wiki or Technote | 67 | -------------------------------------------------------------------------------- /src/humanise.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | import { 3 | FossilBranch, 4 | FossilCheckin, 5 | FossilCommitMessage, 6 | } from './openedRepository'; 7 | 8 | import { localize } from './main'; 9 | 10 | class TimeSpan { 11 | private seconds: number; 12 | 13 | constructor(totalSeconds: number) { 14 | this.seconds = totalSeconds; 15 | } 16 | 17 | public get totalSeconds(): number { 18 | return this.seconds; 19 | } 20 | public get totalMinutes(): number { 21 | return this.seconds / 60; 22 | } 23 | public get totalHours(): number { 24 | return this.seconds / 3600; 25 | } 26 | public get totalDays(): number { 27 | return this.seconds / 86400; 28 | } 29 | // public get totalWeeks(): number { 30 | // return this.seconds / 604800; 31 | // } 32 | } 33 | 34 | const BULLET = '\u2022'; 35 | const FILE_LIST_LIMIT = 8; 36 | 37 | export function formatFilesAsBulletedList(filenames: string[]): string { 38 | let extraCount = 0; 39 | if (filenames.length > FILE_LIST_LIMIT + 1) { 40 | extraCount = filenames.length - FILE_LIST_LIMIT; 41 | filenames = filenames.slice(0, FILE_LIST_LIMIT); 42 | } 43 | 44 | const osFilenames = filenames.map(f => f.replace(/[/\\]/g, path.sep)); 45 | let formatted = ` ${BULLET} ${osFilenames.join(`\n ${BULLET} `)}`; 46 | if (extraCount > 1) { 47 | const andNOthers = localize( 48 | 'and n others', 49 | 'and {0} others', 50 | extraCount 51 | ); 52 | formatted += `\n${andNOthers}`; 53 | } 54 | 55 | return formatted; 56 | } 57 | 58 | export function describeMerge( 59 | localBranchName: FossilBranch, 60 | otherBranchName: FossilCheckin 61 | ): FossilCommitMessage { 62 | return localize( 63 | 'merge into', 64 | 'Merge {0} into {1}', 65 | otherBranchName, 66 | localBranchName 67 | ) as FossilCommitMessage; 68 | } 69 | 70 | export const enum Old { 71 | DATE, 72 | EMPTY_STRING, 73 | } 74 | 75 | export function ageFromNow(date: Date, old: Old = Old.DATE): string { 76 | const elapsedSeconds = timeSince(date) / 1e3; 77 | const elapsed = new TimeSpan(elapsedSeconds); 78 | if (elapsed.totalDays >= 0) { 79 | // past 80 | if (elapsed.totalSeconds < 5) { 81 | return 'now'; 82 | } 83 | if (elapsed.totalSeconds < 15) { 84 | return 'a few moments ago'; 85 | } 86 | if (elapsed.totalSeconds < 99) { 87 | return `${Math.floor(elapsed.totalSeconds)} seconds ago`; 88 | } 89 | if (elapsed.totalMinutes < 60) { 90 | const minutes: string = pluraliseQuantity( 91 | 'minute', 92 | elapsed.totalMinutes 93 | ); 94 | return `${minutes} ago`; 95 | } 96 | if (elapsed.totalHours < 24) { 97 | const now: Date = new Date(); 98 | const today: Date = datePart(now); 99 | const startDate: Date = datePart(addSeconds(now, -elapsedSeconds)); 100 | const yesterday: Date = addDays(today, -1); 101 | 102 | if (startDate.getTime() == yesterday.getTime()) { 103 | return 'yesterday'; 104 | } else { 105 | const hours: string = pluraliseQuantity( 106 | 'hour', 107 | elapsed.totalHours 108 | ); 109 | return `${hours} ago`; 110 | } 111 | } 112 | if (elapsed.totalDays < 7) { 113 | const now: Date = new Date(); 114 | const today: Date = datePart(now); 115 | const startDate: Date = datePart(addSeconds(now, -elapsedSeconds)); 116 | const yesterday: Date = addDays(today, -1); 117 | // const wholeDays: number = Math.round(elapsed.totalDays); 118 | 119 | if (startDate.getTime() == yesterday.getTime()) { 120 | return 'yesterday'; 121 | } else { 122 | const todayWeek: number = getWeek(today); 123 | const startWeek: number = getWeek(startDate); 124 | if (todayWeek == startWeek) { 125 | return `${Math.round(elapsed.totalDays)} days ago`; 126 | } else { 127 | return 'last week'; 128 | } 129 | } 130 | } 131 | if (old == Old.DATE) { 132 | return date.toLocaleDateString(undefined, { 133 | formatMatcher: 'basic', 134 | }); 135 | } else { 136 | return ''; 137 | } 138 | } else { 139 | // future 140 | const totalDays: number = Math.floor(-elapsed.totalDays); 141 | const totalHours: number = Math.floor(-elapsed.totalHours); 142 | const totalMinutes: number = Math.floor(-elapsed.totalMinutes); 143 | if (totalMinutes < 60) { 144 | return `future (${pluraliseQuantity('minute', totalMinutes)})`; 145 | } 146 | if (totalHours < 48) { 147 | return `future (${pluraliseQuantity('hour', totalHours)})`; 148 | } 149 | return `future (${totalDays} days)`; 150 | } 151 | } 152 | 153 | function timeSince(date: Date): number { 154 | return Date.now() - date.getTime(); 155 | } 156 | 157 | function addSeconds(date: Date, numberOfSeconds: number): Date { 158 | const adjustedDate: Date = new Date(date.getTime()); 159 | adjustedDate.setSeconds(adjustedDate.getSeconds() + numberOfSeconds); 160 | return adjustedDate; 161 | } 162 | 163 | function addDays(date: Date, numberOfDays: number): Date { 164 | const adjustedDate: Date = new Date(date.getTime()); 165 | adjustedDate.setDate(adjustedDate.getDate() + numberOfDays); 166 | return adjustedDate; 167 | } 168 | 169 | function datePart(date: Date): Date { 170 | return new Date( 171 | date.getFullYear(), 172 | date.getMonth(), 173 | date.getDate(), 174 | 0, 175 | 0, 176 | 0, 177 | 0 178 | ); 179 | } 180 | 181 | function getWeek(date: Date): number { 182 | const oneJan = new Date(date.getFullYear(), 0, 1); 183 | return Math.ceil( 184 | ((date.getTime() - oneJan.getTime()) / 86400000 + oneJan.getDay() + 1) / 185 | 7 186 | ); 187 | } 188 | 189 | function pluraliseQuantity(word: string, quantity: number) { 190 | quantity = Math.floor(quantity); 191 | return `${quantity} ${word}${quantity == 1 ? '' : 's'}`; 192 | } 193 | -------------------------------------------------------------------------------- /src/decorators.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------------------------------------------- 2 | * Copyright (c) Ben Crowl. All rights reserved. 3 | * Original Copyright (c) Microsoft Corporation. All rights reserved. 4 | * Licensed under the MIT License. See LICENSE.txt in the project root for license information. 5 | *--------------------------------------------------------------------------------------------*/ 6 | 7 | import { done } from './util'; 8 | 9 | // export function memoize( 10 | // target: (this: Record) => Return, 11 | // context: ClassGetterDecoratorContext 12 | // ) { 13 | // const memoizeKey = `$memoize$${context.name as string}`; 14 | // return function (this: Record): Return { 15 | // if (!this[memoizeKey]) { 16 | // this[memoizeKey] = target.apply(this); 17 | // } 18 | // return this[memoizeKey]; 19 | // }; 20 | // } 21 | export function memoize( 22 | target: any, 23 | key: string, 24 | descriptor: TypedPropertyDescriptor 25 | ) { 26 | const memoizeKey = `$memoize$${key}`; 27 | const fn = descriptor.get!; 28 | descriptor.get = function (this: Record): Return { 29 | if (!this[memoizeKey]) { 30 | this[memoizeKey] = fn.apply(this); 31 | } 32 | return this[memoizeKey]; 33 | }; 34 | } 35 | 36 | /** 37 | * Decorator to not allow multiple async calls 38 | */ 39 | // export function throttle( 40 | // target: ( 41 | // this: Record | undefined>, 42 | // ...args: Args 43 | // ) => Promise, 44 | // context: ClassMethodDecoratorContext 45 | // ) { 46 | // const currentKey = `$thr$c$${String(context.name)}`; // $throttle$current$ 47 | // const nextKey = `$thr$n$${String(context.name)}`; // $throttle$next$ 48 | 49 | // const trigger = function ( 50 | // this: Record | undefined>, 51 | // ...args: Args 52 | // ): Promise { 53 | // if (this[nextKey]) { 54 | // return this[nextKey]!; 55 | // } 56 | 57 | // if (this[currentKey]) { 58 | // this[nextKey] = done(this[currentKey]!).then(() => { 59 | // this[nextKey] = undefined; 60 | // return trigger.apply(this, args); 61 | // }); 62 | 63 | // return this[nextKey]!; 64 | // } 65 | 66 | // this[currentKey] = target.apply(this, args); 67 | 68 | // const clear = () => (this[currentKey] = undefined); 69 | // done(this[currentKey]!).then(clear, clear); 70 | 71 | // return this[currentKey]!; 72 | // }; 73 | 74 | // return trigger; 75 | // } 76 | export function throttle( 77 | target: any, 78 | key: string, 79 | descriptor: PropertyDescriptor 80 | ): void { 81 | const currentKey = `$thr$c$${key}`; // $throttle$current$ 82 | const nextKey = `$thr$n$${key}`; // $throttle$next$ 83 | const fn = descriptor.value!; 84 | 85 | descriptor.value = function ( 86 | this: Record | undefined>, 87 | ...args: Args 88 | ): Promise { 89 | if (this[nextKey]) { 90 | return this[nextKey]!; 91 | } 92 | 93 | if (this[currentKey]) { 94 | this[nextKey] = done(this[currentKey]!).then(() => { 95 | this[nextKey] = undefined; 96 | return fn.apply(this, args); 97 | }); 98 | 99 | return this[nextKey]!; 100 | } 101 | 102 | this[currentKey] = fn.apply(this, args); 103 | 104 | const clear = () => (this[currentKey] = undefined); 105 | done(this[currentKey]!).then(clear, clear); 106 | 107 | return this[currentKey]!; 108 | }; 109 | } 110 | 111 | // Make sure asynchronous functions are called one after another. 112 | type ThisPromise = Record>; 113 | 114 | // export function sequentialize( 115 | // target: (this: ThisPromise, ...args: Args) => Promise, 116 | // context: ClassMethodDecoratorContext 117 | // ) { 118 | // const currentKey = `$s11e$${context.name as string}`; // sequentialize 119 | 120 | // return function (this: ThisPromise, ...args: Args): Promise { 121 | // const currentPromise = 122 | // (this[currentKey] as Promise) || Promise.resolve(null); 123 | // const run = async () => await target.apply(this, args); 124 | // this[currentKey] = currentPromise.then(run, run); 125 | // return this[currentKey]; 126 | // }; 127 | // } 128 | 129 | export function sequentialize( 130 | target: any, 131 | key: string, 132 | descriptor: TypedPropertyDescriptor< 133 | (this: ThisPromise, ...args: Args) => Promise 134 | > 135 | ) { 136 | const currentKey = `$s11e$${key}`; // sequentialize 137 | const fn = descriptor.value!; 138 | 139 | descriptor.value = function ( 140 | this: ThisPromise, 141 | ...args: Args 142 | ): Promise { 143 | const currentPromise = 144 | (this[currentKey] as Promise) || Promise.resolve(null); 145 | const run = async () => await fn.apply(this, args); 146 | this[currentKey] = currentPromise.then(run, run); 147 | return this[currentKey]; 148 | }; 149 | } 150 | 151 | type ThisTimer = Record>; 152 | 153 | // export function debounce(delay: number) { 154 | // return function ( 155 | // target: (this: ThisTimer, ...args: Args) => void, 156 | // context: ClassMemberDecoratorContext 157 | // ) { 158 | // const timerKey = `$d6e$${String(context.name)}`; // debounce 159 | 160 | // return function (this: ThisTimer, ...args: Args): void { 161 | // clearTimeout(this[timerKey]); 162 | // this[timerKey] = setTimeout(() => target.apply(this, args), delay); 163 | // }; 164 | // }; 165 | // } 166 | export function debounce(delay: number) { 167 | return function ( 168 | target: any, 169 | key: string, 170 | descriptor: TypedPropertyDescriptor< 171 | (this: ThisTimer, ...args: []) => void 172 | > 173 | ) { 174 | const timerKey = `$d6e$${key}`; // debounce 175 | const fn = descriptor.value!; 176 | 177 | descriptor.value = function (this: ThisTimer, ...args: []): void { 178 | clearTimeout(this[timerKey]); 179 | this[timerKey] = setTimeout(() => fn.apply(this, args), delay); 180 | }; 181 | }; 182 | } 183 | -------------------------------------------------------------------------------- /src/resourceGroups.ts: -------------------------------------------------------------------------------- 1 | import { FossilRoot, FileStatus, ResourceStatus } from './openedRepository'; 2 | import { 3 | Uri, 4 | SourceControlResourceGroup, 5 | SourceControl, 6 | Disposable, 7 | } from 'vscode'; 8 | import * as path from 'path'; 9 | import { FossilResource } from './repository'; 10 | 11 | import { localize } from './main'; 12 | 13 | interface IGroupStatusesParams { 14 | repositoryRoot: FossilRoot; 15 | statusGroups: IStatusGroups; 16 | fileStatuses: FileStatus[]; 17 | } 18 | 19 | export interface IStatusGroups { 20 | conflict: FossilResourceGroup; 21 | staging: FossilResourceGroup; 22 | working: FossilResourceGroup; 23 | untracked: FossilResourceGroup; 24 | } 25 | 26 | export type FossilResourceId = keyof IStatusGroups; 27 | 28 | export function createEmptyStatusGroups(scm: SourceControl): IStatusGroups { 29 | const conflictGroup = new FossilResourceGroup( 30 | scm, 31 | 'conflict', 32 | localize('merge conflicts', 'Unresolved Conflicts') 33 | ); 34 | const stagingGroup = new FossilResourceGroup( 35 | scm, 36 | 'staging', 37 | localize('staged changes', 'Staged Changes') 38 | ) as FossilResourceGroup; 39 | const workingGroup = new FossilResourceGroup( 40 | scm, 41 | 'working', 42 | localize('changes', 'Changes') 43 | ) as FossilResourceGroup; 44 | const untrackedGroup = new FossilResourceGroup( 45 | scm, 46 | 'untracked', 47 | localize('untracked files', 'Untracked Files') 48 | ) as FossilResourceGroup; 49 | 50 | return { 51 | conflict: conflictGroup, 52 | staging: stagingGroup, 53 | working: workingGroup, 54 | untracked: untrackedGroup, 55 | }; 56 | } 57 | 58 | export interface IFossilResourceGroup extends SourceControlResourceGroup { 59 | resourceStates: FossilResource[]; 60 | } 61 | 62 | export class FossilResourceGroup { 63 | private readonly _uriToResource: Map; 64 | private readonly _vscode_group: IFossilResourceGroup; 65 | get disposable(): Disposable { 66 | return this._vscode_group; 67 | } 68 | get resourceStates(): FossilResource[] { 69 | return this._vscode_group.resourceStates; 70 | } 71 | getResource(uri: Uri): FossilResource | undefined { 72 | return this._uriToResource.get(uri.toString()); 73 | } 74 | includesUri(uri: Uri): boolean { 75 | return this._uriToResource.has(uri.toString()); 76 | } 77 | includesDir(uriStr: string): boolean { 78 | // important: `uriStr` should end with path.sep to work properly 79 | for (const key of this._uriToResource.keys()) { 80 | if (key.startsWith(uriStr)) { 81 | return true; 82 | } 83 | } 84 | return false; 85 | } 86 | 87 | constructor( 88 | sourceControl: SourceControl, 89 | id: FossilResourceId, 90 | readonly label: string // translated string 91 | ) { 92 | this._uriToResource = new Map(); 93 | this._vscode_group = sourceControl.createResourceGroup( 94 | id, 95 | label 96 | ) as IFossilResourceGroup; 97 | this._vscode_group.hideWhenEmpty = true; 98 | } 99 | 100 | is(id: FossilResourceId): boolean { 101 | return this._vscode_group.id === id; 102 | } 103 | 104 | updateResources(resources: FossilResource[]): void { 105 | this._vscode_group.resourceStates = resources; 106 | this._uriToResource.clear(); 107 | resources.forEach(resource => 108 | this._uriToResource.set(resource.resourceUri.toString(), resource) 109 | ); 110 | } 111 | 112 | intersect(resources: FossilResource[]): void { 113 | // existing resources must be updated 114 | // because resource status might change 115 | for (const res of resources) { 116 | this._uriToResource.delete(res.resourceUri.toString()); 117 | res.resourceGroup = this; 118 | } 119 | const intersectionResources: FossilResource[] = [ 120 | ...resources, 121 | ...this._uriToResource.values(), 122 | ]; 123 | this.updateResources(intersectionResources); 124 | } 125 | 126 | except(resources_to_exclude: FossilResource[]): void { 127 | for (const res of resources_to_exclude) { 128 | this._uriToResource.delete(res.resourceUri.toString()); 129 | } 130 | this.updateResources([...this._uriToResource.values()]); 131 | } 132 | } 133 | 134 | export function groupStatuses({ 135 | repositoryRoot, 136 | statusGroups: { conflict, staging, working, untracked }, 137 | fileStatuses, 138 | }: IGroupStatusesParams): void { 139 | const workingDirectoryResources: FossilResource[] = []; 140 | const stagingResources: FossilResource[] = []; 141 | const conflictResources: FossilResource[] = []; 142 | const untrackedResources: FossilResource[] = []; 143 | 144 | const chooseResourcesAndGroup = ( 145 | uriString: Uri, 146 | status: ResourceStatus 147 | ): [FossilResource[], FossilResourceGroup] => { 148 | if (status === ResourceStatus.EXTRA) { 149 | return [untrackedResources, untracked]; 150 | } 151 | 152 | if (status === ResourceStatus.CONFLICT) { 153 | return [conflictResources, conflict]; 154 | } 155 | return staging.includesUri(uriString) 156 | ? [stagingResources, staging] 157 | : [workingDirectoryResources, working]; 158 | }; 159 | 160 | const seenUriStrings: Map = new Map(); 161 | 162 | for (const raw of fileStatuses) { 163 | const uri = Uri.file(path.join(repositoryRoot, raw.path)); 164 | const uriString = uri.toString(); 165 | seenUriStrings.set(uriString, true); 166 | const renameUri = raw.rename 167 | ? Uri.file(path.join(repositoryRoot, raw.rename)) 168 | : undefined; 169 | const [resources, group] = chooseResourcesAndGroup(uri, raw.status); 170 | resources.push( 171 | new FossilResource(group, uri, raw.status, raw.klass, renameUri) 172 | ); 173 | } 174 | 175 | conflict.updateResources(conflictResources); 176 | staging.updateResources(stagingResources); 177 | working.updateResources(workingDirectoryResources); 178 | untracked.updateResources(untrackedResources); 179 | } 180 | 181 | export const isResourceGroup = ( 182 | obj: FossilResource | SourceControlResourceGroup 183 | ): obj is SourceControlResourceGroup => 184 | (obj).resourceStates !== undefined; 185 | -------------------------------------------------------------------------------- /src/statusBar.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------------------------------------------- 2 | * Copyright (c) Arseniy Terekhin. All rights reserved. 3 | * Licensed under the MIT License. See LICENSE.txt in the project root for license information. 4 | *--------------------------------------------------------------------------------------------*/ 5 | 6 | import type { Command, SourceControl } from 'vscode'; 7 | import { Repository } from './repository'; 8 | import { ageFromNow, Old } from './humanise'; 9 | import { localize } from './main'; 10 | import { ExecResult } from './fossilExecutable'; 11 | 12 | /** 13 | * A bar with 'sync' icon; 14 | * - should run `fossil up` command for specific repository when clicked [ok] 15 | * - the tooltip should show 'changes' that are shown when running `fossil up --dry-run` 16 | * - should show number of remote changes (if != 0) 17 | * - should be animated when sync/update is running 18 | * - sync should be rescheduled after `sync` or `update` commands 19 | * - handle case when there's no sync URL 20 | */ 21 | class SyncBar { 22 | private icon: 'sync' | 'warning' = 'sync'; // 'sync-ignored' is nice, but not intuitive 23 | private text = ''; 24 | private syncMessage: `${string}\n` | '' = ''; 25 | /** 26 | * match for /changes:\s*(string)/ 27 | * like `17 files modified.` or ` None. Already up-to-date` 28 | */ 29 | private changes: string = ''; // 30 | private nextSyncTime: Date | undefined; // undefined = no auto syncing 31 | 32 | constructor(private repository: Repository) {} 33 | 34 | public onChangesReady(updateResult: ExecResult) { 35 | if (!updateResult.exitCode) { 36 | const match = updateResult.stdout.match(/^changes:\s*((\d*).*)/m); 37 | this.changes = match?.[1] ?? 'unknown changes'; 38 | this.text = match?.[2] ?? ''; // digits of nothing 39 | } else { 40 | this.changes = this.text = ''; 41 | } 42 | } 43 | 44 | public onSyncReady(result: ExecResult) { 45 | this.icon = 'sync'; 46 | if (!result.exitCode) { 47 | this.syncMessage = ''; 48 | } else { 49 | if (/^Usage: /.test(result.stderr)) { 50 | // likely only local repo 51 | this.syncMessage = 'repository with no remote\n'; 52 | } else { 53 | this.icon = 'warning'; 54 | this.syncMessage = `Sync error: ${result.stderr}\n`; 55 | } 56 | } 57 | } 58 | 59 | public onSyncTimeUpdated(date: Date | undefined) { 60 | this.nextSyncTime = date; 61 | } 62 | 63 | public get command(): Command { 64 | const timeMessage = this.nextSyncTime 65 | ? `Next sync ${this.nextSyncTime.toTimeString().split(' ')[0]}` 66 | : `Auto sync disabled`; 67 | return { 68 | command: 'fossil.update', 69 | title: `$(${this.icon}) ${this.text}`.trim(), 70 | tooltip: `${timeMessage}\n${this.syncMessage}${this.changes}\nUpdate`, 71 | arguments: [this.repository satisfies Repository], 72 | }; 73 | } 74 | } 75 | 76 | /** 77 | * Create `vscode.Command` that executes 'fossil.branchChange' 78 | * decorated with icon, branch name, and repository status 79 | * with branch details in the tooltip 80 | */ 81 | function branchCommand(repository: Repository): Command { 82 | const { currentBranch, fossilStatus } = repository; 83 | const icon = fossilStatus!.isMerge ? '$(git-merge)' : '$(git-branch)'; 84 | const title = 85 | icon + 86 | ' ' + 87 | (currentBranch || 'unknown') + 88 | (repository.conflictGroup.resourceStates.length 89 | ? '!' 90 | : repository.workingGroup.resourceStates.length 91 | ? '+' 92 | : ''); 93 | let checkoutAge = ''; 94 | const d = new Date(fossilStatus!.checkout.date.replace(' UTC', '.000Z')); 95 | checkoutAge = ageFromNow(d, Old.EMPTY_STRING); 96 | 97 | return { 98 | command: 'fossil.branchChange', 99 | tooltip: localize( 100 | 'branch change {0} {1}{2} {3}', 101 | '{0}\n{1}{2}\nTags:\n • {3}\nChange Branch...', 102 | fossilStatus!.checkout.checkin, 103 | fossilStatus!.checkout.date, 104 | checkoutAge && ` (${checkoutAge})`, 105 | fossilStatus!.tags.join('\n • ') 106 | ), 107 | title, 108 | arguments: [repository satisfies Repository], 109 | }; 110 | } 111 | 112 | export class StatusBarCommands { 113 | private readonly syncBar: SyncBar; 114 | 115 | constructor( 116 | private readonly repository: Repository, 117 | private readonly sourceControl: SourceControl 118 | ) { 119 | this.syncBar = new SyncBar(repository); 120 | this.update(); 121 | } 122 | 123 | public onChangesReady(updateResult: ExecResult) { 124 | this.syncBar.onChangesReady(updateResult); 125 | this.update(); 126 | } 127 | 128 | public onSyncTimeUpdated(date: Date | undefined) { 129 | this.syncBar.onSyncTimeUpdated(date); 130 | this.update(); 131 | } 132 | 133 | public onSyncReady(syncResult: ExecResult) { 134 | this.syncBar.onSyncReady(syncResult); 135 | this.update(); 136 | } 137 | 138 | /** 139 | * Should be called whenever commands text/actions/tooltips 140 | * are updated 141 | */ 142 | public update(): void { 143 | let commands: Command[]; 144 | if (this.repository.fossilStatus) { 145 | const update = branchCommand(this.repository); 146 | const sideEffects = this.repository.operations; 147 | const messages = []; 148 | for (const [, se] of sideEffects) { 149 | if (se.syncText) { 150 | messages.push(se.syncText); 151 | } 152 | } 153 | messages.sort(); 154 | const sync = messages.length 155 | ? { 156 | title: '$(sync~spin)', 157 | command: '', 158 | tooltip: messages.join('\n'), 159 | } 160 | : this.syncBar.command; 161 | 162 | commands = [update, sync]; 163 | } else { 164 | // this class was just initialized, repository status is unknown 165 | commands = [ 166 | { 167 | command: '', 168 | tooltip: localize( 169 | 'loading {0}', 170 | 'Loading {0}', 171 | this.repository.root 172 | ), 173 | title: '$(sync~spin)', 174 | }, 175 | ]; 176 | } 177 | this.sourceControl.statusBarCommands = commands; 178 | } 179 | } 180 | -------------------------------------------------------------------------------- /src/test/suite/Infrastructure.test.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import * as sinon from 'sinon'; 3 | import { 4 | FossilCWD, 5 | FossilExecutablePath, 6 | FossilStdOut, 7 | FossilStdErr, 8 | ExecFailure, 9 | toString as ExecFailureToString, 10 | } from '../../fossilExecutable'; 11 | import * as assert from 'assert/strict'; 12 | import { getExecutable } from './common'; 13 | import { after, afterEach, before } from 'mocha'; 14 | import { Old, ageFromNow } from '../../humanise'; 15 | import { ThrottlingQueue } from '../../throttlingQueue'; 16 | 17 | suite('Infrastructure', () => { 18 | const sandbox = sinon.createSandbox(); 19 | afterEach(() => { 20 | sandbox.restore(); 21 | }); 22 | test('Error is not thrown when executing unknown command', async () => { 23 | const rootUri = vscode.workspace.workspaceFolders![0].uri; 24 | const cwd = rootUri.fsPath as FossilCWD; 25 | const showErrorMessage: sinon.SinonStub = sandbox 26 | .stub(vscode.window, 'showErrorMessage') 27 | .resolves(); 28 | const executable = getExecutable(); 29 | const result = await executable.exec(cwd, ['fizzbuzz'] as any); 30 | const fossilPath = (executable as any).fossilPath as string; 31 | assert.deepEqual(result, { 32 | message: 'Failed to execute fossil', 33 | stderr: (`${fossilPath}: unknown command: fizzbuzz\n` + 34 | `${fossilPath}: use "help" for more information\n`) as FossilStdErr, 35 | stdout: '' as FossilStdOut, 36 | exitCode: 1, 37 | args: ['fizzbuzz' as any], 38 | fossilErrorCode: 'unknown', 39 | cwd: cwd, 40 | fossilPath: (executable as any).fossilPath, 41 | toString: ExecFailureToString, 42 | }); 43 | sinon.assert.calledOnceWithExactly( 44 | showErrorMessage, 45 | `Fossil: ${fossilPath}: unknown command: fizzbuzz`, 46 | 'Open Fossil Log' 47 | ); 48 | }); 49 | test('Error to string is valid', async () => { 50 | const TestError = { 51 | message: 'my message', 52 | stdout: 'my stdout' as FossilStdOut, 53 | stderr: 'my stderr' as FossilStdErr, 54 | exitCode: 1, 55 | fossilErrorCode: 'unknown', 56 | args: ['cat'], 57 | cwd: 'cwd' as FossilCWD, 58 | fossilPath: '/bin/fossil' as FossilExecutablePath, 59 | toString: ExecFailureToString, 60 | } as ExecFailure; 61 | const referenceString = 62 | 'my message {\n' + 63 | ' "stdout": "my stdout",\n' + 64 | ' "stderr": "my stderr",\n' + 65 | ' "exitCode": 1,\n' + 66 | ' "fossilErrorCode": "unknown",\n' + 67 | ' "args": [\n' + 68 | ' "cat"\n' + 69 | ' ],\n' + 70 | ' "cwd": "cwd",\n' + 71 | ' "fossilPath": "/bin/fossil"\n' + 72 | '}'; 73 | assert.equal(TestError.toString(), referenceString); 74 | }); 75 | 76 | suite('ageFromNow', function () { 77 | const N = 1686899727000; // 2023-06-16T07:15:27.000Z friday 78 | const minutes = (n: number) => new Date(N + n * 60000); 79 | const days = (n: number) => minutes(n * 24 * 60); 80 | let fakeTimers: sinon.SinonFakeTimers; 81 | 82 | before(() => { 83 | fakeTimers = sinon.useFakeTimers(N); 84 | }); 85 | after(() => { 86 | fakeTimers.restore(); 87 | }); 88 | test('Now', () => { 89 | assert.equal(ageFromNow(new Date()), 'now'); 90 | }); 91 | test('Now - 12 seconds', () => { 92 | assert.equal(ageFromNow(minutes(-0.2)), 'a few moments ago'); 93 | }); 94 | test('Now - 30 seconds', () => { 95 | assert.equal(ageFromNow(minutes(-0.5)), '30 seconds ago'); 96 | }); 97 | test('Now - 1 minute', () => { 98 | assert.equal(ageFromNow(minutes(-1)), '60 seconds ago'); 99 | }); 100 | test('Now - 2 minutes', () => { 101 | assert.equal(ageFromNow(minutes(-2)), '2 minutes ago'); 102 | }); 103 | test('Now - 10 minute', () => { 104 | assert.equal(ageFromNow(minutes(-10)), '10 minutes ago'); 105 | }); 106 | test('Now - 1 hour', () => { 107 | assert.equal(ageFromNow(minutes(-60)), '1 hour ago'); 108 | }); 109 | test('Now - 23.5 hour', () => { 110 | assert.equal(ageFromNow(minutes(-23.5 * 60)), 'yesterday'); 111 | }); 112 | test('Now - 1 day', () => { 113 | assert.equal(ageFromNow(days(-1)), 'yesterday'); 114 | }); 115 | test('Now - 2 days', () => { 116 | assert.equal(ageFromNow(days(-2)), '2 days ago'); 117 | }); 118 | test('Now - 3 days', () => { 119 | assert.equal(ageFromNow(days(-3)), '3 days ago'); 120 | }); 121 | test('Now - 6 days', () => { 122 | assert.equal(ageFromNow(days(-6)), 'last week'); 123 | }); 124 | test('Now - 7 days', () => { 125 | assert.equal(ageFromNow(days(-7)), '6/9/2023'); 126 | }); 127 | test('Long ago is empty string', () => { 128 | assert.equal(ageFromNow(days(-30), Old.EMPTY_STRING), ''); 129 | }); 130 | test('Now + 1 minute', () => { 131 | assert.equal(ageFromNow(minutes(1)), 'future (1 minute)'); 132 | }); 133 | test('Now + 1 day', () => { 134 | assert.equal(ageFromNow(days(1)), 'future (24 hours)'); 135 | }); 136 | test('Now + 7 days', () => { 137 | assert.equal(ageFromNow(days(7)), 'future (7 days)'); 138 | }); 139 | test('Now + one year', () => { 140 | assert.equal(ageFromNow(days(366)), 'future (366 days)'); 141 | }); 142 | }); 143 | test('Queue "next" logic', async () => { 144 | const queue = new ThrottlingQueue(); 145 | const p1 = queue.enqueue(() => new Promise(c => c(1)), 'p'); 146 | const p2 = queue.enqueue(() => new Promise(c => c(2)), 'p'); 147 | const p3 = queue.enqueue(() => new Promise(c => c(3)), 'p'); 148 | const res = await Promise.allSettled([p1, p2, p3]); 149 | assert.deepEqual(res, [ 150 | { 151 | status: 'fulfilled', 152 | value: 1, 153 | }, 154 | { 155 | status: 'fulfilled', 156 | value: 2, 157 | }, 158 | { 159 | status: 'fulfilled', 160 | value: 2, 161 | }, 162 | ]); 163 | }); 164 | test('Queue can handle exceptions', async () => { 165 | const error = new Error(); 166 | const queue = new ThrottlingQueue(); 167 | const p1 = queue.enqueue(() => new Promise((c, e) => e(error)), 'p'); 168 | const res = await Promise.allSettled([p1]); 169 | assert.deepEqual(res, [ 170 | { 171 | status: 'rejected', 172 | reason: error, 173 | }, 174 | ]); 175 | }); 176 | }); 177 | -------------------------------------------------------------------------------- /src/praise.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CancellationToken, 3 | DecorationRangeBehavior, 4 | Disposable, 5 | Hover, 6 | languages, 7 | MarkdownString, 8 | Position, 9 | Range, 10 | TextDocument, 11 | TextDocumentChangeEvent, 12 | TextEditor, 13 | TextEditorSelectionChangeEvent, 14 | ThemableDecorationAttachmentRenderOptions, 15 | ThemeColor, 16 | window, 17 | workspace, 18 | } from 'vscode'; 19 | import { FossilHash, Praise } from './openedRepository'; 20 | import { Repository } from './repository'; 21 | 22 | const annotationDecoration = window.createTextEditorDecorationType({ 23 | rangeBehavior: DecorationRangeBehavior.ClosedOpen, 24 | before: { 25 | borderColor: new ThemeColor('focusBorder'), 26 | height: '100%', 27 | margin: '0 26px -1px 0', 28 | fontWeight: 'normal', 29 | fontStyle: 'normal', 30 | backgroundColor: 'rgba(255, 255, 255, 0.07)', 31 | color: new ThemeColor('editor.foreground'), 32 | }, 33 | light: { 34 | before: { 35 | backgroundColor: 'rgba(0, 0, 0, 0.07)', 36 | }, 37 | }, 38 | }); 39 | 40 | const annotationHighlight = window.createTextEditorDecorationType({ 41 | isWholeLine: true, 42 | backgroundColor: 'rgba(128, 255, 255, 0.07)', 43 | light: { 44 | backgroundColor: 'rgba(0, 128, 128, 0.07)', 45 | }, 46 | }); 47 | 48 | export class PraiseAnnotator { 49 | private static editors = new WeakMap(); 50 | private readonly disposable: Disposable; 51 | private readonly document: TextDocument; 52 | private readonly hoverProvider: Disposable; 53 | private constructor( 54 | private readonly repository: Repository, 55 | private readonly editor: TextEditor, 56 | private readonly hashes: FossilHash[] 57 | ) { 58 | this.document = editor.document; 59 | this.disposable = Disposable.from( 60 | window.onDidChangeTextEditorSelection( 61 | this.onTextEditorSelectionChanged, 62 | this 63 | ), 64 | workspace.onDidCloseTextDocument(this.onDidCloseTextDocument, this), 65 | workspace.onDidChangeTextDocument(this.onTextDocumentChanged, this) 66 | ); 67 | this.hoverProvider = languages.registerHoverProvider( 68 | { pattern: this.document.uri.fsPath }, 69 | { provideHover: this.onHover.bind(this) } 70 | ); 71 | } 72 | static async create( 73 | repository: Repository, 74 | editor: TextEditor, 75 | praises: Praise[] 76 | ): Promise { 77 | await editor.document.save(); // for `fossil diff` to work 78 | let prev_hash: FossilHash | undefined; 79 | const nbsp = ' '; 80 | const decorations = praises.map((praise, lineNo) => { 81 | const range = editor.document.validateRange( 82 | new Range(lineNo, 0, lineNo, 0) 83 | ); 84 | let before: ThemableDecorationAttachmentRenderOptions; 85 | const common = { 86 | borderStyle: 'solid', 87 | borderWidth: '0 2px 0 0', 88 | }; 89 | if (prev_hash == praise[0]) { 90 | before = { 91 | contentText: nbsp, 92 | textDecoration: 'none;padding: 0 33ch 0 0', 93 | ...common, 94 | }; 95 | } else { 96 | prev_hash = praise[0]; 97 | // total width: 8(hash) + 1 + 10(date) + 1 + 13 = 33 98 | const checkin = praise[0].slice(0, 8) || nbsp.repeat(8); 99 | const date = praise[1] || nbsp.repeat(10); 100 | const username = praise[2].slice(-13); 101 | const contentText = `${checkin} ${date}${nbsp.repeat( 102 | 14 - username.length 103 | )}${username}`; 104 | before = { 105 | contentText, 106 | textDecoration: 'none;padding: 0 1ch 0 0', 107 | ...common, 108 | }; 109 | } 110 | if (!praise[0]) { 111 | before.backgroundColor = 'rgba(53, 255, 28, 0.07)'; 112 | } 113 | return { renderOptions: { before }, range }; 114 | }); 115 | editor.setDecorations(annotationDecoration, decorations); 116 | const hashes = praises.map(praise => praise[0]); 117 | const annotator = new PraiseAnnotator(repository, editor, hashes); 118 | PraiseAnnotator.editors.set(editor, annotator); 119 | return annotator; 120 | } 121 | 122 | static tryDelete(editor: TextEditor): boolean { 123 | const praise = PraiseAnnotator.editors.get(editor); 124 | praise?.dispose(); 125 | return praise !== undefined; 126 | } 127 | 128 | private onTextEditorSelectionChanged( 129 | event: TextEditorSelectionChangeEvent 130 | ): void { 131 | if (event.textEditor === this.editor) { 132 | const ranges: Range[] = []; 133 | const line = event.selections[0].active.line; 134 | const curHash = this.hashes[line]; 135 | this.hashes.map((hash, lineNo) => { 136 | if (hash === curHash) { 137 | ranges.push(new Range(lineNo, 0, lineNo, 0)); 138 | } 139 | }); 140 | event.textEditor.setDecorations(annotationHighlight, ranges); 141 | } 142 | } 143 | 144 | private onDidCloseTextDocument(document: TextDocument) { 145 | // this event can be delayed, but I believe 146 | // this is the right place to cleanup 147 | if (document === this.document) { 148 | this.dispose(); 149 | } 150 | } 151 | 152 | private onTextDocumentChanged(event: TextDocumentChangeEvent) { 153 | if (event.document === this.document) { 154 | this.dispose(); 155 | } 156 | } 157 | 158 | private async onHover( 159 | document: TextDocument, 160 | position: Position, 161 | _token: CancellationToken 162 | ): Promise { 163 | if (document !== this.editor.document) { 164 | return undefined; 165 | } 166 | const checkin = this.hashes[position.line]; 167 | if (!checkin) { 168 | return new Hover(new MarkdownString('local change')); 169 | } 170 | const info = await this.repository.info(checkin); 171 | const infoString = 172 | `**${info.comment}**\n\n` + 173 | Object.entries(info) 174 | .map(([key, value]) => 175 | key == 'comment' ? '' : `* ${key}: **${value}**\n` 176 | ) 177 | .join(''); 178 | return new Hover(new MarkdownString(infoString, true)); 179 | } 180 | 181 | dispose(): void { 182 | PraiseAnnotator.editors.delete(this.editor); 183 | this.editor.setDecorations(annotationDecoration, []); 184 | this.editor.setDecorations(annotationHighlight, []); 185 | this.disposable.dispose(); 186 | this.hoverProvider.dispose(); 187 | } 188 | } 189 | -------------------------------------------------------------------------------- /src/fileSystemProvider.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------------------------------------------- 2 | * Copyright (c) Ben Crowl. All rights reserved. 3 | * Original Copyright (c) Microsoft Corporation. All rights reserved. 4 | * Licensed under the MIT License. See LICENSE.txt in the project root for license information. 5 | *--------------------------------------------------------------------------------------------*/ 6 | 7 | import { 8 | workspace, 9 | Uri, 10 | Disposable, 11 | Event, 12 | EventEmitter, 13 | window, 14 | FileSystemError, 15 | FileStat, 16 | FileType, 17 | FileChangeEvent, 18 | FileSystemProvider, 19 | FileChangeType, 20 | } from 'vscode'; 21 | import { debounce, throttle } from './decorators'; 22 | import { Model, ModelChangeEvent, OriginalResourceChangeEvent } from './model'; 23 | import { eventToPromise, filterEvent, toDisposable } from './util'; 24 | import { fromFossilUri, toFossilUri } from './uri'; 25 | import { sep } from 'path'; 26 | export const EmptyDisposable = toDisposable(() => null); 27 | 28 | function isWindowsPath(path: string): boolean { 29 | return /^[a-zA-Z]:\\/.test(path); 30 | } 31 | 32 | function isDescendant(parent: string, descendant: string): boolean { 33 | if (parent === descendant) { 34 | return true; 35 | } 36 | 37 | if (parent.charAt(parent.length - 1) !== sep) { 38 | parent += sep; 39 | } 40 | 41 | // Windows is case insensitive 42 | if (isWindowsPath(parent)) { 43 | parent = parent.toLowerCase(); 44 | descendant = descendant.toLowerCase(); 45 | } 46 | 47 | return descendant.startsWith(parent); 48 | } 49 | 50 | function pathEquals(a: string, b: string): boolean { 51 | // Windows is case insensitive 52 | if (isWindowsPath(a)) { 53 | a = a.toLowerCase(); 54 | b = b.toLowerCase(); 55 | } 56 | 57 | return a === b; 58 | } 59 | 60 | interface CacheRow { 61 | uri: Uri; 62 | timestamp: number; 63 | } 64 | 65 | const THREE_MINUTES = 1000 * 60 * 3; 66 | const FIVE_MINUTES = 1000 * 60 * 5; 67 | 68 | function not_implemented(): never { 69 | throw new Error('Method not implemented.'); 70 | } 71 | 72 | export class FossilFileSystemProvider implements FileSystemProvider { 73 | private _onDidChangeFile = new EventEmitter(); 74 | readonly onDidChangeFile: Event = 75 | this._onDidChangeFile.event; 76 | 77 | private changedRepositoryRoots = new Set(); 78 | private cache = new Map(); 79 | private mtime = new Date().getTime(); 80 | private disposables: Disposable[] = []; 81 | 82 | constructor(private model: Model) { 83 | this.disposables.push( 84 | model.onDidChangeRepository(this.onDidChangeRepository, this), 85 | model.onDidChangeOriginalResource( 86 | this.onDidChangeOriginalResource, 87 | this 88 | ), 89 | workspace.registerFileSystemProvider('fossil', this, { 90 | isReadonly: true, 91 | isCaseSensitive: true, 92 | }) 93 | ); 94 | 95 | setInterval(() => this.cleanup(), FIVE_MINUTES); 96 | } 97 | 98 | private onDidChangeRepository({ repository }: ModelChangeEvent): void { 99 | this.changedRepositoryRoots.add(repository.root); 100 | this.eventuallyFireChangeEvents(); 101 | } 102 | 103 | private onDidChangeOriginalResource({ 104 | uri, 105 | }: OriginalResourceChangeEvent): void { 106 | if (uri.scheme !== 'file') { 107 | return; 108 | } 109 | 110 | const fossilUri = toFossilUri(uri); 111 | this.mtime = new Date().getTime(); 112 | this._onDidChangeFile.fire([ 113 | { type: FileChangeType.Changed, uri: fossilUri }, 114 | ]); 115 | } 116 | 117 | @debounce(1100) 118 | private eventuallyFireChangeEvents(): void { 119 | this.fireChangeEvents(); 120 | } 121 | 122 | @throttle 123 | private async fireChangeEvents(): Promise { 124 | if (!window.state.focused) { 125 | const onDidFocusWindow = filterEvent( 126 | window.onDidChangeWindowState, 127 | e => e.focused 128 | ); 129 | await eventToPromise(onDidFocusWindow); 130 | } 131 | 132 | const events: FileChangeEvent[] = []; 133 | 134 | for (const { uri } of this.cache.values()) { 135 | const fsPath = uri.fsPath; 136 | 137 | for (const root of this.changedRepositoryRoots) { 138 | if (isDescendant(root, fsPath)) { 139 | events.push({ type: FileChangeType.Changed, uri }); 140 | break; 141 | } 142 | } 143 | } 144 | 145 | if (events.length > 0) { 146 | this.mtime = new Date().getTime(); 147 | this._onDidChangeFile.fire(events); 148 | } 149 | 150 | this.changedRepositoryRoots.clear(); 151 | } 152 | 153 | watch(): Disposable { 154 | return EmptyDisposable; 155 | } 156 | 157 | async stat(uri: Uri): Promise { 158 | await this.model.isInitialized; 159 | 160 | const repository = this.model.getRepository(uri); 161 | if (!repository) { 162 | throw FileSystemError.FileNotFound(); 163 | } 164 | return { type: FileType.File, size: 0, mtime: this.mtime, ctime: 0 }; 165 | } 166 | 167 | async readFile(uri: Uri): Promise { 168 | await this.model.isInitialized; 169 | 170 | const repository = this.model.getRepository(uri); 171 | 172 | if (!repository) { 173 | throw FileSystemError.FileNotFound(); 174 | } 175 | 176 | const cacheKey = uri.toString(); 177 | const timestamp = new Date().getTime(); 178 | const cacheValue: CacheRow = { uri, timestamp }; 179 | 180 | this.cache.set(cacheKey, cacheValue); 181 | 182 | const content = await repository.cat(fromFossilUri(uri)); 183 | if (content !== undefined) { 184 | return content; 185 | } 186 | throw FileSystemError.FileNotFound(); 187 | } 188 | 189 | /* c8 ignore start */ 190 | readDirectory(): never { 191 | not_implemented(); 192 | } 193 | 194 | createDirectory(): never { 195 | not_implemented(); 196 | } 197 | 198 | writeFile(): never { 199 | not_implemented(); 200 | } 201 | 202 | delete(): never { 203 | not_implemented(); 204 | } 205 | 206 | rename(): never { 207 | not_implemented(); 208 | } 209 | /* c8 ignore stop */ 210 | 211 | private cleanup(): void { 212 | const now = new Date().getTime(); 213 | const cache = new Map(); 214 | 215 | for (const row of this.cache.values()) { 216 | const { path } = fromFossilUri(row.uri); 217 | const isOpen = workspace.textDocuments 218 | .filter(d => d.uri.scheme === 'file') 219 | .some(d => pathEquals(d.uri.fsPath, path)); 220 | 221 | if (isOpen || now - row.timestamp < THREE_MINUTES) { 222 | cache.set(row.uri.toString(), row); 223 | } else { 224 | // TODO: should fire delete events? 225 | } 226 | } 227 | 228 | this.cache = cache; 229 | } 230 | 231 | dispose(): void { 232 | this.disposables.forEach(d => d.dispose()); 233 | } 234 | } 235 | -------------------------------------------------------------------------------- /src/test/suite/timelineSuite.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import { window } from 'vscode'; 3 | import * as assert from 'assert/strict'; 4 | import { FossilUriParams, toFossilUri } from '../../uri'; 5 | import type { FossilCWD } from '../../fossilExecutable'; 6 | import { add, cleanupFossil, getExecutable, getRepository } from './common'; 7 | import { Suite, before } from 'mocha'; 8 | import * as sinon from 'sinon'; 9 | import type { FossilCheckin } from '../../openedRepository'; 10 | 11 | // separate function because hash size is different 12 | const uriMatch = (uri: vscode.Uri, checkin: FossilCheckin) => 13 | sinon.match((exp: vscode.Uri): boolean => { 14 | const exp_q = JSON.parse(exp.query) as FossilUriParams; 15 | return ( 16 | uri.path == exp.path && 17 | uri.fsPath == exp_q.path && 18 | exp_q.checkin.slice(0, 40) == checkin 19 | ); 20 | }); 21 | 22 | export function timelineSuite(this: Suite): void { 23 | let file2uri: vscode.Uri; 24 | 25 | before(async () => { 26 | await cleanupFossil(getRepository()); 27 | await add('file1.txt', 'line1\n', 'file1.txt: first'); 28 | await add('file1.txt', 'line1\nline2\n', 'file1.txt: second', 'SKIP'); 29 | await add( 30 | 'file1.txt', 31 | 'line1\nline2\nline3\n', 32 | 'file1.txt: third', 33 | 'SKIP' 34 | ); 35 | await add('file2.txt', 'line1\n', 'file2.txt: first'); 36 | await add('file2.txt', 'line1\nline2\n', 'file2.txt: second', 'SKIP'); 37 | file2uri = await add( 38 | 'file2.txt', 39 | 'line1\nline2\nline3\n', 40 | 'file2.txt: third', 41 | 'SKIP' 42 | ); 43 | }); 44 | 45 | test('`fossil.fileLog` undefined', async () => { 46 | await vscode.commands.executeCommand('fossil.fileLog'); 47 | }); 48 | 49 | test('Show diff from `fossil.fileLog`', async () => { 50 | const repository = getRepository(); 51 | const showQuickPick = this.ctx.sandbox.stub(window, 'showQuickPick'); 52 | showQuickPick.onFirstCall().callsFake(items => { 53 | assert.ok(items instanceof Array); 54 | assert.equal(items[0].label, '$(tag) Current'); 55 | return Promise.resolve(items[0]); 56 | }); 57 | showQuickPick.onSecondCall().callsFake(items => { 58 | assert.ok(items instanceof Array); 59 | assert.equal(items[0].label, '$(circle-outline) Parent'); 60 | return Promise.resolve(items[0]); 61 | }); 62 | 63 | const diffCommand = this.ctx.sandbox 64 | .stub(vscode.commands, 'executeCommand') 65 | .callThrough() 66 | .withArgs('vscode.diff') 67 | .resolves(); 68 | 69 | await vscode.commands.executeCommand('fossil.fileLog', file2uri); 70 | sinon.assert.calledTwice(showQuickPick); 71 | 72 | const parentHash = await repository.getInfo('current', 'parent'); 73 | sinon.assert.calledOnceWithExactly( 74 | diffCommand, 75 | 'vscode.diff', 76 | toFossilUri(file2uri, 'current'), 77 | toFossilUri(file2uri, parentHash), 78 | `file2.txt (current vs. ${parentHash.slice(0, 12)})` 79 | ); 80 | }).timeout(2000); 81 | 82 | const testDiff = async ( 83 | callback: ( 84 | items: readonly vscode.QuickPickItem[] 85 | ) => Thenable 86 | ) => { 87 | const repository = getRepository(); 88 | const showQuickPick = this.ctx.sandbox.stub(window, 'showQuickPick'); 89 | showQuickPick.onFirstCall().callsFake(items => { 90 | assert.ok(items instanceof Array); 91 | assert.equal(items[0].label, '$(tag) Current'); 92 | return Promise.resolve(items[0]); 93 | }); 94 | showQuickPick.onSecondCall().callsFake(items => { 95 | assert.ok(items instanceof Array); 96 | return callback(items); 97 | }); 98 | 99 | const diffCommand = this.ctx.sandbox 100 | .stub(vscode.commands, 'executeCommand') 101 | .callThrough() 102 | .withArgs('vscode.diff') 103 | .resolves(); 104 | 105 | await vscode.commands.executeCommand('fossil.log'); 106 | sinon.assert.calledTwice(showQuickPick); 107 | 108 | const currentHash = await repository.getInfo('current', 'hash'); 109 | const parentHash = await repository.getInfo(currentHash, 'parent'); 110 | 111 | sinon.assert.calledOnceWithExactly( 112 | diffCommand, 113 | 'vscode.diff', 114 | uriMatch(file2uri, parentHash), 115 | uriMatch(file2uri, currentHash), 116 | `file2.txt (${parentHash.slice(0, 12)} vs. ${currentHash.slice( 117 | 0, 118 | 12 119 | )})`, 120 | { preview: false } 121 | ); 122 | }; 123 | 124 | test('Show diff from `fossil.Log`', async () => { 125 | await testDiff(items => { 126 | assert.equal(items[4].label, '    M  file2.txt'); 127 | return Promise.resolve(items[4]); 128 | }); 129 | }); 130 | 131 | test('Show diff all from `fossil.Log`', async () => { 132 | await testDiff(items => { 133 | assert.equal( 134 | items[2].label, 135 | '$(go-to-file) Open all changed files' 136 | ); 137 | return Promise.resolve(items[2]); 138 | }); 139 | }); 140 | 141 | test('Amend commit message', async () => { 142 | const cwd = this.ctx.workspaceUri.fsPath as FossilCWD; 143 | 144 | await add('amend.txt', '\n', 'message to amend'); 145 | 146 | const showQuickPick = this.ctx.sandbox.stub(window, 'showQuickPick'); 147 | showQuickPick.onFirstCall().callsFake(items => { 148 | assert.ok(items instanceof Array); 149 | assert.equal(items[0].label, '$(tag) Current'); 150 | return Promise.resolve(items[0]); 151 | }); 152 | showQuickPick.onSecondCall().callsFake(items => { 153 | assert.ok(items instanceof Array); 154 | const aItem = items.find( 155 | item => item.label === '    A  amend.txt' 156 | ); 157 | assert.ok(aItem); 158 | assert.equal(aItem.description, '.'); 159 | assert.equal(items[1].label, '$(edit) Edit commit message'); 160 | return Promise.resolve(items[1]); 161 | }); 162 | const messageStub = this.ctx.sandbox 163 | .stub(window, 'showInputBox') 164 | .withArgs(sinon.match({ placeHolder: 'Commit message' })) 165 | .resolves('updated commit message'); 166 | const sim: sinon.SinonStub = this.ctx.sandbox 167 | .stub(window, 'showInformationMessage') 168 | .resolves(); 169 | 170 | await vscode.commands.executeCommand('fossil.log'); 171 | sinon.assert.calledTwice(showQuickPick); 172 | sinon.assert.calledOnceWithExactly(messageStub, { 173 | value: 'message to amend', 174 | placeHolder: 'Commit message', 175 | prompt: 'Please provide a commit message', 176 | ignoreFocusOut: true, 177 | }); 178 | sinon.assert.calledOnceWithExactly(sim, 'Commit message was updated.'); 179 | 180 | const executable = getExecutable(); 181 | const stdout = (await executable.exec(cwd, ['info'])).stdout; 182 | assert.match(stdout, /updated commit message/m); 183 | }).timeout(5000); 184 | } 185 | -------------------------------------------------------------------------------- /src/test/suite/revertSuite.ts: -------------------------------------------------------------------------------- 1 | import { Uri, window, commands } from 'vscode'; 2 | import * as sinon from 'sinon'; 3 | import { add, fakeFossilStatus, getExecStub, getRepository } from './common'; 4 | import * as assert from 'assert/strict'; 5 | import * as fs from 'fs/promises'; 6 | import type { Suite } from 'mocha'; 7 | import type { FossilResourceGroup } from '../../resourceGroups'; 8 | import type { FossilResource } from '../../repository'; 9 | import { Reason } from '../../fossilExecutable'; 10 | import { RelativePath } from '../../openedRepository'; 11 | 12 | export function RevertSuite(this: Suite): void { 13 | test('Single source', async () => { 14 | const url = await add( 15 | 'revert_me.txt', 16 | 'Some original text\n', 17 | 'add revert_me.txt' 18 | ); 19 | await fs.writeFile(url.fsPath, 'something new'); 20 | 21 | const repository = getRepository(); 22 | await repository.updateStatus('Test' as Reason); 23 | const resource = repository.workingGroup.getResource(url); 24 | assert.ok(resource); 25 | 26 | const showWarningMessage: sinon.SinonStub = this.ctx.sandbox.stub( 27 | window, 28 | 'showWarningMessage' 29 | ); 30 | showWarningMessage.onFirstCall().resolves('&&Discard Changes'); 31 | 32 | await commands.executeCommand('fossil.revert', resource); 33 | const newContext = await fs.readFile(url.fsPath); 34 | assert.equal(newContext.toString('utf-8'), 'Some original text\n'); 35 | }); 36 | 37 | suite('Dialog has no typos', () => { 38 | let swmResources: FossilResource[]; 39 | const prepareResources = async () => { 40 | if (swmResources) { 41 | return swmResources; 42 | } 43 | const repository = getRepository(); 44 | const fake_status = []; 45 | const execStub = getExecStub(this.ctx.sandbox); 46 | const fileUris: Uri[] = []; 47 | for (const filename of 'abcdefghijklmn') { 48 | // 14 files 49 | const fileUri = Uri.joinPath( 50 | this.ctx.workspaceUri, 51 | 'added', 52 | filename 53 | ); 54 | const action = ['k', 'l', 'm', 'n'].includes(filename) 55 | ? 'ADDED' 56 | : 'EDITED'; 57 | fake_status.push(`${action} added/${filename}`); 58 | fileUris.push(fileUri); 59 | } 60 | const statusCall = fakeFossilStatus( 61 | execStub, 62 | fake_status.join('\n') 63 | ); 64 | await repository.updateStatus('Test' as Reason); 65 | sinon.assert.calledOnce(statusCall); 66 | const resources = fileUris.map(uri => { 67 | const resource = repository.workingGroup.getResource(uri); 68 | assert.ok(resource); 69 | return resource; 70 | }); 71 | swmResources = resources; 72 | return swmResources; 73 | }; 74 | 75 | test('10 + 4 files', async () => { 76 | const resources = await prepareResources(); 77 | const swm = this.ctx.sandbox.stub(window, 'showWarningMessage'); 78 | await commands.executeCommand('fossil.revert', ...resources); 79 | sinon.assert.calledOnceWithMatch( 80 | swm, 81 | 'Are you sure you want to discard changes to 10 files?\n' + 82 | '\n • a\n • b\n • c\n • d\n • e\n • f\n • g\n • h\n' + 83 | 'and 2 others\n\n(and forget 4 other added files)', 84 | { modal: true } 85 | ); 86 | }); 87 | 88 | test('10 files', async () => { 89 | const resources = await prepareResources(); 90 | const swm = this.ctx.sandbox.stub(window, 'showWarningMessage'); 91 | await commands.executeCommand( 92 | 'fossil.revert', 93 | ...resources.slice(0, 10) 94 | ); 95 | sinon.assert.calledOnceWithMatch( 96 | swm, 97 | 'Are you sure you want to discard changes to 10 files?\n' + 98 | '\n • a\n • b\n • c\n • d\n • e\n • f\n • g\n • h\n' + 99 | 'and 2 others', 100 | { modal: true } 101 | ); 102 | }); 103 | 104 | test('3 files', async () => { 105 | const resources = await prepareResources(); 106 | const swm = this.ctx.sandbox.stub(window, 'showWarningMessage'); 107 | await commands.executeCommand( 108 | 'fossil.revert', 109 | ...resources.slice(0, 3) 110 | ); 111 | sinon.assert.calledOnceWithMatch( 112 | swm, 113 | 'Are you sure you want to discard changes to 3 files?\n' + 114 | '\n • a\n • b\n • c', 115 | { modal: true } 116 | ); 117 | }); 118 | 119 | test('2 added file', async () => { 120 | const resources = await prepareResources(); 121 | const swm = this.ctx.sandbox.stub(window, 'showWarningMessage'); 122 | await commands.executeCommand( 123 | 'fossil.revert', 124 | ...resources.slice(10, 12) 125 | ); 126 | sinon.assert.notCalled(swm); 127 | }); 128 | }); 129 | 130 | test('Revert (Nothing)', async () => { 131 | await commands.executeCommand('fossil.revert'); 132 | }); 133 | 134 | // for testing `fossil.revertAll` only 135 | async function revertAllTest( 136 | sandbox: sinon.SinonSandbox, 137 | groups: FossilResourceGroup[], 138 | message: string, 139 | files: string[] 140 | ): Promise { 141 | const swm: sinon.SinonStub = sandbox.stub(window, 'showWarningMessage'); 142 | swm.onFirstCall().resolves('&&Discard Changes'); 143 | 144 | const repository = getRepository(); 145 | const execStub = getExecStub(sandbox); 146 | const statusStub = fakeFossilStatus( 147 | execStub, 148 | 'EDITED a.txt\nEDITED b.txt\nCONFLICT c.txt\nCONFLICT d.txt' 149 | ); 150 | const revertStub = execStub 151 | .withArgs(sinon.match.array.startsWith(['revert'])) 152 | .resolves(); 153 | await repository.updateStatus('Test' as Reason); 154 | sinon.assert.calledOnce(statusStub); 155 | await commands.executeCommand('fossil.revertAll', ...groups); 156 | sinon.assert.calledOnceWithExactly( 157 | swm, 158 | message, 159 | { modal: true }, 160 | '&&Discard Changes' 161 | ); 162 | sinon.assert.calledOnceWithExactly(revertStub, [ 163 | 'revert', 164 | '--', 165 | ...(files as RelativePath[]), 166 | ]); 167 | } 168 | 169 | test('Revert all (no groups)', async () => { 170 | await revertAllTest( 171 | this.ctx.sandbox, 172 | [], 173 | 'Are you sure you want to discard changes in ' + 174 | '"Changes" and "Unresolved Conflicts" group?', 175 | ['a.txt', 'b.txt', 'c.txt', 'd.txt'] 176 | ); 177 | }); 178 | 179 | test('Revert all (changes group)', async () => { 180 | const repository = getRepository(); 181 | await revertAllTest( 182 | this.ctx.sandbox, 183 | [repository.workingGroup], 184 | 'Are you sure you want to discard changes in "Changes" group?', 185 | ['a.txt', 'b.txt'] 186 | ); 187 | }); 188 | 189 | test('Revert all (conflict group)', async () => { 190 | const repository = getRepository(); 191 | await revertAllTest( 192 | this.ctx.sandbox, 193 | [repository.conflictGroup], 194 | 'Are you sure you want to discard changes ' + 195 | 'in "Unresolved Conflicts" group?', 196 | ['c.txt', 'd.txt'] 197 | ); 198 | }); 199 | } 200 | -------------------------------------------------------------------------------- /pikchr/test/advanced.test.pikchr: -------------------------------------------------------------------------------- 1 | // SYNTAX TEST "source.pikchr" "long statements" 2 | 3 | line right even with $r left of X9 then up until even with VARIABLE.n 4 | // <---- storage.type.class.pikchr 5 | // ^^^^^ ^^^^ ^^ support.constant.edge.pikchr 6 | // ^^^^ ^^^^ ^^^^^ ^^^^ ^^^^ keyword.pikchr 7 | // ^^ variable.language.pikchr 8 | // ^^ keyword.control.of.pikchr 9 | // ^^ ^^^^^^^^ variable.language.place.pikchr 10 | // ^^^^ keyword.pikchr 11 | 12 | circle rad 0.05 at Paper + (-0.055, -0.25) 13 | // <------ storage.type.class.pikchr 14 | // ^^^ support.constant.property-value.pikchr 15 | // ^^^^ constant.numeric.pikchr 16 | // ^^ keyword.pikchr 17 | // ^^^^^ variable.language.place.pikchr 18 | // ^ keyword.operator.arithmetic.pikchr 19 | // ^ punctuation.parenthesis.begin.pikchr 20 | // ^ ^ keyword.operator.arithmetic.pikchr 21 | // ^^^^^ ^^^^ constant.numeric.pikchr 22 | // ^ punctuation.parenthesis.end.pikchr 23 | 24 | 25 | line thick right from 0.3cm below start of previous 26 | // <---- storage.type.class.pikchr 27 | // ^^^^^ entity.name.tag.pikchr 28 | // ^^^^^ support.constant.edge.pikchr 29 | // ^^^^ keyword.pikchr 30 | // ^^^^^ constant.numeric.pikchr 31 | // ^^ keyword.other.unit.cm.pikchr 32 | // ^^^^^ keyword.pikchr 33 | // ^^^^^ support.constant.edge.pikchr 34 | // ^^ keyword.control.of.pikchr 35 | // ^^^^^^^^ constant.language.pikchr 36 | 37 | oval width OVAL1.height height OVAL1.width 38 | // <---- storage.type.class.pikchr 39 | // ^^^^^ ^^^^^^ support.constant.property-value.pikchr 40 | // ^^^^^ ^^^^^ variable.language.place.pikchr 41 | // ^ ^ punctuation.separator.period.pikchr 42 | // ^^^^^^ ^^^^^ support.constant.property-value.pikchr 43 | 44 | oval "" fit at $r*1.2 below 1/2 way between X0 and X9 45 | // <---- storage.type.class.pikchr 46 | // ^^ string.quoted.double.pikchr 47 | // ^^^ entity.name.tag.pikchr 48 | // ^ -entity.name.tag.pikchr 49 | // ^^ keyword.pikchr 50 | // ^^ variable.language.pikchr 51 | // ^ keyword.operator.arithmetic.pikchr 52 | // ^^^ ^ ^ constant.numeric.pikchr 53 | // ^^^^^ keyword.pikchr 54 | // ^ keyword.operator.arithmetic.pikchr 55 | // ^^^ ^^^^^^^ keyword.pikchr 56 | // ^^ ^^ variable.language.place.pikchr 57 | // ^^^ keyword.pikchr 58 | 59 | arrow from TYPENAME.e right even with ATTR 60 | // <----- storage.type.class.pikchr 61 | // ^^^^ keyword.pikchr 62 | // ^^^^^^^^ ^^^^ variable.language.place.pikchr 63 | // ^ punctuation.separator.period.pikchr 64 | // ^ constant.language.pikchr 65 | // ^^^^^ support.constant.edge.pikchr 66 | // ^^^^ ^^^^ keyword.pikchr 67 | 68 | box "expr" fit with .w at 1.75*$h below BTW.w 69 | // <--- storage.type.class.pikchr 70 | // ^^^^^^ string.quoted.double.pikchr 71 | // ^^^ entity.name.tag.pikchr 72 | // ^^^^ keyword.pikchr 73 | // ^ punctuation.separator.period.pikchr 74 | // ^ constant.language.pikchr 75 | // ^^ keyword.pikchr 76 | // ^^^^ constant.numeric.pikchr 77 | // ^ keyword.operator.arithmetic.pikchr 78 | // ^^ variable.language.pikchr 79 | // ^^^^^ keyword.pikchr 80 | // ^^^ variable.language.place.pikchr 81 | // ^ punctuation.separator.period.pikchr 82 | // ^ constant.language.pikchr 83 | box (thickness) 84 | // <--- storage.type.class.pikchr 85 | // ^ punctuation.parenthesis.begin.pikchr 86 | // ^^^^^^^^^ variable.language.pikchr 87 | // ^ source.pikchr punctuation.parenthesis.end.pikchr 88 | 89 | spline thin color gray <-> \ 90 | // <------ storage.type.class.pikchr 91 | // ^^^^ entity.name.tag.pikchr 92 | // ^^^^^ support.constant.property-value.pikchr 93 | // ^^^^ support.constant.color.w3c-standard-color-name.pikchr 94 | // ^^^ entity.name.tag.pikchr 95 | // ^ source.pikchr 96 | // ^ punctuation.separator.continuation.line.pikchr 97 | from d1+8mm heading 0 from C2 98 | // <---- keyword.pikchr 99 | // ^^ variable.language.pikchr 100 | // ^ keyword.operator.arithmetic.pikchr 101 | // ^^^ constant.numeric.pikchr 102 | // ^^ keyword.other.unit.mm.pikchr 103 | // ^^^^^^^ keyword.pikchr 104 | // ^ constant.numeric.pikchr 105 | // ^^^^ keyword.pikchr 106 | // ^^ variable.language.place.pikchr 107 | 108 | circle "C3" at dist(C2,C4) heading 30 from C2 109 | // ^^^^ entity.name.function.pikchr 110 | // ^^^^^^^ ^^^^ keyword.pikchr 111 | // ^^ ^^ ^^ variable.language.place.pikchr 112 | 113 | box height C3.y-C2.y \ 114 | // ^^^^^ support.constant.property-value.pikchr 115 | width (C5P.e.x-C0.w.x)+linewid \ 116 | // ^^^^^ support.constant.property-value.pikchr 117 | // ^ ^ ^ ^ constant.language.pikchr 118 | with .w at 0.5*linewid west of C0.w \ 119 | // ^^^^ keyword.pikchr 120 | // ^ ^ constant.language.pikchr 121 | behind C0 \ 122 | // ^^^^^^ keyword.pikchr 123 | fill 0xc6e2ff thin color gray 124 | // ^^^^ support.constant.property-value.pikchr 125 | 126 | text "radius" at (6/8,1/2 /*comment*/) 127 | // <---- storage.type.class.pikchr 128 | // ^^^^^^^^ string.quoted.double.pikchr 129 | // ^^ keyword.pikchr 130 | // ^ punctuation.parenthesis.begin.pikchr 131 | // ^ ^ ^ ^ constant.numeric.pikchr 132 | // ^ ^keyword.operator.arithmetic.pikchr 133 | // ^ ^ punctuation.bracket.angle.begin.pikchr 134 | // ^ ^ punctuation.bracket.angle.end.pikchr 135 | // ^ punctuation.parenthesis.end.pikchr 136 | // ^^ ^^ ^^ ^^ variable.language.place.pikchr 137 | // ^ ^ punctuation.separator.period.pikchr 138 | // ^ punctuation.separator.pikchr 139 | // ^^^^^^^^^^^ comment.block.pikchr 140 | 141 | "Pikchr source " rjust "code input " rjust at 2nd vertex /**/ of previous 142 | // <---------------- string.quoted.double.pikchr 143 | // ^^^^^^^^^^^^^ string.quoted.double.pikchr 144 | // ^^^^^ ^^^^^ ^^ ^^^^^^ ^^ keyword.pikchr 145 | // ^^ constant.language.pikchr 146 | // ^^^^ comment.block.pikchr 147 | // ^^^^^^^^ constant.language.pikchr 148 | 149 | diamond;One: [line ->] with .n at 1cm below previous 150 | // <------- storage.type.class.pikchr 151 | // ^^^^ storage.type.class.pikchr 152 | // ^ punctuation.separator.delimiter.end.pikchr 153 | // ^^^ variable.language.place.pikchr 154 | // ^ punctuation.separator.pikchr 155 | // ^ punctuation.bracket.square.begin.pikchr 156 | // ^^ entity.name.tag.pikchr 157 | // ^ punctuation.bracket.square.end.pikchr 158 | // ^^^^ ^^ ^^^^^ keyword.pikchr 159 | // ^ punctuation.separator.period.pikchr 160 | // ^ constant.language.pikchr 161 | // ^^^^^^^^ constant.language.pikchr 162 | LE2: box "expr" fit with .w at (4*arrowht+linerad east of LOP3.e,LIKE) 163 | // ^^^^ support.constant.edge.pikchr 164 | // ^^ keyword.control.of.pikchr 165 | -------------------------------------------------------------------------------- /src/test/suite/branchSuite.ts: -------------------------------------------------------------------------------- 1 | import { InputBox } from 'vscode'; 2 | import { window, commands } from 'vscode'; 3 | import * as sinon from 'sinon'; 4 | import { fakeExecutionResult, getExecStub } from './common'; 5 | import * as assert from 'assert/strict'; 6 | import { Suite } from 'mocha'; 7 | import { FossilBranch } from '../../openedRepository'; 8 | 9 | export function BranchSuite(this: Suite): void { 10 | test('Create public branch', async () => { 11 | const cib = this.ctx.sandbox.stub(window, 'createInputBox'); 12 | cib.onFirstCall().callsFake(() => { 13 | const inputBox: InputBox = cib.wrappedMethod(); 14 | const stub = sinon.stub(inputBox); 15 | stub.show.callsFake(() => { 16 | stub.value = 'hello branch'; 17 | const onDidAccept = stub.onDidAccept.getCall(0).args[0]; 18 | const onDidChangeValue = 19 | stub.onDidChangeValue.getCall(0).args[0]; 20 | const onDidTriggerButton = 21 | stub.onDidTriggerButton.getCall(0).args[0]; 22 | onDidTriggerButton(stub.buttons[1]); // private on 23 | onDidTriggerButton(stub.buttons[1]); // private off 24 | onDidChangeValue(stub.value); 25 | assert.equal(stub.validationMessage, ''); 26 | onDidAccept(); 27 | }); 28 | return stub; 29 | }); 30 | 31 | const creation = getExecStub(this.ctx.sandbox).withArgs([ 32 | 'branch', 33 | 'new', 34 | 'hello branch', 35 | 'current', 36 | ]); 37 | await commands.executeCommand('fossil.branch'); 38 | sinon.assert.calledOnce(creation); 39 | }); 40 | 41 | test('Branch already exists warning is shown', async () => { 42 | const cib = this.ctx.sandbox.stub(window, 'createInputBox'); 43 | cib.onFirstCall().callsFake(() => { 44 | const inputBox: InputBox = cib.wrappedMethod(); 45 | const stub = sinon.stub(inputBox); 46 | stub.show.callsFake(() => { 47 | stub.value = 'hello branch'; 48 | stub.onDidAccept.getCall(0).args[0](); 49 | }); 50 | return stub; 51 | }); 52 | 53 | const swm: sinon.SinonStub = this.ctx.sandbox 54 | .stub(window, 'showWarningMessage') 55 | .resolves(); 56 | 57 | const creation = getExecStub(this.ctx.sandbox).withArgs([ 58 | 'branch', 59 | 'new', 60 | 'hello branch', 61 | 'current', 62 | ]); 63 | await commands.executeCommand('fossil.branch'); 64 | sinon.assert.calledOnce(creation); 65 | sinon.assert.calledOnceWithExactly( 66 | swm, 67 | "Branch 'hello branch' already exists. Update or Re-open?", 68 | { 69 | modal: true, 70 | }, 71 | '&&Update', 72 | '&&Re-open' 73 | ); 74 | }).timeout(14500); 75 | 76 | test('Create private branch', async () => { 77 | const cib = this.ctx.sandbox.stub(window, 'createInputBox'); 78 | cib.onFirstCall().callsFake(() => { 79 | const inputBox: InputBox = cib.wrappedMethod(); 80 | const stub = sinon.stub(inputBox); 81 | stub.show.callsFake(() => { 82 | stub.value = 'hello branch'; 83 | const onDidAccept = stub.onDidAccept.getCall(0).args[0]; 84 | const onDidTriggerButton = 85 | stub.onDidTriggerButton.getCall(0).args[0]; 86 | onDidTriggerButton(stub.buttons[1]); // private on 87 | onDidAccept(); 88 | }); 89 | return stub; 90 | }); 91 | 92 | const execStub = getExecStub(this.ctx.sandbox); 93 | const creation = execStub 94 | .withArgs(['branch', 'new', 'hello branch', 'current', '--private']) 95 | .resolves(fakeExecutionResult()); 96 | await commands.executeCommand('fossil.branch'); 97 | sinon.assert.calledOnce(creation); 98 | }); 99 | 100 | test('Create branch with color', async () => { 101 | const cib = this.ctx.sandbox.stub(window, 'createInputBox'); 102 | cib.onFirstCall().callsFake(() => { 103 | const inputBox: InputBox = cib.wrappedMethod(); 104 | const stub = sinon.stub(inputBox); 105 | stub.show.callsFake(() => { 106 | const onDidAccept = stub.onDidAccept.getCall(0).args[0]; 107 | const onDidTriggerButton = 108 | stub.onDidTriggerButton.getCall(0).args[0]; 109 | onDidTriggerButton(stub.buttons[0]); 110 | stub.value = '#aabbcc'; 111 | onDidAccept(); 112 | stub.value = 'color branch'; 113 | onDidAccept(); 114 | }); 115 | return stub; 116 | }); 117 | const execStub = getExecStub(this.ctx.sandbox); 118 | const creation = execStub 119 | .withArgs([ 120 | 'branch', 121 | 'new', 122 | 'color branch', 123 | 'current', 124 | '--bgcolor', 125 | '#aabbcc', 126 | ]) 127 | .resolves(fakeExecutionResult()); 128 | await commands.executeCommand('fossil.branch'); 129 | sinon.assert.calledOnce(creation); 130 | }); 131 | 132 | test('Create branch canceled', async () => { 133 | const cib = this.ctx.sandbox.stub(window, 'createInputBox'); 134 | cib.onFirstCall().callsFake(() => { 135 | const inputBox: InputBox = cib.wrappedMethod(); 136 | const stub = sinon.stub(inputBox); 137 | stub.show.callsFake(() => { 138 | const onDidAccept = stub.onDidAccept.getCall(0).args[0]; 139 | stub.value = ''; 140 | onDidAccept(); 141 | }); 142 | return stub; 143 | }); 144 | await commands.executeCommand('fossil.branch'); 145 | sinon.assert.calledOnce(cib); 146 | }); 147 | 148 | test('Branch already exists - reopen branch', async () => { 149 | const cib = this.ctx.sandbox.stub(window, 'createInputBox'); 150 | cib.onFirstCall().callsFake(() => { 151 | const inputBox: InputBox = cib.wrappedMethod(); 152 | const stub = sinon.stub(inputBox); 153 | stub.show.callsFake(() => { 154 | const onDidAccept = stub.onDidAccept.getCall(0).args[0]; 155 | stub.value = 'trunk'; 156 | onDidAccept(); 157 | }); 158 | return stub; 159 | }); 160 | const swm: sinon.SinonStub = this.ctx.sandbox 161 | .stub(window, 'showWarningMessage') 162 | .resolves('&&Re-open' as any); 163 | 164 | const execStub = getExecStub(this.ctx.sandbox); 165 | const newBranchStub = execStub 166 | .withArgs(['branch', 'new', 'trunk', 'current']) 167 | .onSecondCall() 168 | .resolves(fakeExecutionResult()); 169 | 170 | await commands.executeCommand('fossil.branch'); 171 | sinon.assert.calledOnce(cib); 172 | sinon.assert.calledOnce(swm); 173 | sinon.assert.calledTwice(newBranchStub); 174 | }); 175 | 176 | test('Branch already exists - update to', async () => { 177 | const cib = this.ctx.sandbox.stub(window, 'createInputBox'); 178 | cib.onFirstCall().callsFake(() => { 179 | const inputBox: InputBox = cib.wrappedMethod(); 180 | const stub = sinon.stub(inputBox); 181 | stub.show.callsFake(() => { 182 | const onDidAccept = stub.onDidAccept.getCall(0).args[0]; 183 | stub.value = 'trunk'; 184 | onDidAccept(); 185 | }); 186 | return stub; 187 | }); 188 | const swm: sinon.SinonStub = this.ctx.sandbox 189 | .stub(window, 'showWarningMessage') 190 | .resolves('&&Update' as any); 191 | 192 | const execStub = getExecStub(this.ctx.sandbox); 193 | const newBranchStub = execStub.withArgs( 194 | sinon.match.array.startsWith(['branch', 'new', 'trunk', 'current']) 195 | ); 196 | const updateStub = execStub 197 | .withArgs(sinon.match.array.startsWith(['update', 'trunk'])) 198 | .resolves(fakeExecutionResult()); 199 | 200 | await commands.executeCommand('fossil.branch'); 201 | sinon.assert.calledOnce(cib); 202 | sinon.assert.calledOnce(swm); 203 | sinon.assert.calledOnce(newBranchStub); 204 | sinon.assert.calledOnceWithExactly( 205 | updateStub, 206 | ['update', 'trunk' as FossilBranch], 207 | undefined, 208 | { logErrors: true } 209 | ); 210 | }); 211 | } 212 | -------------------------------------------------------------------------------- /src/test/suite/renameSuite.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import { Uri, window, workspace, commands } from 'vscode'; 3 | import * as sinon from 'sinon'; 4 | import { 5 | add, 6 | assertGroups, 7 | cleanupFossil, 8 | fakeFossilStatus, 9 | getExecStub, 10 | getRepository, 11 | } from './common'; 12 | import * as assert from 'assert/strict'; 13 | import * as fs from 'fs/promises'; 14 | import { 15 | FossilCommitMessage, 16 | OpenedRepository, 17 | RelativePath, 18 | ResourceStatus, 19 | } from '../../openedRepository'; 20 | import { delay, eventToPromise } from '../../util'; 21 | import { Suite, before } from 'mocha'; 22 | import { Reason } from '../../fossilExecutable'; 23 | 24 | export function RenameSuite(this: Suite): void { 25 | const rootUri = this.ctx.workspaceUri; 26 | const config = () => workspace.getConfiguration('fossil'); 27 | 28 | before(async () => { 29 | await config().update('enableRenaming', true); 30 | await cleanupFossil(getRepository()); 31 | }); 32 | 33 | test('Rename a file', async () => { 34 | const oldFilename = 'not_renamed.txt'; 35 | const newFilename = 'renamed.txt'; 36 | await add(oldFilename, 'foo content\n', `add: ${oldFilename}`, 'ADDED'); 37 | 38 | const sim: sinon.SinonStub = this.ctx.sandbox.stub( 39 | window, 40 | 'showInformationMessage' 41 | ); 42 | const answeredYes = sim.onFirstCall().resolves('Yes'); 43 | 44 | const edit = new vscode.WorkspaceEdit(); 45 | const newFilePath = Uri.joinPath(rootUri, newFilename); 46 | edit.renameFile(Uri.joinPath(rootUri, oldFilename), newFilePath); 47 | 48 | const success = await workspace.applyEdit(edit); 49 | assert.ok(success); 50 | 51 | const repository = getRepository(); 52 | await answeredYes; 53 | await eventToPromise(repository.onDidRunOperation); 54 | await repository.updateStatus('Test' as Reason); 55 | 56 | assertGroups(repository, { 57 | working: [[newFilePath.fsPath, ResourceStatus.RENAMED]], 58 | }); 59 | await cleanupFossil(repository); 60 | }).timeout(6000); 61 | 62 | test("Don't show again", async () => { 63 | const repository = getRepository(); 64 | assertGroups(repository, {}, "Previous test didn't cleanup or failed"); 65 | 66 | assert.equal(config().get('enableRenaming'), true, 'contract'); 67 | const execStub = getExecStub(this.ctx.sandbox); 68 | const oldFilename = 'do_not_show.txt'; 69 | const oldUri = Uri.joinPath(rootUri, oldFilename); 70 | await fs.writeFile(oldUri.fsPath, '123'); 71 | const newFilename = 'test_failed.txt'; 72 | 73 | const edit = new vscode.WorkspaceEdit(); 74 | const newFilePath = Uri.joinPath(rootUri, newFilename); 75 | edit.renameFile(oldUri, newFilePath); 76 | 77 | const sim = ( 78 | this.ctx.sandbox.stub( 79 | window, 80 | 'showInformationMessage' 81 | ) as sinon.SinonStub 82 | ).resolves("Don't show again"); 83 | 84 | const status = fakeFossilStatus(execStub, `EDITED ${oldFilename}\n`); 85 | const success = await workspace.applyEdit(edit); 86 | assert.ok(success); 87 | sinon.assert.calledOnceWithExactly( 88 | status, 89 | ['status', '--differ', '--merge'], 90 | 'file rename event' as Reason 91 | ); 92 | 93 | // renaming triggers an async event, that is not awaited. await it. 94 | for (let i = 0; i < 200; ++i) { 95 | if (sim.callCount != 0) { 96 | break; 97 | } 98 | /* c8 ignore next 2 */ 99 | await delay(5); 100 | } 101 | sinon.assert.calledOnceWithExactly( 102 | sim, 103 | '"do_not_show.txt" was renamed to "test_failed.txt" on ' + 104 | 'filesystem. Rename in fossil repository too?', 105 | { 106 | modal: false, 107 | }, 108 | 'Yes', 109 | 'Cancel', 110 | "Don't show again" 111 | ); 112 | 113 | for (let i = 1; i < 200; ++i) { 114 | if (config().get('enableRenaming') === false) { 115 | break; 116 | } 117 | await delay(5); 118 | } 119 | assert.equal(config().get('enableRenaming'), false, 'no update'); 120 | await config().update('enableRenaming', true); 121 | assertGroups(repository, { 122 | working: [[oldUri.fsPath, ResourceStatus.MODIFIED]], 123 | }); 124 | execStub.restore(); 125 | await cleanupFossil(repository); 126 | }).timeout(3000); 127 | 128 | test('Rename directory', async () => { 129 | const repository = getRepository(); 130 | assertGroups(repository, {}, "Previous test didn't cleanup or failed"); 131 | 132 | const oldDirname = 'not_renamed'; 133 | const newDirname = 'renamed'; 134 | const oldDirUrl = Uri.joinPath(rootUri, oldDirname); 135 | const newDirUrl = Uri.joinPath(rootUri, newDirname); 136 | await fs.mkdir(oldDirUrl.fsPath); 137 | const filenames = ['mud', 'cabbage', 'brick']; 138 | const oldUris = filenames.map(filename => 139 | Uri.joinPath(oldDirUrl, filename) 140 | ); 141 | const newUris = filenames.map(filename => 142 | Uri.joinPath(newDirUrl, filename) 143 | ); 144 | 145 | await Promise.all( 146 | oldUris.map(uri => fs.writeFile(uri.fsPath, `foo ${uri}\n`)) 147 | ); 148 | const openedRepository: OpenedRepository = (repository as any) 149 | .repository; 150 | await openedRepository.exec(['add', '--', oldDirname as RelativePath]); 151 | await openedRepository.exec([ 152 | 'commit', 153 | '-m', 154 | `add directory: ${oldDirname}` as FossilCommitMessage, 155 | '--no-warnings', 156 | ]); 157 | 158 | const sim: sinon.SinonStub = this.ctx.sandbox.stub( 159 | window, 160 | 'showInformationMessage' 161 | ); 162 | 163 | const answeredYes = sim.onFirstCall().resolves('Yes'); 164 | 165 | const edit = new vscode.WorkspaceEdit(); 166 | edit.renameFile(oldDirUrl, newDirUrl); 167 | 168 | const success = await workspace.applyEdit(edit); 169 | assert.ok(success); 170 | 171 | await answeredYes; 172 | await eventToPromise(repository.onDidRunOperation); 173 | await repository.updateStatus('Test' as Reason); 174 | 175 | assertGroups(repository, { 176 | working: newUris.map((url: Uri) => [ 177 | url.fsPath, 178 | ResourceStatus.RENAMED, 179 | ]), 180 | }); 181 | await cleanupFossil(repository); 182 | }).timeout(10000); 183 | 184 | test('Relocate', async () => { 185 | const repository = getRepository(); 186 | assertGroups(repository, {}, "Previous test didn't cleanup or failed"); 187 | const oldFilename = 'not_relocated.txt'; 188 | const newFilename = 'relocated.txt'; 189 | const newUri = Uri.joinPath(rootUri, newFilename); 190 | const oldUri = await add( 191 | oldFilename, 192 | 'foo content\n', 193 | `add: ${oldFilename}`, 194 | 'ADDED' 195 | ); 196 | await fs.rename(oldUri.fsPath, newUri.fsPath); 197 | await repository.updateStatus('Test' as Reason); 198 | assertGroups(repository, { 199 | working: [[oldUri.fsPath, ResourceStatus.MISSING]], 200 | untracked: [[newUri.fsPath, ResourceStatus.EXTRA]], 201 | }); 202 | this.ctx.sandbox 203 | .stub(window, 'showQuickPick') 204 | .onFirstCall() 205 | .callsFake(items => { 206 | assert.ok(items instanceof Array); 207 | assert.equal(items.length, 3); 208 | assert.equal(items[0].label, '$(folder-opened) Open Dialog'); 209 | assert.equal(items[1].label, 'Extras'); 210 | assert.equal(items[2].label, '$(symbol-file) relocated.txt'); 211 | return Promise.resolve(items[0]); 212 | }); 213 | 214 | const sod = this.ctx.sandbox 215 | .stub(window, 'showOpenDialog') 216 | .resolves([newUri]); 217 | await commands.executeCommand( 218 | 'fossil.relocate', 219 | repository.workingGroup.resourceStates[0] 220 | ); 221 | sinon.assert.calledOnce(sod); 222 | assertGroups(repository, { 223 | working: [[newUri.fsPath, ResourceStatus.RENAMED]], 224 | }); 225 | }).timeout(10000); 226 | 227 | test('Relocate nothing', async () => { 228 | await commands.executeCommand('fossil.relocate'); 229 | }); 230 | } 231 | -------------------------------------------------------------------------------- /src/test/suite/utilitiesSuite.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import { Uri } from 'vscode'; 3 | import * as sinon from 'sinon'; 4 | import * as assert from 'assert/strict'; 5 | import * as fs from 'fs'; 6 | import { 7 | assertGroups, 8 | getExecStub, 9 | getExecutable, 10 | getRepository, 11 | } from './common'; 12 | import { Suite, afterEach, beforeEach } from 'mocha'; 13 | import { debounce, memoize, sequentialize, throttle } from '../../decorators'; 14 | import { delay } from '../../util'; 15 | import { DocumentFsPath, Reason } from '../../fossilExecutable'; 16 | import { ResourceStatus } from '../../openedRepository'; 17 | 18 | function undoSuite(this: Suite) { 19 | test('Undo and redo warning', async () => { 20 | const showWarningMessage: sinon.SinonStub = this.ctx.sandbox.stub( 21 | vscode.window, 22 | 'showWarningMessage' 23 | ); 24 | await vscode.commands.executeCommand('fossil.undo'); 25 | sinon.assert.calledOnce(showWarningMessage); 26 | sinon.assert.calledWithExactly( 27 | showWarningMessage.firstCall, 28 | 'Nothing to undo.' 29 | ); 30 | await vscode.commands.executeCommand('fossil.redo'); 31 | sinon.assert.calledTwice(showWarningMessage); 32 | sinon.assert.calledWithExactly( 33 | showWarningMessage.secondCall, 34 | 'Nothing to redo.' 35 | ); 36 | }).timeout(2000); 37 | test('Undo and redo working', async () => { 38 | const showWarningMessage: sinon.SinonStub = this.ctx.sandbox.stub( 39 | vscode.window, 40 | 'showWarningMessage' 41 | ); 42 | 43 | const undoTxtPath = Uri.joinPath( 44 | this.ctx.workspaceUri, 45 | 'undo-fuarw.txt' 46 | ).fsPath as DocumentFsPath; 47 | await fs.promises.writeFile(undoTxtPath, 'line\n'); 48 | 49 | const repository = getRepository(); 50 | const execStub = getExecStub(this.ctx.sandbox); 51 | 52 | await repository.updateStatus('Test' as Reason); 53 | assertGroups(repository, { 54 | untracked: [[undoTxtPath, ResourceStatus.EXTRA]], 55 | }); 56 | 57 | showWarningMessage.onFirstCall().resolves('&&Delete file'); 58 | 59 | assert.ok(fs.existsSync(undoTxtPath)); 60 | await vscode.commands.executeCommand( 61 | 'fossil.deleteFiles', 62 | repository.untrackedGroup 63 | ); 64 | sinon.assert.calledOnceWithExactly( 65 | showWarningMessage, 66 | 'Are you sure you want to DELETE undo-fuarw.txt?\n' + 67 | 'This is IRREVERSIBLE!\n' + 68 | 'This file will be FOREVER LOST if you proceed.', 69 | { modal: true }, 70 | '&&Delete file' 71 | ); 72 | sinon.assert.calledWithExactly(execStub, ['clean', undoTxtPath]); 73 | assert.ok(!fs.existsSync(undoTxtPath)); 74 | 75 | const showInformationMessage: sinon.SinonStub = this.ctx.sandbox.stub( 76 | vscode.window, 77 | 'showInformationMessage' 78 | ); 79 | 80 | showInformationMessage.onFirstCall().resolves('Undo'); 81 | await vscode.commands.executeCommand('fossil.undo'); 82 | assert.ok(fs.existsSync(undoTxtPath)); 83 | const executable = getExecutable(); 84 | const fossilPath = (executable as any).fossilPath as string; 85 | assert.equal( 86 | showInformationMessage.firstCall.args[0], 87 | `Undo '${fossilPath} clean ${undoTxtPath}'?` 88 | ); 89 | 90 | showInformationMessage.onSecondCall().resolves('Redo'); 91 | await vscode.commands.executeCommand('fossil.redo'); 92 | assert.ok(!fs.existsSync(undoTxtPath)); 93 | assert.equal( 94 | showInformationMessage.secondCall.args[0], 95 | `Redo '${fossilPath} clean ${undoTxtPath}'?` 96 | ); 97 | }).timeout(7000); 98 | } 99 | 100 | function decoratorsSuite(this: Suite) { 101 | let fakeTimers: sinon.SinonFakeTimers; 102 | const startTimeStamp = new Date('2024-03-13T00:00:00Z').getTime(); 103 | 104 | beforeEach(() => { 105 | fakeTimers = sinon.useFakeTimers(startTimeStamp); 106 | }); 107 | afterEach(() => { 108 | // assert.equal(fakeTimers.countTimers(), 0, 'All timers must run'); 109 | fakeTimers.restore(); 110 | }); 111 | 112 | test('Memoize', async () => { 113 | class MemoizeTest { 114 | constructor( 115 | public memoize_count_a = 0, 116 | public memoize_count_b = 0 117 | ) {} 118 | @memoize 119 | public get memoized_property_a(): Uri { 120 | ++this.memoize_count_a; 121 | return Uri.file('memoize_a.txt'); 122 | } 123 | @memoize 124 | public get memoized_property_b(): Uri { 125 | ++this.memoize_count_b; 126 | return Uri.file('memoize_b.txt'); 127 | } 128 | public get counts(): [number, number] { 129 | return [this.memoize_count_a, this.memoize_count_b]; 130 | } 131 | } 132 | const dt = new MemoizeTest(); 133 | assert.equal(dt.memoized_property_a.fsPath, '/memoize_a.txt'); 134 | assert.deepStrictEqual(dt.counts, [1, 0]); 135 | assert.equal(dt.memoized_property_a.fsPath, '/memoize_a.txt'); 136 | assert.deepStrictEqual(dt.counts, [1, 0]); 137 | assert.equal(dt.memoized_property_b.fsPath, '/memoize_b.txt'); 138 | assert.deepStrictEqual(dt.counts, [1, 1]); 139 | assert.equal(dt.memoized_property_b.fsPath, '/memoize_b.txt'); 140 | assert.deepStrictEqual(dt.counts, [1, 1]); 141 | assert.equal(dt.memoized_property_a.fsPath, '/memoize_a.txt'); 142 | assert.deepStrictEqual(dt.counts, [1, 1]); 143 | }); 144 | test('Throttle', async () => { 145 | class ThrottledTest { 146 | constructor(public throttle_count = 0) {} 147 | @throttle 148 | async throttled_method(key: string): Promise { 149 | await delay(25); 150 | return `${key}-${this.throttle_count++}`; 151 | } 152 | } 153 | const dt = new ThrottledTest(); 154 | assert.equal(fakeTimers.countTimers(), 0); 155 | const p0 = dt.throttled_method('a'); 156 | const p1 = dt.throttled_method('b'); 157 | const p2 = dt.throttled_method('c'); 158 | assert.equal(fakeTimers.countTimers(), 1); 159 | const resPromise = Promise.all([p0, p1, p2]); 160 | await fakeTimers.runAllAsync(); 161 | assert.equal(fakeTimers.countTimers(), 0); 162 | assert.deepStrictEqual(await resPromise, ['a-0', 'b-1', 'b-1']); 163 | assert.equal(fakeTimers.countTimers(), 0); 164 | assert.equal(dt.throttle_count, 2); 165 | }); 166 | test('Sequentialize', async () => { 167 | class SequentializeTest { 168 | constructor(public sequentialize_count = 0) {} 169 | @sequentialize 170 | async sequentialized_method( 171 | ms: number, 172 | key: string 173 | ): Promise { 174 | await delay(ms); 175 | return `${key}-${this.sequentialize_count++}`; 176 | } 177 | } 178 | 179 | const dt = new SequentializeTest(); 180 | const p0 = dt.sequentialized_method(50, 'a'); 181 | const p1 = dt.sequentialized_method(20, 'b'); 182 | const p2 = dt.sequentialized_method(10, 'c'); 183 | const resPromise = Promise.race([p0, p1, p2]); 184 | assert.equal(fakeTimers.countTimers(), 0); 185 | await fakeTimers.runAllAsync(); 186 | assert.deepStrictEqual(await resPromise, 'a-0'); 187 | assert.equal(fakeTimers.countTimers(), 0); 188 | assert.equal(dt.sequentialize_count, 3); 189 | assert.deepStrictEqual(await p1, 'b-1'); 190 | assert.deepStrictEqual(await p2, 'c-2'); 191 | }); 192 | test('Debounce', async () => { 193 | class DebounceTest { 194 | constructor(public debounce_count = 0) {} 195 | @debounce(50) 196 | debounced_method(): void { 197 | this.debounce_count++; 198 | } 199 | } 200 | 201 | const dt = new DebounceTest(); 202 | assert.equal(fakeTimers.countTimers(), 0); 203 | const p0 = dt.debounced_method(); 204 | assert.equal(fakeTimers.countTimers(), 1); 205 | const p1 = dt.debounced_method(); 206 | const p2 = dt.debounced_method(); 207 | const resPromise = Promise.all([p0, p1, p2]); 208 | assert.equal(fakeTimers.countTimers(), 1); 209 | const debounceTimer = fakeTimers.next(); 210 | assert.deepEqual(await resPromise, [undefined, undefined, undefined]); 211 | assert.equal(fakeTimers.countTimers(), 0); 212 | assert.equal(debounceTimer, startTimeStamp + 50, 'main timer worked'); 213 | assert.equal(dt.debounce_count, 1, 'reached main timer only'); 214 | }); 215 | } 216 | 217 | export function utilitiesSuite(this: Suite): void { 218 | suite('Undo', undoSuite.bind(this)); 219 | suite('Decorators', decoratorsSuite.bind(this)); 220 | test('Show output', async () => { 221 | await vscode.commands.executeCommand('fossil.showOutput'); 222 | // currently there is no way to validate fossil.showOutput 223 | }); 224 | test('Open UI', async () => { 225 | const sendText = sinon.stub(); 226 | const terminal = { 227 | sendText: sendText as unknown, 228 | } as vscode.Terminal; 229 | const createTerminalStub = this.ctx.sandbox 230 | .stub(vscode.window, 'createTerminal') 231 | .returns(terminal); 232 | await vscode.commands.executeCommand('fossil.openUI'); 233 | sinon.assert.calledOnce(createTerminalStub); 234 | sinon.assert.calledOnceWithExactly(sendText, 'fossil ui'); 235 | }); 236 | test('Commit input box knows which repository to use', () => { 237 | const repository = getRepository(); 238 | assert.deepStrictEqual(repository.sourceControl.acceptInputCommand, { 239 | command: 'fossil.commitWithInput', 240 | title: 'Commit', 241 | arguments: [repository], 242 | }); 243 | }); 244 | } 245 | --------------------------------------------------------------------------------