├── .circleci ├── CHANGELOG.md └── config.yml ├── .gitignore ├── .nvmrc ├── .vscode ├── extensions.json ├── launch.json ├── settings.json └── tasks.json ├── .vscodeignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── docs └── images │ └── nested-tags-intro.gif ├── images ├── icon.png ├── nested-tags-logo.png └── nested-tags-logo.psd ├── jest.config.js ├── package-lock.json ├── package.json ├── src ├── extension.ts ├── sets.ts ├── tag-tree-data-provider.ts ├── tag-tree-view.ts └── tag-tree │ ├── __snapshots__ │ └── tag-tree.test.ts.snap │ ├── file-node.test.ts │ ├── file-node.ts │ ├── tag-node.test.ts │ ├── tag-node.ts │ ├── tag-tree.test.ts │ ├── tag-tree.ts │ └── test-helpers.ts ├── tsconfig.json └── tslint.json /.circleci/CHANGELOG.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/blakedietz/vscode-nested-tags/1ba6ff74351553972a16195b2e59ec20d6ea883c/.circleci/CHANGELOG.md -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | jobs: 3 | build-deploy-release-branches: 4 | docker: 5 | - image: circleci/node:12.13.0 6 | working_directory: ~/repo 7 | 8 | steps: 9 | - checkout 10 | - run: 11 | name: Install node.js Dependencies with npm 12 | command: npm i 13 | - run: 14 | name: Run unit tests 15 | command: npm run test 16 | - run: 17 | name: Run semantic-release to generate new git tags and next version 18 | command: npm run semantic-release 19 | 20 | build-deploy-feature-branch: 21 | docker: 22 | - image: circleci/node:12.13.0 23 | working_directory: ~/repo 24 | steps: 25 | - checkout 26 | - run: 27 | name: Install node.js Dependencies with npm 28 | command: npm i 29 | - save_cache: 30 | key: project-name-{{ .Branch }}-{{ checksum "package.json" }} 31 | paths: 32 | - node_modules 33 | - run: 34 | name: Run unit tests 35 | command: npm run test 36 | - run: 37 | name: Build the source 38 | command: npm run build 39 | 40 | workflows: 41 | version: 2 42 | build-release-branches: 43 | jobs: 44 | - build-deploy-release-branches: 45 | filters: 46 | branches: 47 | only: 48 | - master 49 | - beta 50 | - alpha 51 | - next 52 | 53 | build-feature-branch: 54 | jobs: 55 | - build-deploy-feature-branch: 56 | filters: 57 | branches: 58 | only: 59 | - /build.*/ 60 | - /chore.*/ 61 | - /ci.*/ 62 | - /develop.*/ 63 | - /docs.*/ 64 | - /experiment.*/ 65 | - /feature.*/ 66 | - /fix.*/ 67 | - /perf.*/ 68 | - /test.*/ 69 | - /style.*/ 70 | - /refactor.*/ 71 | - /release.*/ 72 | - /revert.*/ 73 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | out 2 | node_modules 3 | .vscode-test/ 4 | *.vsix 5 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 12.13.0 2 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | // See http://go.microsoft.com/fwlink/?LinkId=827846 3 | // for the documentation about the extensions.json format 4 | "recommendations": [ 5 | "eg2.tslint" 6 | ] 7 | } -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | // A launch configuration that compiles the extension and then opens it inside a new window 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | { 6 | "version": "0.2.0", 7 | "configurations": [ 8 | { 9 | "type": "node", 10 | "name": "vscode-jest-tests", 11 | "request": "launch", 12 | "program": "${workspaceFolder}/node_modules/jest/bin/jest", 13 | "args": [ 14 | "--runInBand" 15 | ], 16 | "cwd": "${workspaceFolder}", 17 | "console": "integratedTerminal", 18 | "internalConsoleOptions": "neverOpen" 19 | }, 20 | { 21 | "name": "Run Extension", 22 | "type": "extensionHost", 23 | "request": "launch", 24 | "runtimeExecutable": "${execPath}", 25 | "args": [ 26 | "--extensionDevelopmentPath=${workspaceFolder}" 27 | ], 28 | "outFiles": [ 29 | "${workspaceFolder}/out/**/*.js" 30 | ], 31 | "preLaunchTask": "watch" 32 | } 33 | ] 34 | } 35 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | // Place your settings in this file to overwrite default and user settings. 2 | { 3 | "files.exclude": { 4 | "out": false // set this to true to hide the "out" folder with the compiled JS files 5 | }, 6 | "search.exclude": { 7 | "out": true // set this to false to include "out" folder in search results 8 | }, 9 | // Turn off tsc task auto detection since we have the necessary tasks as npm scripts 10 | "typescript.tsc.autoDetect": "off", 11 | "standard.enable": false 12 | } -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | // See https://go.microsoft.com/fwlink/?LinkId=733558 2 | // for the documentation about the tasks.json format 3 | { 4 | "version": "2.0.0", 5 | "tasks": [ 6 | { 7 | "type": "npm", 8 | "script": "watch", 9 | "problemMatcher": "$tsc-watch", 10 | "isBackground": true, 11 | "presentation": { 12 | "reveal": "never" 13 | }, 14 | "group": { 15 | "kind": "build", 16 | "isDefault": true 17 | } 18 | }, 19 | { 20 | "type": "npm", 21 | "script": "build:watch", 22 | "problemMatcher": [] 23 | } 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /.vscodeignore: -------------------------------------------------------------------------------- 1 | .vscode/** 2 | .vscode-test/** 3 | out/test/** 4 | src/** 5 | .gitignore 6 | vsc-extension-quickstart.md 7 | **/tsconfig.json 8 | **/tslint.json 9 | **/*.map 10 | **/*.ts 11 | **/*.psd -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # [1.5.0](https://github.com/blakedietz/vscode-nested-tags/compare/v1.4.0...v1.5.0) (2019-04-23) 2 | 3 | 4 | ### Features 5 | 6 | * **docs:** Add changelog support ([a6f1de4](https://github.com/blakedietz/vscode-nested-tags/commit/a6f1de4)) 7 | 8 | ## [1.4.0](https://github.com/blakedietz/vscode-nested-tags/compare/v1.3.0...v1.4.0) (2019-04-23) 9 | 10 | ### Features 11 | 12 | - **tag tree:** Add relative file path ([#43](https://github.com/blakedietz/vscode-nested-tags/issues/43)) ([811fbe7](https://github.com/blakedietz/vscode-nested-tags/commit/811fbe7)) 13 | 14 | ## [1.3.0](https://github.com/blakedietz/vscode-nested-tags/compare/v1.2.2...v1.3.0) (2019-04-22) 15 | 16 | ### Features 17 | 18 | - **yaml:** Ref 615e932af45ff4ef73b9810d0f4bdf80fb567167 ([a5de2c3](https://github.com/blakedietz/vscode-nested-tags/commit/a5de2c3)) 19 | 20 | ## [1.2.2](https://github.com/blakedietz/vscode-nested-tags/compare/v1.2.1...v1.2.2) (2019-04-01) 21 | 22 | ### Bug Fixes 23 | 24 | - **last build:** Fixes build ([7b2188f](https://github.com/blakedietz/vscode-nested-tags/commit/7b2188f)) 25 | 26 | ## [1.2.1](https://github.com/blakedietz/vscode-nested-tags/compare/v1.2.0...v1.2.1) (2019-03-28) 27 | 28 | ### Bug Fixes 29 | 30 | - **tag-tree:** Fix removal of parent nodes ([#39](https://github.com/blakedietz/vscode-nested-tags/issues/39)) ([#40](https://github.com/blakedietz/vscode-nested-tags/issues/40)) ([7949982](https://github.com/blakedietz/vscode-nested-tags/commit/7949982)), closes [#37](https://github.com/blakedietz/vscode-nested-tags/issues/37) 31 | 32 | ## [1.2.0](https://github.com/blakedietz/vscode-nested-tags/releases/tag/v1.2.0) (2019-03-15) 33 | 34 | ### Features 35 | 36 | - **File node**: Add support for opening file from file node (#36) 37 | - **Tag tree**: Sort the tag tree by tag and file (#21) 38 | 39 | ### Documentation 40 | 41 | - **vscode logo:** Adds logo for extension (#20) 42 | 43 | ### Build 44 | 45 | - **config.yml:** Add release branch 46 | 47 | ## [1.1.1](https://github.com/blakedietz/vscode-nested-tags/releases/tag/v1.1.1) (2019-02-14) 48 | 49 | ### Bug fixes 50 | 51 | - **File tree:** Only allow markdown files in the tree (#27) 52 | - **Performance:** Scan files asynchronously (#24) 53 | 54 | ## [1.1.0](https://github.com/blakedietz/vscode-nested-tags/compare/v1.0.2...v1.1.0) (2019-02-09) 55 | 56 | ### Bug Fixes 57 | 58 | - **icon.png:** Renames nested-tags-logo.png to icon.png ([#23](https://github.com/blakedietz/vscode-nested-tags/issues/23)) ([0a2182c](https://github.com/blakedietz/vscode-nested-tags/commit/0a2182c)) 59 | 60 | ### Features 61 | 62 | - **Sorting:** Sort the tag tree by tag and file ([#22](https://github.com/blakedietz/vscode-nested-tags/issues/22)) ([70eb1ec](https://github.com/blakedietz/vscode-nested-tags/commit/70eb1ec)), closes [#20](https://github.com/blakedietz/vscode-nested-tags/issues/20) [#21](https://github.com/blakedietz/vscode-nested-tags/issues/21) [#11](https://github.com/blakedietz/vscode-nested-tags/issues/11) 63 | 64 | ## [1.0.2](https://github.com/blakedietz/vscode-nested-tags/compare/v1.0.1...v1.0.2) (2019-02-06) 65 | 66 | ### Bug Fixes 67 | 68 | - **performance:** Debounce on document change ([97df61c](https://github.com/blakedietz/vscode-nested-tags/commit/97df61c)) 69 | 70 | ## [1.0.1](https://github.com/blakedietz/vscode-nested-tags/compare/v1.0.0...v1.0.1) (2019-02-05) 71 | 72 | ### Bug Fixes 73 | 74 | - **package.json:** Add onView activationEvent ([429bbc9](https://github.com/blakedietz/vscode-nested-tags/commit/429bbc9)) 75 | 76 | ## 1.0.0 (2019-02-05) 77 | 78 | ### Bug Fixes 79 | 80 | - **release:** Fix release build ([e64c1d4](https://github.com/blakedietz/vscode-nested-tags/commit/e64c1d4)) 81 | 82 | ### Features 83 | 84 | - **add package support:** Publish major version to marketplace ([f644887](https://github.com/blakedietz/vscode-nested-tags/commit/f644887)) 85 | - **big bang:** Start of the project ([576efa9](https://github.com/blakedietz/vscode-nested-tags/commit/576efa9)) 86 | - **file change support:** Add support for file change events ([664b716](https://github.com/blakedietz/vscode-nested-tags/commit/664b716)) 87 | - **modified file change:** Update treeview on δ ([187f071](https://github.com/blakedietz/vscode-nested-tags/commit/187f071)) 88 | - **project file support:** Read files ([a7ce181](https://github.com/blakedietz/vscode-nested-tags/commit/a7ce181)) 89 | - **tag-tree-data-provider:** Add FileSystemProvider support ([9e8dcc7](https://github.com/blakedietz/vscode-nested-tags/commit/9e8dcc7)) 90 | - **TagTree:** Add first implementation of a tag tree ([50fe369](https://github.com/blakedietz/vscode-nested-tags/commit/50fe369)) 91 | - **tree view:** Add vscode tree view for tags ([65e4214](https://github.com/blakedietz/vscode-nested-tags/commit/65e4214)) 92 | - **tree view:** Adds support for working tree view example ([c0f8372](https://github.com/blakedietz/vscode-nested-tags/commit/c0f8372)) 93 | - **trie:** Adds trie ([08db995](https://github.com/blakedietz/vscode-nested-tags/commit/08db995)) 94 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2020 Blake Dietz 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # vscode-nested-tags 2 | 3 | View your notes without being limited by your file system's hierarchy. 4 | 5 | ## Features 6 | 7 | ![](./docs/images/nested-tags-intro.gif) 8 | 9 | Markdown files with the directive 10 | 11 | ```markdown 12 | 13 | ``` 14 | 15 | or yaml frontmatter with a tags property (square brackets style) 16 | 17 | ```yaml 18 | --- 19 | title: Hello nested tags 20 | tags: [topic, here/is/a/nested/example] 21 | --- 22 | 23 | ``` 24 | 25 | or yaml frontmatter with a tags property (unordered list style) 26 | 27 | ```yaml 28 | --- 29 | title: Hello nested tags 30 | tags: 31 | - topic 32 | - here/is/a/nested/example 33 | --- 34 | 35 | ``` 36 | 37 | will be visible from the file tab under a "Tag Tree" view. 38 | 39 | ## Extension Settings 40 | 41 | ### Configurations 42 | 43 | | Name | Type | Description | 44 | | ---------------------------------------- | ------------- | --------------------------------------------- | 45 | | `vscode-nested-tags.additionalFileTypes` | Array | Additional file types to introspect for tags. | 46 | 47 | ### Custom file extensions 48 | 49 | You can define custom file extensions in your `settings.json`. These file extensions allow the plugin to look at more than just markdown files for the tag system. 50 | 51 | Here's an example `settings.json` file. 52 | 53 | ```json 54 | { 55 | "vscode-nested-tags.additionalFileTypes": ["tex", "html"] 56 | } 57 | ``` 58 | 59 | Now all `.tex` and `.html` files till be watched alongside `.md` files. 60 | 61 | ## Requirements 62 | 63 | ### Operating system 64 | 65 | This extension has only been tested on macOS. 66 | 67 | ### Code 68 | 69 | vs code 1.30.0 is required at a minimum. 70 | 71 | ## Support 72 | 73 | Help support the project by sending some BTC to the following crypto wallets 74 | 75 | ### BTC 76 | 77 | 39DUAVda1zkiszw4KrPSHwdmT4pNDUrWfE 78 | 79 | ### ETH 80 | 81 | 0x30D2Cba30E62DfD64E945F821cF0d27B437AdBa5 82 | -------------------------------------------------------------------------------- /docs/images/nested-tags-intro.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/blakedietz/vscode-nested-tags/1ba6ff74351553972a16195b2e59ec20d6ea883c/docs/images/nested-tags-intro.gif -------------------------------------------------------------------------------- /images/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/blakedietz/vscode-nested-tags/1ba6ff74351553972a16195b2e59ec20d6ea883c/images/icon.png -------------------------------------------------------------------------------- /images/nested-tags-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/blakedietz/vscode-nested-tags/1ba6ff74351553972a16195b2e59ec20d6ea883c/images/nested-tags-logo.png -------------------------------------------------------------------------------- /images/nested-tags-logo.psd: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/blakedietz/vscode-nested-tags/1ba6ff74351553972a16195b2e59ec20d6ea883c/images/nested-tags-logo.psd -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "roots": [ 3 | "/src" 4 | ], 5 | "transform": { 6 | "^.+\\.tsx?$": "ts-jest" 7 | }, 8 | "testRegex": "(/__tests__/.*|(\\.|/)(test|spec))\\.tsx?$", 9 | "moduleFileExtensions": [ 10 | "ts", 11 | "js", 12 | "json", 13 | "node" 14 | ], 15 | }; 16 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vscode-nested-tags", 3 | "displayName": "Nested Tags", 4 | "description": "", 5 | "version": "0.0.0", 6 | "engines": { 7 | "vscode": "^1.30.0" 8 | }, 9 | "repository": { 10 | "url": "https://github.com/blakedietz/vscode-nested-tags" 11 | }, 12 | "main": "./out/extension.js", 13 | "scripts": { 14 | "build": "tsc -build tsconfig.json", 15 | "build:watch": "tsc -build tsconfig.json -w", 16 | "commit": "git-cz", 17 | "compile": "tsc -p ./", 18 | "docs": "./node_modules/.bin/typedoc --tsconfig tsconfig.json --out ./docs", 19 | "lint": "tslint -p tsconfig.json", 20 | "postinstall": "node ./node_modules/vscode/bin/install", 21 | "prettify": "prettier --write \"src/**/*{.ts,.md}\"", 22 | "semantic-release": "semantic-release", 23 | "test": "jest --passWithNoTests", 24 | "test:watch": "jest --watch --passWithNoTests", 25 | "test:update-snapshot":"jest --updateSnapshot", 26 | "vscode:prepublish": "npm run build", 27 | "watch": "npm run build:watch" 28 | }, 29 | "devDependencies": { 30 | "@semantic-release/changelog": "^3.0.2", 31 | "@semantic-release/commit-analyzer": "^6.1.0", 32 | "@semantic-release/exec": "^3.3.2", 33 | "@semantic-release/git": "^7.0.8", 34 | "@semantic-release/github": "^5.2.9", 35 | "@semantic-release/npm": "^5.1.4", 36 | "@types/debounce": "^1.2.0", 37 | "@types/jest": "^23.3.12", 38 | "@types/node": "^10.12.18", 39 | "@types/recursive-readdir": "2.2.0", 40 | "commitizen": "^3.0.5", 41 | "cz-conventional-changelog": "^2.1.0", 42 | "husky": "^1.3.1", 43 | "jest": "^23.6.0", 44 | "lint-staged": "^8.1.0", 45 | "prettier": "^1.15.3", 46 | "semantic-release": "^16.0.0-beta.39", 47 | "semantic-release-vsce": "2.1.2", 48 | "ts-jest": "^23.10.5", 49 | "tslint": "^5.12.1", 50 | "tslint-config-prettier": "^1.17.0", 51 | "typedoc": "^0.15.3", 52 | "typescript": "^3.2.2", 53 | "vscode": "^1.1.25" 54 | }, 55 | "dependencies": { 56 | "debounce": "^1.2.0", 57 | "gray-matter": "4.0.2" 58 | }, 59 | "activationEvents": [ 60 | "onView:tagTreeView" 61 | ], 62 | "contributes": { 63 | "views": { 64 | "explorer": [ 65 | { 66 | "id": "tagTreeView", 67 | "name": "Tag Tree" 68 | } 69 | ] 70 | }, 71 | "configuration": { 72 | "type": "object", 73 | "title": "nested-tags", 74 | "properties": { 75 | "vscode-nested-tags.additionalFileTypes": { 76 | "type": "array", 77 | "default": [], 78 | "description": "Add file types that you want nested tags to scan for @nested-tags annotations." 79 | } 80 | } 81 | } 82 | }, 83 | "icon": "images/icon.png", 84 | "galleryBanner": { 85 | "color": "#073642", 86 | "theme": "dark" 87 | }, 88 | "publisher": "vscode-nested-tags", 89 | "config": { 90 | "loglevel": "verbose", 91 | "commitizen": { 92 | "path": "node_modules/cz-conventional-changelog" 93 | } 94 | }, 95 | "husky": { 96 | "hooks": { 97 | "pre-commit": "npm run lint && npm run build && npm run test && lint-staged", 98 | "pre-push": "npm run test" 99 | } 100 | }, 101 | "lint-staged": { 102 | "*.{ts}": [ 103 | "prettier --write", 104 | "git add" 105 | ] 106 | }, 107 | "release": { 108 | "plugins": [ 109 | [ 110 | "@semantic-release/release-notes-generator", 111 | { 112 | "preset": "angular", 113 | "parserOpts": { 114 | "noteKeywords": [ 115 | "BREAKING CHANGE", 116 | "BREAKING CHANGES", 117 | "BREAKING" 118 | ] 119 | }, 120 | "writerOpts": { 121 | "commitsSort": [ 122 | "subject", 123 | "scope" 124 | ] 125 | } 126 | } 127 | ], 128 | "@semantic-release/github", 129 | [ 130 | "semantic-release-vsce", 131 | { 132 | "path": "@semantic-release/github", 133 | "assets": "vscode-nested-tags.vsix" 134 | } 135 | ] 136 | ] 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /src/extension.ts: -------------------------------------------------------------------------------- 1 | // The module 'vscode' contains the VS Code extensibility API 2 | // Import the module and reference it with the alias vscode in your code below 3 | import * as vscode from "vscode"; 4 | import { TagTreeView } from "./tag-tree-view"; 5 | 6 | // this method is called when your extension is activated 7 | // your extension is activated the very first time the command is executed 8 | export function activate(context: vscode.ExtensionContext) { 9 | new TagTreeView(context); 10 | } 11 | 12 | // this method is called when your extension is deactivated 13 | export function deactivate() {} 14 | -------------------------------------------------------------------------------- /src/sets.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 3 | * @param aS The set of 4 | * @param bS 5 | */ 6 | export function setsAreEqual(aS: Set, bS: Set) { 7 | // Stop early 8 | if (aS.size !== bS.size) { 9 | return false; 10 | } 11 | 12 | // Check every key 13 | for (const a of aS) { 14 | if (!bS.has(a)) { 15 | return false; 16 | } 17 | } 18 | 19 | // Sets are equal 20 | return true; 21 | } 22 | -------------------------------------------------------------------------------- /src/tag-tree-data-provider.ts: -------------------------------------------------------------------------------- 1 | import { debounce } from "debounce"; 2 | import * as fs from "fs"; 3 | import * as vscode from "vscode"; 4 | import { setsAreEqual } from "./sets"; 5 | import { FileNode, fileNodeSort } from "./tag-tree/file-node"; 6 | import { TagNode, tagNodeSort } from "./tag-tree/tag-node"; 7 | import { TagTree } from "./tag-tree/tag-tree"; 8 | import * as grayMatter from "gray-matter"; 9 | import { Uri } from "vscode"; 10 | import * as path from "path"; 11 | 12 | interface IFileInfo { 13 | tags: Set; 14 | filePath: string; 15 | } 16 | 17 | class TagTreeDataProvider 18 | implements vscode.TreeDataProvider { 19 | private tagTree: TagTree; 20 | // Responsible for notifying the TreeDataProvider to update for the specified element in tagTree 21 | private _onDidChangeTreeData: vscode.EventEmitter< 22 | TagNode | FileNode | null 23 | > = new vscode.EventEmitter(); 24 | /* 25 | * An optional event to signal that an element or root has changed. 26 | * This will trigger the view to update the changed element/root and its children recursively (if shown). 27 | * To signal that root has changed, do not pass any argument or pass undefined or null. 28 | */ 29 | readonly onDidChangeTreeData: vscode.Event = this 30 | ._onDidChangeTreeData.event; 31 | 32 | constructor() { 33 | /* Register the extension to events of interest 34 | * Debounce to improve performance. Otherwise a file read would occur during each of the user's changes to the document. 35 | */ 36 | vscode.workspace.onDidChangeTextDocument( 37 | debounce( 38 | (e: vscode.TextDocumentChangeEvent) => this.onDocumentChanged(e), 39 | 500 40 | ) 41 | ); 42 | vscode.workspace.onWillSaveTextDocument(e => { 43 | this.onWillSaveTextDocument(e); 44 | }); 45 | 46 | // @ts-ignore 47 | const additionalFileTypes: string[] = vscode.workspace 48 | .getConfiguration() 49 | .get("vscode-nested-tags.additionalFileTypes"); 50 | const customGlobPattern = 51 | additionalFileTypes.length > 0 ? `,${additionalFileTypes.join(",")}` : ""; 52 | const globPattern = `{md${customGlobPattern}}`; 53 | 54 | const fileWatcher = vscode.workspace.createFileSystemWatcher("**/*"); 55 | fileWatcher.onDidDelete((...args) => this.onDidDelete(...args)); 56 | fileWatcher.onDidCreate((...args) => this.onDidCreate(...args)); 57 | 58 | this.tagTree = new TagTree(); 59 | 60 | /* 61 | * Add all files in the current workspace folder to the tag tree 62 | */ 63 | (async () => { 64 | const uris = await vscode.workspace.findFiles(`**/*.${globPattern}`); 65 | const infos = await Promise.all( 66 | uris.map(uri => this.getTagsFromFileOnFileSystem(uri.fsPath)) 67 | ); 68 | infos 69 | .filter(info => info.tags.size > 0) 70 | .forEach(info => { 71 | const displayName = this.getPathRelativeToWorkspaceFolder( 72 | Uri.file(info.filePath) 73 | ); 74 | 75 | this.tagTree.addFile(info.filePath, [...info.tags], displayName); 76 | }); 77 | 78 | this._onDidChangeTreeData.fire(); 79 | })(); 80 | } 81 | 82 | /** 83 | * Required for implementing TreeDataProvider interface. 84 | * 85 | * @param {(TagNode | FileNode)} element 86 | * @returns 87 | * @memberof TagTreeDataProvider 88 | */ 89 | public getChildren(element: TagNode | FileNode) { 90 | if (element instanceof FileNode) { 91 | return []; 92 | } else if (element === undefined) { 93 | // Convert the tags and files sets to arrays, then sort the arrays add tags first, then files 94 | const children = [ 95 | ...[...this.tagTree.root.tags.values()].sort(tagNodeSort), 96 | ...[...this.tagTree.root.files.values()].sort(fileNodeSort) 97 | ]; 98 | 99 | return children; 100 | } else { 101 | const children = [ 102 | ...[...element.tags.values()].sort(tagNodeSort), 103 | ...[...element.files.values()].sort(fileNodeSort) 104 | ]; 105 | 106 | return children; 107 | } 108 | } 109 | 110 | /** 111 | * Required for implementing TreeDataProvider interface. 112 | * 113 | * @param {(TagNode | FileNode)} element 114 | * @returns {vscode.TreeItem} 115 | * @memberof TagTreeDataProvider 116 | */ 117 | public getTreeItem(element: TagNode | FileNode): vscode.TreeItem { 118 | const tagTreeNode = this.tagTree.getNode(element.pathToNode); 119 | const { displayName } = tagTreeNode; 120 | const isFile = tagTreeNode instanceof FileNode; 121 | 122 | const collapsibleState = isFile 123 | ? vscode.TreeItemCollapsibleState.None 124 | : vscode.TreeItemCollapsibleState.Collapsed; 125 | 126 | const result = new vscode.TreeItem(displayName, collapsibleState); 127 | if (isFile) { 128 | result.command = { 129 | arguments: [vscode.Uri.file(tagTreeNode.filePath)], 130 | command: "vscode.open", 131 | title: "Jump to tag reference" 132 | }; 133 | } 134 | return result; 135 | } 136 | 137 | /** 138 | * Update the ui view if the document that is about to be saved has a different set of tags than 139 | * what is located in the currentState of the tag tree. This keeps the tree view in sync with 140 | * any changes to tags for a document before saving. 141 | * @param changeEvent 142 | */ 143 | private async onWillSaveTextDocument( 144 | changeEvent: vscode.TextDocumentWillSaveEvent 145 | ): Promise { 146 | if ( 147 | changeEvent.document.isDirty && 148 | this.matchesWatchedFileExtensions(changeEvent.document.uri) 149 | ) { 150 | const filePath = changeEvent.document.fileName; 151 | const fileInfo = await this.getTagsFromFileOnFileSystem(filePath); 152 | const tagsInTreeForFile = this.tagTree.getTagsForFile(filePath); 153 | // @ts-ignore 154 | this.updateTreeForFile(filePath, tagsInTreeForFile, fileInfo.tags); 155 | } 156 | } 157 | 158 | /** 159 | * Updates the tagTree and the ui tree view upon _every_ _single_ _change_ (saved or unsaved) 160 | * to a document. This method helps to keep the tag contents of the document in sync with the 161 | * tag tree view in the UI. This method fires for documents that have already been written to 162 | * the file system or are still in memory. 163 | * 164 | * @param changeEvent 165 | */ 166 | private onDocumentChanged(changeEvent: vscode.TextDocumentChangeEvent): void { 167 | const filePath = changeEvent.document.fileName; 168 | // If the file has been saved and the file is a watched file type allow for making changes to the tag tree 169 | if ( 170 | filePath !== undefined && 171 | this.matchesWatchedFileExtensions(changeEvent.document.uri) 172 | ) { 173 | const fileInfo = this.getTagsFromFileText( 174 | changeEvent.document.getText(), 175 | filePath 176 | ); 177 | const tagsInTreeForFile = this.tagTree.getTagsForFile(filePath); 178 | const isUpdateNeeded = !setsAreEqual(fileInfo.tags, tagsInTreeForFile); 179 | /* 180 | * This could be potentially performance intensive due to the number of changes that could 181 | * be made to a document and how large the document is. There will definitely need to be some 182 | * work done around TagTree to make sure that the code is performant. 183 | */ 184 | if (isUpdateNeeded) { 185 | this.tagTree.deleteFile(filePath); 186 | const displayName = this.getPathRelativeToWorkspaceFolder( 187 | Uri.file(filePath) 188 | ); 189 | this.tagTree.addFile( 190 | filePath, 191 | [...fileInfo.tags.values()], 192 | displayName 193 | ); 194 | // TODO: (bdietz) - this._onDidChangeTreeData.fire(specificNode?) 195 | this._onDidChangeTreeData.fire(); 196 | } 197 | } 198 | } 199 | 200 | private async onDidDelete(fileUri: vscode.Uri) { 201 | // I'm not sure if it matters whether or not the item is attempted to be deleted 202 | this.tagTree.deleteFile(fileUri.fsPath); 203 | this._onDidChangeTreeData.fire(); 204 | } 205 | 206 | private async onDidCreate(fileUri: vscode.Uri) { 207 | if (!fs.lstatSync(fileUri.fsPath).isDirectory()) { 208 | const displayName = this.getPathRelativeToWorkspaceFolder(fileUri); 209 | const fileInfo = await this.getTagsFromFileOnFileSystem(fileUri.fsPath); 210 | this.tagTree.addFile( 211 | fileUri.fsPath, 212 | [...fileInfo.tags.values()], 213 | displayName 214 | ); 215 | this._onDidChangeTreeData.fire(); 216 | } 217 | } 218 | 219 | /** 220 | * 221 | * @param filePath The uri path to the file 222 | * @param tagsBefore The tags before a change to the document 223 | * @param tagsAfter The tags after a change to the document 224 | */ 225 | private updateTreeForFile( 226 | filePath: string, 227 | tagsBefore: Set, 228 | tagsAfter: Set 229 | ) { 230 | const isUpdateNeeded = !setsAreEqual(tagsBefore, tagsAfter); 231 | if (isUpdateNeeded) { 232 | this.tagTree.deleteFile(filePath); 233 | const displayName = this.getPathRelativeToWorkspaceFolder( 234 | Uri.file(filePath) 235 | ); 236 | this.tagTree.addFile(filePath, [...tagsAfter.values()], displayName); 237 | /* 238 | * TODO (bdietz) - this._onDidChangeTreeData.fire(specificNode?) 239 | * specifying the specific node would help to improve the efficiency of the tree refresh. 240 | * Right now null/undefined being passed in fires off a refresh for the root of the tag tree. 241 | * I wonder if all the parents that have been impacted should be returned from the tag tree 242 | * for a fileDelete. 243 | */ 244 | this._onDidChangeTreeData.fire(); 245 | } 246 | } 247 | 248 | // TODO: (bdietz) - the method names of getTagsFrom* are kind of misleading because they return a FileInfo object. 249 | 250 | /** 251 | * Retrieves tags for a file's text content without accessing the file system. 252 | * 253 | * @param fileContents The document text 254 | * @param filePath The local filesystem path 255 | */ 256 | private getTagsFromFileText( 257 | fileContents: string, 258 | filePath: string 259 | ): IFileInfo { 260 | // Parse any yaml frontmatter and check for tags within that frontmatter 261 | const { data } = grayMatter(fileContents); 262 | let yamlTags = new Set(); 263 | if (data.tags) { 264 | yamlTags = new Set([data.tags]); 265 | } 266 | 267 | return fileContents.split("\n").reduce( 268 | (accumulator, currentLine) => { 269 | if (currentLine.includes("@nested-tags:")) { 270 | // @ts-ignore 271 | const tagsToAdd = currentLine 272 | .split("@nested-tags:") 273 | .pop() 274 | // Do some best effort cleanup on common multi-line comment closing syntax 275 | .replace("-->", "") 276 | .replace("*/", "") 277 | .split(","); 278 | return { 279 | ...accumulator, 280 | tags: new Set([...accumulator.tags, ...tagsToAdd]) 281 | }; 282 | } 283 | 284 | return accumulator; 285 | }, 286 | // @ts-ignore 287 | { tags: new Set(...yamlTags), filePath } 288 | ); 289 | } 290 | 291 | /** 292 | * Retrieves tags for a file on the file system. 293 | * 294 | * @param filePath The local filesystem path 295 | */ 296 | private async getTagsFromFileOnFileSystem( 297 | filePath: string 298 | ): Promise { 299 | const buffer = await fs.promises.readFile(filePath); 300 | return this.getTagsFromFileText(buffer.toString(), filePath); 301 | } 302 | 303 | /** 304 | * 305 | * @param uri 306 | */ 307 | private getPathRelativeToWorkspaceFolder(uri: Uri): string { 308 | const currentWorkspaceFolder = vscode.workspace.getWorkspaceFolder(uri); 309 | const relativePath = 310 | typeof currentWorkspaceFolder !== "undefined" 311 | ? path.relative(currentWorkspaceFolder.uri.path, uri.path) 312 | : uri.path; 313 | 314 | return relativePath; 315 | } 316 | 317 | /** 318 | * Checks to see if a given file uri matches the file extensions that are user configured. 319 | * 320 | * @param uri 321 | */ 322 | private matchesWatchedFileExtensions(uri: Uri) { 323 | const supportedFileExtensions = new Set([ 324 | "md", 325 | // @ts-ignore 326 | ...vscode.workspace 327 | .getConfiguration() 328 | .get("vscode-nested-tags.additionalFileTypes") 329 | ]); 330 | 331 | const fileExtension = uri.fsPath.split(".").pop(); 332 | 333 | return supportedFileExtensions.has(fileExtension); 334 | } 335 | } 336 | 337 | export { TagTreeDataProvider }; 338 | -------------------------------------------------------------------------------- /src/tag-tree-view.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | import { TagTreeDataProvider } from "./tag-tree-data-provider"; 3 | 4 | class TagTreeView { 5 | constructor(context: vscode.ExtensionContext) { 6 | vscode.window.createTreeView("tagTreeView", { 7 | treeDataProvider: new TagTreeDataProvider() 8 | }); 9 | } 10 | } 11 | 12 | export { TagTreeView }; 13 | -------------------------------------------------------------------------------- /src/tag-tree/__snapshots__/tag-tree.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`TagTree addNode Single depth path 1`] = ` 4 | "TagTree { 5 | root: TagNode { 6 | parent: null, 7 | displayName: '', 8 | files: Map {}, 9 | pathToNode: '', 10 | tag: '', 11 | tags: Map { 12 | 'hello' => TagNode { 13 | parent: [Circular], 14 | displayName: 'hello', 15 | files: Map { 16 | 'foo.md' => FileNode { 17 | displayName: 'foo.md', 18 | key: 'foo.md', 19 | filePath: 'foo.md', 20 | pathToNode: 'hello/foo.md', 21 | tags: [ 'hello' ] 22 | } 23 | }, 24 | pathToNode: 'hello', 25 | tag: 'hello', 26 | tags: Map {} 27 | } 28 | } 29 | }, 30 | fileIndex: Map { 31 | 'foo.md' => [ 32 | TagNode { 33 | parent: TagNode { 34 | parent: null, 35 | displayName: '', 36 | files: Map {}, 37 | pathToNode: '', 38 | tag: '', 39 | tags: Map { 'hello' => [Circular] } 40 | }, 41 | displayName: 'hello', 42 | files: Map { 43 | 'foo.md' => FileNode { 44 | displayName: 'foo.md', 45 | key: 'foo.md', 46 | filePath: 'foo.md', 47 | pathToNode: 'hello/foo.md', 48 | tags: [ 'hello' ] 49 | } 50 | }, 51 | pathToNode: 'hello', 52 | tag: 'hello', 53 | tags: Map {} 54 | } 55 | ] 56 | } 57 | }" 58 | `; 59 | 60 | exports[`TagTree addNode Two directories deep 1`] = ` 61 | "TagTree { 62 | root: TagNode { 63 | parent: null, 64 | displayName: '', 65 | files: Map {}, 66 | pathToNode: '', 67 | tag: '', 68 | tags: Map { 69 | 'hello' => TagNode { 70 | parent: [Circular], 71 | displayName: 'hello', 72 | files: Map {}, 73 | pathToNode: 'hello', 74 | tag: 'hello', 75 | tags: Map { 76 | 'world' => TagNode { 77 | parent: [Circular], 78 | displayName: 'world', 79 | files: Map { 80 | 'foo.md' => FileNode { 81 | displayName: 'foo.md', 82 | key: 'foo.md', 83 | filePath: 'foo.md', 84 | pathToNode: 'hello/world/foo.md', 85 | tags: [ 'hello/world' ] 86 | } 87 | }, 88 | pathToNode: 'hello/world', 89 | tag: 'world', 90 | tags: Map {} 91 | } 92 | } 93 | } 94 | } 95 | }, 96 | fileIndex: Map { 97 | 'foo.md' => [ 98 | TagNode { 99 | parent: TagNode { 100 | parent: TagNode { 101 | parent: null, 102 | displayName: '', 103 | files: Map {}, 104 | pathToNode: '', 105 | tag: '', 106 | tags: Map { 'hello' => [Circular] } 107 | }, 108 | displayName: 'hello', 109 | files: Map {}, 110 | pathToNode: 'hello', 111 | tag: 'hello', 112 | tags: Map { 'world' => [Circular] } 113 | }, 114 | displayName: 'world', 115 | files: Map { 116 | 'foo.md' => FileNode { 117 | displayName: 'foo.md', 118 | key: 'foo.md', 119 | filePath: 'foo.md', 120 | pathToNode: 'hello/world/foo.md', 121 | tags: [ 'hello/world' ] 122 | } 123 | }, 124 | pathToNode: 'hello/world', 125 | tag: 'world', 126 | tags: Map {} 127 | } 128 | ] 129 | } 130 | }" 131 | `; 132 | 133 | exports[`TagTree addNode Two files under the same tag 1`] = ` 134 | "TagTree { 135 | root: TagNode { 136 | parent: null, 137 | displayName: '', 138 | files: Map {}, 139 | pathToNode: '', 140 | tag: '', 141 | tags: Map { 142 | 'hello' => TagNode { 143 | parent: [Circular], 144 | displayName: 'hello', 145 | files: Map { 146 | 'foo.md' => FileNode { 147 | displayName: 'foo.md', 148 | key: 'foo.md', 149 | filePath: 'foo.md', 150 | pathToNode: 'hello/foo.md', 151 | tags: [ 'hello' ] 152 | }, 153 | 'bar.md' => FileNode { 154 | displayName: 'foo.md', 155 | key: 'bar.md', 156 | filePath: 'bar.md', 157 | pathToNode: 'hello/bar.md', 158 | tags: [ 'hello' ] 159 | } 160 | }, 161 | pathToNode: 'hello', 162 | tag: 'hello', 163 | tags: Map {} 164 | } 165 | } 166 | }, 167 | fileIndex: Map { 168 | 'foo.md' => [ 169 | TagNode { 170 | parent: TagNode { 171 | parent: null, 172 | displayName: '', 173 | files: Map {}, 174 | pathToNode: '', 175 | tag: '', 176 | tags: Map { 'hello' => [Circular] } 177 | }, 178 | displayName: 'hello', 179 | files: Map { 180 | 'foo.md' => FileNode { 181 | displayName: 'foo.md', 182 | key: 'foo.md', 183 | filePath: 'foo.md', 184 | pathToNode: 'hello/foo.md', 185 | tags: [ 'hello' ] 186 | }, 187 | 'bar.md' => FileNode { 188 | displayName: 'foo.md', 189 | key: 'bar.md', 190 | filePath: 'bar.md', 191 | pathToNode: 'hello/bar.md', 192 | tags: [ 'hello' ] 193 | } 194 | }, 195 | pathToNode: 'hello', 196 | tag: 'hello', 197 | tags: Map {} 198 | } 199 | ], 200 | 'bar.md' => [ 201 | TagNode { 202 | parent: TagNode { 203 | parent: null, 204 | displayName: '', 205 | files: Map {}, 206 | pathToNode: '', 207 | tag: '', 208 | tags: Map { 'hello' => [Circular] } 209 | }, 210 | displayName: 'hello', 211 | files: Map { 212 | 'foo.md' => FileNode { 213 | displayName: 'foo.md', 214 | key: 'foo.md', 215 | filePath: 'foo.md', 216 | pathToNode: 'hello/foo.md', 217 | tags: [ 'hello' ] 218 | }, 219 | 'bar.md' => FileNode { 220 | displayName: 'foo.md', 221 | key: 'bar.md', 222 | filePath: 'bar.md', 223 | pathToNode: 'hello/bar.md', 224 | tags: [ 'hello' ] 225 | } 226 | }, 227 | pathToNode: 'hello', 228 | tag: 'hello', 229 | tags: Map {} 230 | } 231 | ] 232 | } 233 | }" 234 | `; 235 | 236 | exports[`TagTree addNode Two separate branches 1`] = ` 237 | "TagTree { 238 | root: TagNode { 239 | parent: null, 240 | displayName: '', 241 | files: Map {}, 242 | pathToNode: '', 243 | tag: '', 244 | tags: Map { 245 | 'hello' => TagNode { 246 | parent: [Circular], 247 | displayName: 'hello', 248 | files: Map { 249 | 'foo.md' => FileNode { 250 | displayName: 'foo.md', 251 | key: 'foo.md', 252 | filePath: 'foo.md', 253 | pathToNode: 'hello/foo.md', 254 | tags: [ 'hello', 'world' ] 255 | } 256 | }, 257 | pathToNode: 'hello', 258 | tag: 'hello', 259 | tags: Map {} 260 | }, 261 | 'world' => TagNode { 262 | parent: [Circular], 263 | displayName: 'world', 264 | files: Map { 265 | 'foo.md' => FileNode { 266 | displayName: 'foo.md', 267 | key: 'foo.md', 268 | filePath: 'foo.md', 269 | pathToNode: 'world/foo.md', 270 | tags: [ 'hello', 'world' ] 271 | } 272 | }, 273 | pathToNode: 'world', 274 | tag: 'world', 275 | tags: Map {} 276 | } 277 | } 278 | }, 279 | fileIndex: Map { 280 | 'foo.md' => [ 281 | TagNode { 282 | parent: TagNode { 283 | parent: null, 284 | displayName: '', 285 | files: Map {}, 286 | pathToNode: '', 287 | tag: '', 288 | tags: Map { 289 | 'hello' => [Circular], 290 | 'world' => TagNode { 291 | parent: [Circular], 292 | displayName: 'world', 293 | files: Map { 294 | 'foo.md' => FileNode { 295 | displayName: 'foo.md', 296 | key: 'foo.md', 297 | filePath: 'foo.md', 298 | pathToNode: 'world/foo.md', 299 | tags: [ 'hello', 'world' ] 300 | } 301 | }, 302 | pathToNode: 'world', 303 | tag: 'world', 304 | tags: Map {} 305 | } 306 | } 307 | }, 308 | displayName: 'hello', 309 | files: Map { 310 | 'foo.md' => FileNode { 311 | displayName: 'foo.md', 312 | key: 'foo.md', 313 | filePath: 'foo.md', 314 | pathToNode: 'hello/foo.md', 315 | tags: [ 'hello', 'world' ] 316 | } 317 | }, 318 | pathToNode: 'hello', 319 | tag: 'hello', 320 | tags: Map {} 321 | }, 322 | TagNode { 323 | parent: TagNode { 324 | parent: null, 325 | displayName: '', 326 | files: Map {}, 327 | pathToNode: '', 328 | tag: '', 329 | tags: Map { 330 | 'hello' => TagNode { 331 | parent: [Circular], 332 | displayName: 'hello', 333 | files: Map { 334 | 'foo.md' => FileNode { 335 | displayName: 'foo.md', 336 | key: 'foo.md', 337 | filePath: 'foo.md', 338 | pathToNode: 'hello/foo.md', 339 | tags: [ 'hello', 'world' ] 340 | } 341 | }, 342 | pathToNode: 'hello', 343 | tag: 'hello', 344 | tags: Map {} 345 | }, 346 | 'world' => [Circular] 347 | } 348 | }, 349 | displayName: 'world', 350 | files: Map { 351 | 'foo.md' => FileNode { 352 | displayName: 'foo.md', 353 | key: 'foo.md', 354 | filePath: 'foo.md', 355 | pathToNode: 'world/foo.md', 356 | tags: [ 'hello', 'world' ] 357 | } 358 | }, 359 | pathToNode: 'world', 360 | tag: 'world', 361 | tags: Map {} 362 | } 363 | ] 364 | } 365 | }" 366 | `; 367 | 368 | exports[`TagTree deleteFile Delete file under shared tag 1`] = ` 369 | "TagTree { 370 | root: TagNode { 371 | parent: null, 372 | displayName: '', 373 | files: Map {}, 374 | pathToNode: '', 375 | tag: '', 376 | tags: Map { 377 | 'hello' => TagNode { 378 | parent: [Circular], 379 | displayName: 'hello', 380 | files: Map { 381 | '|Users|test|bar.md' => FileNode { 382 | displayName: 'bar.md', 383 | key: '|Users|test|bar.md', 384 | filePath: '/Users/test/bar.md', 385 | pathToNode: 'hello/|Users|test|bar.md', 386 | tags: [ 'hello' ] 387 | } 388 | }, 389 | pathToNode: 'hello', 390 | tag: 'hello', 391 | tags: Map {} 392 | } 393 | } 394 | }, 395 | fileIndex: Map { 396 | '|Users|test|bar.md' => [ 397 | TagNode { 398 | parent: TagNode { 399 | parent: null, 400 | displayName: '', 401 | files: Map {}, 402 | pathToNode: '', 403 | tag: '', 404 | tags: Map { 'hello' => [Circular] } 405 | }, 406 | displayName: 'hello', 407 | files: Map { 408 | '|Users|test|bar.md' => FileNode { 409 | displayName: 'bar.md', 410 | key: '|Users|test|bar.md', 411 | filePath: '/Users/test/bar.md', 412 | pathToNode: 'hello/|Users|test|bar.md', 413 | tags: [ 'hello' ] 414 | } 415 | }, 416 | pathToNode: 'hello', 417 | tag: 'hello', 418 | tags: Map {} 419 | } 420 | ] 421 | } 422 | }" 423 | `; 424 | 425 | exports[`TagTree deleteFile Delete file under shared tag with empty node 1`] = ` 426 | "TagTree { 427 | root: TagNode { 428 | parent: null, 429 | displayName: '', 430 | files: Map {}, 431 | pathToNode: '', 432 | tag: '', 433 | tags: Map { 434 | 'hello' => TagNode { 435 | parent: [Circular], 436 | displayName: 'hello', 437 | files: Map { 438 | '|Users|test|bar.md' => FileNode { 439 | displayName: 'bar.md', 440 | key: '|Users|test|bar.md', 441 | filePath: '/Users/test/bar.md', 442 | pathToNode: 'hello/|Users|test|bar.md', 443 | tags: [ 'hello' ] 444 | } 445 | }, 446 | pathToNode: 'hello', 447 | tag: 'hello', 448 | tags: Map {} 449 | } 450 | } 451 | }, 452 | fileIndex: Map { 453 | '|Users|test|bar.md' => [ 454 | TagNode { 455 | parent: TagNode { 456 | parent: null, 457 | displayName: '', 458 | files: Map {}, 459 | pathToNode: '', 460 | tag: '', 461 | tags: Map { 'hello' => [Circular] } 462 | }, 463 | displayName: 'hello', 464 | files: Map { 465 | '|Users|test|bar.md' => FileNode { 466 | displayName: 'bar.md', 467 | key: '|Users|test|bar.md', 468 | filePath: '/Users/test/bar.md', 469 | pathToNode: 'hello/|Users|test|bar.md', 470 | tags: [ 'hello' ] 471 | } 472 | }, 473 | pathToNode: 'hello', 474 | tag: 'hello', 475 | tags: Map {} 476 | } 477 | ] 478 | } 479 | }" 480 | `; 481 | 482 | exports[`TagTree deleteFile Delete node under root 1`] = ` 483 | "TagTree { 484 | root: TagNode { 485 | parent: null, 486 | displayName: '', 487 | files: Map {}, 488 | pathToNode: '', 489 | tag: '', 490 | tags: Map {} 491 | }, 492 | fileIndex: Map {} 493 | }" 494 | `; 495 | 496 | exports[`TagTree getTagsForFile Retrieves all tags for a file 1`] = ` 497 | "TagTree { 498 | root: TagNode { 499 | parent: null, 500 | displayName: '', 501 | files: Map {}, 502 | pathToNode: '', 503 | tag: '', 504 | tags: Map { 505 | 'hello' => TagNode { 506 | parent: [Circular], 507 | displayName: 'hello', 508 | files: Map { 509 | 'foo.md' => FileNode { 510 | displayName: 'foo.md', 511 | key: 'foo.md', 512 | filePath: 'foo.md', 513 | pathToNode: 'hello/foo.md', 514 | tags: [ 'hello', 'world' ] 515 | } 516 | }, 517 | pathToNode: 'hello', 518 | tag: 'hello', 519 | tags: Map {} 520 | }, 521 | 'world' => TagNode { 522 | parent: [Circular], 523 | displayName: 'world', 524 | files: Map { 525 | 'foo.md' => FileNode { 526 | displayName: 'foo.md', 527 | key: 'foo.md', 528 | filePath: 'foo.md', 529 | pathToNode: 'world/foo.md', 530 | tags: [ 'hello', 'world' ] 531 | } 532 | }, 533 | pathToNode: 'world', 534 | tag: 'world', 535 | tags: Map {} 536 | } 537 | } 538 | }, 539 | fileIndex: Map { 540 | 'foo.md' => [ 541 | TagNode { 542 | parent: TagNode { 543 | parent: null, 544 | displayName: '', 545 | files: Map {}, 546 | pathToNode: '', 547 | tag: '', 548 | tags: Map { 549 | 'hello' => [Circular], 550 | 'world' => TagNode { 551 | parent: [Circular], 552 | displayName: 'world', 553 | files: Map { 554 | 'foo.md' => FileNode { 555 | displayName: 'foo.md', 556 | key: 'foo.md', 557 | filePath: 'foo.md', 558 | pathToNode: 'world/foo.md', 559 | tags: [ 'hello', 'world' ] 560 | } 561 | }, 562 | pathToNode: 'world', 563 | tag: 'world', 564 | tags: Map {} 565 | } 566 | } 567 | }, 568 | displayName: 'hello', 569 | files: Map { 570 | 'foo.md' => FileNode { 571 | displayName: 'foo.md', 572 | key: 'foo.md', 573 | filePath: 'foo.md', 574 | pathToNode: 'hello/foo.md', 575 | tags: [ 'hello', 'world' ] 576 | } 577 | }, 578 | pathToNode: 'hello', 579 | tag: 'hello', 580 | tags: Map {} 581 | }, 582 | TagNode { 583 | parent: TagNode { 584 | parent: null, 585 | displayName: '', 586 | files: Map {}, 587 | pathToNode: '', 588 | tag: '', 589 | tags: Map { 590 | 'hello' => TagNode { 591 | parent: [Circular], 592 | displayName: 'hello', 593 | files: Map { 594 | 'foo.md' => FileNode { 595 | displayName: 'foo.md', 596 | key: 'foo.md', 597 | filePath: 'foo.md', 598 | pathToNode: 'hello/foo.md', 599 | tags: [ 'hello', 'world' ] 600 | } 601 | }, 602 | pathToNode: 'hello', 603 | tag: 'hello', 604 | tags: Map {} 605 | }, 606 | 'world' => [Circular] 607 | } 608 | }, 609 | displayName: 'world', 610 | files: Map { 611 | 'foo.md' => FileNode { 612 | displayName: 'foo.md', 613 | key: 'foo.md', 614 | filePath: 'foo.md', 615 | pathToNode: 'world/foo.md', 616 | tags: [ 'hello', 'world' ] 617 | } 618 | }, 619 | pathToNode: 'world', 620 | tag: 'world', 621 | tags: Map {} 622 | } 623 | ] 624 | } 625 | }" 626 | `; 627 | -------------------------------------------------------------------------------- /src/tag-tree/file-node.test.ts: -------------------------------------------------------------------------------- 1 | import { fileNodeSort, FileNode } from "./file-node"; 2 | 3 | describe(fileNodeSort.name, () => { 4 | test("Sorts in ascending order", () => { 5 | const fileNodeA = new FileNode("a", "a", "a", [], "a"); 6 | const fileNodeB = new FileNode("b", "b", "b", [], "b"); 7 | 8 | expect(fileNodeSort(fileNodeA, fileNodeB)).toEqual(-1); 9 | expect(fileNodeSort(fileNodeB, fileNodeA)).toEqual(1); 10 | expect(fileNodeSort(fileNodeA, fileNodeA)).toEqual(0); 11 | }); 12 | }); 13 | -------------------------------------------------------------------------------- /src/tag-tree/file-node.ts: -------------------------------------------------------------------------------- 1 | class FileNode { 2 | // The name that should be displayed when viewing in the ui 3 | public displayName: string; 4 | // The absolute filepath 5 | public filePath: string; 6 | // The tags that are in the file itself; 7 | public tags: string[]; 8 | public pathToNode: string; 9 | public key: string; 10 | 11 | constructor( 12 | key: string, 13 | filePath: string, 14 | pathToNode: string, 15 | tags: string[], 16 | displayName: string 17 | ) { 18 | this.displayName = displayName; 19 | this.key = key; 20 | this.filePath = filePath; 21 | this.pathToNode = `${pathToNode}/${this.key}`; 22 | this.tags = tags; 23 | } 24 | } 25 | 26 | interface IFileNodeSort { 27 | (fileA: FileNode, fileB: FileNode): number; 28 | } 29 | 30 | const fileNodeSort: IFileNodeSort = (fileA: FileNode, fileB: FileNode) => { 31 | if (fileA.filePath < fileB.filePath) { 32 | return -1; 33 | } else if (fileA.filePath > fileB.filePath) { 34 | return 1; 35 | } else { 36 | return 0; 37 | } 38 | }; 39 | 40 | export { FileNode, fileNodeSort }; 41 | -------------------------------------------------------------------------------- /src/tag-tree/tag-node.test.ts: -------------------------------------------------------------------------------- 1 | import { tagNodeSort, TagNode } from "./tag-node"; 2 | 3 | describe(tagNodeSort.name, () => { 4 | test("Sorts in ascending order", () => { 5 | const tagNodeA = new TagNode(null, "a", ""); 6 | const tagNodeB = new TagNode(null, "b", ""); 7 | 8 | expect(tagNodeSort(tagNodeA, tagNodeB)).toEqual(-1); 9 | expect(tagNodeSort(tagNodeB, tagNodeA)).toEqual(1); 10 | expect(tagNodeSort(tagNodeA, tagNodeA)).toEqual(0); 11 | }); 12 | }); 13 | -------------------------------------------------------------------------------- /src/tag-tree/tag-node.ts: -------------------------------------------------------------------------------- 1 | import { FileNode } from "./file-node"; 2 | 3 | class TagNode { 4 | public tags: Map; 5 | public files: Map; 6 | public parent: TagNode | null; 7 | 8 | public displayName: string; 9 | public tag: string; 10 | public pathToNode: string; 11 | 12 | constructor( 13 | parent = null, 14 | tag: string, 15 | pathToNode: string, 16 | displayName = "" 17 | ) { 18 | this.parent = parent; 19 | this.displayName = displayName; 20 | this.files = new Map(); 21 | this.pathToNode = pathToNode; 22 | this.tag = tag; 23 | this.tags = new Map(); 24 | } 25 | 26 | public addTag(tag: string, pathToNode: string, displayName = tag) { 27 | // @ts-ignore 28 | this.tags.set(tag, new TagNode(this, tag, pathToNode, displayName)); 29 | } 30 | 31 | public getTag(tag: string) { 32 | return this.tags.get(tag); 33 | } 34 | 35 | public deleteTag(tag: string) { 36 | this.tags.delete(tag); 37 | } 38 | 39 | public hasFile(key: string) { 40 | return this.files.has(key); 41 | } 42 | 43 | public addFile(node: FileNode) { 44 | this.files.set(node.key, node); 45 | } 46 | 47 | public deleteFile(fileKey: string) { 48 | this.files.delete(fileKey); 49 | } 50 | } 51 | 52 | interface ITagNodeSort { 53 | (tagA: TagNode, tagB: TagNode): number; 54 | } 55 | const tagNodeSort: ITagNodeSort = (tagA: TagNode, tagB: TagNode) => { 56 | if (tagA.tag < tagB.tag) { 57 | return -1; 58 | } else if (tagA.tag > tagB.tag) { 59 | return 1; 60 | } else { 61 | return 0; 62 | } 63 | }; 64 | 65 | export { TagNode, tagNodeSort }; 66 | -------------------------------------------------------------------------------- /src/tag-tree/tag-tree.test.ts: -------------------------------------------------------------------------------- 1 | import { TagTree } from "./tag-tree"; 2 | import { createDeepSnapshot } from "./test-helpers"; 3 | 4 | describe("TagTree", () => { 5 | describe("deleteFile", () => { 6 | test("Delete node under root", () => { 7 | const tagTree = new TagTree(); 8 | const filePath = "/Users/test/foo.md"; 9 | tagTree.addFile(filePath, ["hello"], "foo.md"); 10 | tagTree.deleteFile(filePath); 11 | 12 | expect(createDeepSnapshot(tagTree)).toMatchSnapshot(); 13 | }); 14 | test("Delete file under shared tag", () => { 15 | const tagTree = new TagTree(); 16 | const filePath = "/Users/test/foo.md"; 17 | tagTree.addFile(filePath, ["hello"], "foo.md"); 18 | tagTree.addFile("/Users/test/bar.md", ["hello"], "bar.md"); 19 | tagTree.deleteFile(filePath); 20 | 21 | expect(createDeepSnapshot(tagTree)).toMatchSnapshot(); 22 | }); 23 | 24 | test("Delete file under shared tag with empty node", () => { 25 | const tagTree = new TagTree(); 26 | const filePath = "/Users/test/foo.md"; 27 | tagTree.addFile(filePath, ["hello/world/asuh"], "foo.md"); 28 | tagTree.addFile("/Users/test/bar.md", ["hello"], "bar.md"); 29 | tagTree.deleteFile(filePath); 30 | expect(createDeepSnapshot(tagTree)).toMatchSnapshot(); 31 | }); 32 | 33 | test("Move tag tree from one top level node to another", () => { 34 | const tagTree = new TagTree(); 35 | const filePath = "/Users/test/foo.md"; 36 | tagTree.addFile(filePath, ["hello/world"], "foo.md"); 37 | tagTree.deleteFile(filePath); 38 | tagTree.addFile(filePath, ["goodbye/world"], "foo.md"); 39 | const tag = tagTree.root.getTag("hello"); 40 | const renamedTag = tagTree.root.getTag("goodbye"); 41 | 42 | expect(tag).toBeUndefined(); 43 | expect(renamedTag).toBeDefined(); 44 | }); 45 | }); 46 | describe("getTagsForFile", () => { 47 | test("Retrieves all tags for a file", () => { 48 | const tagTree = new TagTree(); 49 | tagTree.addFile("foo.md", ["hello", "world"], "foo.md"); 50 | const tags = tagTree.getTagsForFile("foo.md"); 51 | expect([...tags]).toEqual(["hello", "world"]); 52 | expect(createDeepSnapshot(tagTree)).toMatchSnapshot(); 53 | }); 54 | }); 55 | 56 | describe("addNode", () => { 57 | test("Single depth path", () => { 58 | const tagTree = new TagTree(); 59 | tagTree.addFile("foo.md", ["hello"], "foo.md"); 60 | 61 | expect(createDeepSnapshot(tagTree)).toMatchSnapshot(); 62 | }); 63 | test("Two separate branches", () => { 64 | const tagTree = new TagTree(); 65 | tagTree.addFile("foo.md", ["hello", "world"], "foo.md"); 66 | 67 | expect(createDeepSnapshot(tagTree)).toMatchSnapshot(); 68 | }); 69 | test("Two directories deep", () => { 70 | const tagTree = new TagTree(); 71 | tagTree.addFile("foo.md", ["hello/world"], "foo.md"); 72 | expect(createDeepSnapshot(tagTree)).toMatchSnapshot(); 73 | }); 74 | test("Two files under the same tag", () => { 75 | const tagTree = new TagTree(); 76 | tagTree.addFile("foo.md", ["hello"], "foo.md"); 77 | tagTree.addFile("bar.md", ["hello"], "foo.md"); 78 | 79 | expect(createDeepSnapshot(tagTree)).toMatchSnapshot(); 80 | }); 81 | }); 82 | }); 83 | -------------------------------------------------------------------------------- /src/tag-tree/tag-tree.ts: -------------------------------------------------------------------------------- 1 | import { TagNode } from "./tag-node"; 2 | import { FileNode } from "./file-node"; 3 | 4 | class TagTree { 5 | public root: TagNode; 6 | private fileIndex: Map; 7 | 8 | constructor() { 9 | this.root = new TagNode(null, "", ""); 10 | this.fileIndex = new Map(); 11 | } 12 | 13 | /// TODO:(bdietz) - figure out if this method supports adding a file multiple times 14 | public addFile(filePath: string, tags: string[], displayName: string) { 15 | // TODO: (bdietz) - should it be the file node's job to write its own key? 16 | for (const tag of tags) { 17 | const newNode = new FileNode( 18 | this.createKeyForFile(filePath), 19 | filePath, 20 | tag, 21 | tags, 22 | displayName 23 | ); 24 | this.addFileNode(tag, newNode); 25 | } 26 | } 27 | 28 | public deleteFile(filePath: string): void { 29 | const fileKey = this.createKeyForFile(filePath); 30 | if (!this.fileIndex.has(fileKey)) { 31 | return; 32 | } 33 | 34 | const tags = this.fileIndex.get(fileKey); 35 | 36 | for (const tag of tags!) { 37 | tag.deleteFile(fileKey); 38 | this.deletePathToFile(tag); 39 | } 40 | // After removing all of the nodes in the tree, remove the entry in the file index 41 | this.fileIndex.delete(fileKey); 42 | } 43 | 44 | public getTagsForFile(filePath: string) { 45 | const fileKey = this.createKeyForFile(filePath); 46 | if (!this.fileIndex.has(fileKey)) { 47 | return new Set(); 48 | } 49 | const tagNodes = this.fileIndex.get(fileKey)!; 50 | return tagNodes.reduce((tags, tagNode) => { 51 | return tags.add(tagNode.tag); 52 | }, new Set()); 53 | } 54 | 55 | public getNode(nodePath: string) { 56 | // @ts-ignore 57 | return nodePath.split("/").reduce((currentNode, pathPart) => { 58 | // Must be looking at a file 59 | if (pathPart.includes(".")) { 60 | return currentNode.files.get(this.createKeyForFile(pathPart)); 61 | } else { 62 | return currentNode.tags.get(pathPart); 63 | } 64 | }, this.root); 65 | } 66 | 67 | private addFileNode(tagPath: string, fileNode: FileNode): void { 68 | /** 69 | * Given a tag filePath, the tags that lead up to the file may not exist in the tag tree continue down the filePath 70 | * building the parts of the tree that don't exist one node at a time 71 | */ 72 | const nodeToAddFileTo = tagPath.split("/").reduce( 73 | ({ currentNode, currentPath }, pathToAdd) => { 74 | const newCurrentPath = 75 | currentPath === "" ? pathToAdd : `${currentPath}/${pathToAdd}`; 76 | 77 | if (currentNode.getTag(pathToAdd) !== undefined) { 78 | return { 79 | currentNode: currentNode.getTag(pathToAdd)!, 80 | currentPath: newCurrentPath 81 | }; 82 | } 83 | // The node has not had this tag added yet so make sure one exists 84 | else { 85 | currentNode.addTag(pathToAdd, newCurrentPath); 86 | return { 87 | currentNode: currentNode.getTag(pathToAdd)!, 88 | currentPath: newCurrentPath 89 | }; 90 | } 91 | }, 92 | { currentNode: this.root, currentPath: "" } 93 | ).currentNode; 94 | 95 | /** 96 | * At this point, the tag tree has been built and we can just add the file to the 97 | * tag tree 98 | */ 99 | nodeToAddFileTo.addFile(fileNode); 100 | if (!this.fileIndex.has(fileNode.key)) { 101 | this.fileIndex.set(fileNode.key, [nodeToAddFileTo]); 102 | } else { 103 | const nodesForFile = this.fileIndex.get(fileNode.key)!; 104 | this.fileIndex.set(fileNode.key, [...nodesForFile, nodeToAddFileTo]); 105 | } 106 | } 107 | 108 | private createKeyForFile(filePath: string): string { 109 | return filePath.replace(/\//g, "|"); 110 | } 111 | 112 | private deletePathToFile(node: TagNode) { 113 | let currentNode = node; 114 | 115 | while (currentNode!.files.size === 0 && currentNode!.tags.size === 0) { 116 | const { parent } = currentNode; 117 | // We're not at the root of the tree yet 118 | if (parent !== null) { 119 | parent!.deleteTag(currentNode.tag); 120 | // @ts-ignore 121 | currentNode = parent; 122 | // At this point we've hit the root of the tree 123 | } else { 124 | break; 125 | } 126 | } 127 | } 128 | } 129 | 130 | export { TagTree }; 131 | -------------------------------------------------------------------------------- /src/tag-tree/test-helpers.ts: -------------------------------------------------------------------------------- 1 | import * as util from "util"; // has no default export; 2 | 3 | // @ts-ignore 4 | function replaceMapWithObject(key, value) { 5 | if (value instanceof Map || value instanceof Set) { 6 | var obj = {}; 7 | for (let [mapKey, mapValue] of value) { 8 | // @ts-ignore 9 | obj[mapKey] = mapValue; 10 | } 11 | return obj; 12 | } 13 | return value; 14 | } 15 | 16 | // @ts-ignore 17 | export function walkFromPath(path: string, tree) { 18 | const treeObject = convertTreeToObject(tree); 19 | 20 | const pathParts = path.split("/"); 21 | const file = pathParts.pop(); 22 | const tagPath = pathParts; 23 | 24 | // @ts-ignore 25 | const finalTagNode = tagPath.reduce( 26 | (node, nodePath) => node.tags[nodePath], 27 | treeObject.root 28 | ); 29 | 30 | // @ts-ignore 31 | return finalTagNode.files[file]; 32 | } 33 | 34 | // @ts-ignore 35 | export function createDeepSnapshot(object) { 36 | return util.inspect(object, { depth: Infinity }); 37 | } 38 | 39 | // @ts-ignore 40 | export function convertTreeToObject(objectToSerialize) { 41 | return JSON.parse(JSON.stringify(objectToSerialize, replaceMapWithObject)); 42 | } 43 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "es6", 5 | "outDir": "out", 6 | "lib": [ 7 | "es6" 8 | ], 9 | "sourceMap": true, 10 | "rootDir": "src", 11 | /* Strict Type-Checking Option */ 12 | "strict": true, /* enable all strict type-checking options */ 13 | /* Additional Checks */ 14 | "noUnusedLocals": true /* Report errors on unused locals. */ 15 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 16 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 17 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 18 | }, 19 | "exclude": [ 20 | "node_modules", 21 | ".vscode-test" 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "tslint:latest", 4 | "tslint-config-prettier" 5 | ], 6 | "rules": { 7 | "no-string-throw": true, 8 | "no-unused-expression": true, 9 | "no-duplicate-variable": true, 10 | "curly": true, 11 | "class-name": true, 12 | "semicolon": [ 13 | true, 14 | "always" 15 | ], 16 | "triple-equals": true 17 | }, 18 | "defaultSeverity": "warning" 19 | } 20 | --------------------------------------------------------------------------------