Skip to content

Commit

Permalink
Merge pull request #20 from kortina/ak-reference-provider
Browse files Browse the repository at this point in the history
add reference provider (for peek/find references) and create note when definition not found
kortina authored May 16, 2020
2 parents 132149d + aa1b93e commit db5f3c4
Showing 17 changed files with 1,215 additions and 21 deletions.
29 changes: 24 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
@@ -49,16 +49,37 @@ You can bind this to a keyboard shortcut by adding to your `keybindings.json`:

#### Peek and Go to Definition for Wiki Links

![peek-and-to-to-definition](demo/peek-and-go-to-definition.gif)
![peek-and-go-to-definition](demo/peek-and-go-to-definition.gif)

#### `cmd+shift+f` to Search Workspace for Notes with #tag
#### Create New Note On Missing Go To Definition

![tag-search](demo/tag-search.gif)
![create-note-on-missing-go-to-definition](demo/create-note-on-missing-go-to-definition.gif)

#### New Note Command

![new-note-command](demo/new-note-command.gif)

#### Peek References to Wiki Links

![peek-references-wiki-link](demo/peek-references-wiki-link.png)

#### Peek References to Tag

![peek-references-tag](demo/peek-references-tag.png)

#### Find All References to Wiki Links

![find-all-references-wiki-link](demo/find-all-references-wiki-link.png)

#### Find All References to Tag

![find-all-references-tag](demo/find-all-references-tag.png)

#### `cmd+shift+f` to Search Workspace for Notes with Tag

![tag-search](demo/tag-search.gif)


## dev

Run `npm install` first.
@@ -83,8 +104,6 @@ Run `npm install` first.

### TODO

- Add command to create file based on name in the wiki-link under the cursor
- Add command to create new note with name + heading
- Provide better support for ignore patterns, eg, don't complete `file.md` if it is within `ignored_dir/`
- Should we support filename without extension, eg, assume `[[file]]` is a reference to `file.md`?
- Should we support links to headings? eg, `file.md#heading-text`?
Binary file added demo/create-note-on-missing-go-to-definition.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added demo/find-all-references-tag.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added demo/find-all-references-wiki-link.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added demo/peek-references-tag.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added demo/peek-references-wiki-link.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
790 changes: 787 additions & 3 deletions package-lock.json

Large diffs are not rendered by default.

21 changes: 18 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
@@ -2,7 +2,7 @@
"name": "vscode-markdown-notes",
"displayName": "VS Code Markdown Notes",
"description": "Navigate notes with [[wiki-links]] and #tags (like Bear, Roam, etc). Use Peek Definition to preview linked notes. Quickly create new notes with a command.",
"version": "0.0.5",
"version": "0.0.6",
"publisher": "kortina",
"repository": {
"url": "https://github.com/kortina/vscode-markdown-notes.git",
@@ -57,6 +57,11 @@
],
"default": "uniqueFilenames",
"description": "By default, expect 'uniqueFilenames' for every `.md` file in workspace and treat `file.md` as link to file in any subdirectory. If you expect collisions in filenames for notes (eg, `note1/note.md` `note2/note.md`) use 'relativePaths' to render links between files."
},
"vscodeMarkdownNotes.createNoteOnGoToDefinitionWhenMissing": {
"type": "boolean",
"default": true,
"description": "By default, when invoking `editor.action.revealDefinition` on `[[note.md]]` if `note.md` does not exist in workspace, create it. NB: Works only when `vscodeMarkdownNotes.workspaceFilenameConvention` = 'uniqueFilenames'."
}
}
}
@@ -72,15 +77,25 @@
"vscode:prepublish": "npm run compile",
"compile": "tsc -p ./",
"lint": "eslint -c .eslintrc.js --ext .ts src",
"watch": "tsc -watch -p ./"
"watch": "tsc -watch -p ./",
"pretest": "npm run compile",
"test": "node ./out/test/runTest.js"
},
"devDependencies": {
"@types/chai": "^4.2.11",
"@types/glob": "^7.1.1",
"@types/mocha": "^5.2.6",
"@types/node": "^10.12.18",
"@types/vscode": "^1.32.0",
"@typescript-eslint/eslint-plugin": "^2.28.0",
"@typescript-eslint/parser": "^2.28.0",
"chai": "^4.2.0",
"glob": "^7.1.4",
"mocha": "^6.1.4",
"source-map-support": "^0.5.12",
"typescript": "^3.8.3",
"vscode-test": "^1.3.0",
"eslint": "^6.8.0",
"typescript": "^3.5.1",
"vsce": "^1.75.0"
}
}
216 changes: 210 additions & 6 deletions src/extension.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import * as vscode from 'vscode';
import { basename, dirname, join, normalize, relative, resolve } from 'path';
import { existsSync, readFile, writeFileSync } from 'fs';
const fsp = require('fs').promises;
import {
CompletionItemProvider,
TextDocument,
@@ -12,18 +13,27 @@ import {
CompletionItemKind,
Uri,
} from 'vscode';
import { NoteRefsTreeDataProvider } from './treeViewReferences';
import { debug } from 'util';
import { create } from 'domain';

function workspaceFilenameConvention(): string | undefined {
const workspaceFilenameConvention = (): string | undefined => {
let cfg = vscode.workspace.getConfiguration('vscodeMarkdownNotes');
return cfg.get('workspaceFilenameConvention');
}
function useUniqueFilenames(): boolean {
};

const useUniqueFilenames = (): boolean => {
return workspaceFilenameConvention() == 'uniqueFilenames';
}
};

function useRelativePaths(): boolean {
const useRelativePaths = (): boolean => {
return workspaceFilenameConvention() == 'relativePaths';
}
};

const createNoteOnGoToDefinitionWhenMissing = (): boolean => {
let cfg = vscode.workspace.getConfiguration('vscodeMarkdownNotes');
return !!cfg.get('createNoteOnGoToDefinitionWhenMissing');
};

function filenameForConvention(uri: Uri, fromDocument: TextDocument): string {
if (useUniqueFilenames()) {
@@ -63,6 +73,85 @@ class WorkspaceTagList {
}
}

export class ReferenceSearch {
// TODO/ FIXME: I wonder if instead of this just-in-time search through all the files,
// we should instead build the search index for all Tags and WikiLinks once on-boot
// and then just look in the index for the locations.
// In that case, we would need to implement some sort of change watcher,
// to know if our index needs to be updated.
// This is pretty brute force as it is.
//
// static TAG_WORD_SET = new Set();
// static STARTED_INIT = false;
// static COMPLETED_INIT = false;

static rangesForWordInDocumentData = (
queryWord: string | null,
data: string
): Array<vscode.Range> => {
let ranges: Array<vscode.Range> = [];
if (!queryWord) {
return [];
}
let lines = data.split(/[\r\n]/);
lines.map((line, lineNum) => {
let charNum = 0;
// https://stackoverflow.com/questions/17726904/javascript-splitting-a-string-yet-preserving-the-spaces
let words = line.split(/(\S+\s+)/);
words.map((word) => {
// console.log(`word: ${word} charNum: ${charNum}`);
let spacesBefore = word.length - word.trimLeft().length;
let trimmed = word.trim();
if (trimmed == queryWord) {
let r = new vscode.Range(
new vscode.Position(lineNum, charNum + spacesBefore),
// I thought we had to sub 1 to get the zero-based index of the last char of this word:
// new vscode.Position(lineNum, charNum + spacesBefore + trimmed.length - 1)
// but the highlighting is off if we do that ¯\_(ツ)_/¯
new vscode.Position(lineNum, charNum + spacesBefore + trimmed.length)
);
ranges.push(r);
}
charNum += word.length;
});
});
return ranges;
};

static async search(contextWord: ContextWord): Promise<vscode.Location[]> {
let locations: vscode.Location[] = [];
let query: string;
if (contextWord.type == ContextWordType.Tag) {
query = `#${contextWord.word}`;
} else if ((contextWord.type = ContextWordType.WikiLink)) {
query = `[[${basename(contextWord.word)}]]`;
} else {
return [];
}
// console.log(`query: ${query}`);
let files = (await workspace.findFiles('**/*')).filter(
// TODO: parameterize extensions. Add $ to end?
(f) => f.scheme == 'file' && f.path.match(/\.(md|markdown)/i)
);
let paths = files.map((f) => f.path);
let fileBuffers = await Promise.all(paths.map((p) => fsp.readFile(p)));
fileBuffers.map((data, i) => {
let path = files[i].path;
// console.debug('--------------------');
// console.log(path);
// console.log(`${data}`.split(/\n/)[0]);
let ranges = this.rangesForWordInDocumentData(query, `${data}`);
ranges.map((r) => {
let loc = new vscode.Location(Uri.file(path), r);
locations.push(loc);
});
});

// console.log(locations);
return locations;
}
}

enum ContextWordType {
Null, // 0
WikiLink, // 1
@@ -76,6 +165,16 @@ interface ContextWord {
range: vscode.Range | undefined;
}

const debugContextWord = (contextWord: ContextWord) => {
const { type, word, hasExtension, range } = contextWord;
console.debug({
type: ContextWordType[contextWord.type],
word: contextWord.word,
hasExtension: contextWord.hasExtension,
range: contextWord.range,
});
};

const NULL_CONTEXT_WORD = {
type: ContextWordType.Null,
word: '',
@@ -203,6 +302,7 @@ class MarkdownDefinitionProvider implements vscode.DefinitionProvider {
// console.debug('provideDefinition');

const contextWord = getContextWord(document, position);
// debugContextWord(contextWord);
if (contextWord.type != ContextWordType.WikiLink) {
// console.debug('getContextWord was not WikiLink');
return [];
@@ -243,9 +343,105 @@ class MarkdownDefinitionProvider implements vscode.DefinitionProvider {
}
}

// else, create the file
if (files.length == 0) {
const path = MarkdownDefinitionProvider.createMissingNote(contextWord);
if (path !== undefined) {
files.push(vscode.Uri.parse(`file://${path}`));
}
}

const p = new vscode.Position(0, 0);
return files.map((f) => new vscode.Location(f, p));
}

static createMissingNote = (contextWord: ContextWord): string | undefined => {
// don't create new files if contextWord is a Tag
if (contextWord.type != ContextWordType.WikiLink) {
return;
}
let cfg = vscode.workspace.getConfiguration('vscodeMarkdownNotes');
if (!createNoteOnGoToDefinitionWhenMissing()) {
return;
}
const filename = vscode.window.activeTextEditor?.document.fileName;
if (filename !== undefined) {
if (!useUniqueFilenames()) {
vscode.window.showWarningMessage(
`createNoteOnGoToDefinitionWhenMissing only works when vscodeMarkdownNotes.workspaceFilenameConvention = 'uniqueFilenames'`
);
return;
}
// add an extension if one does not exist
let mdFilename = contextWord.word.match(/\.(md|markdown)$/i)
? contextWord.word
: `${contextWord.word}.md`;
// by default, create new note in same dir as the current document
// TODO: could convert this to an option (to, eg, create in workspace root)
const path = `${dirname(filename)}/${mdFilename}`;
const title = titleCaseFilename(contextWord.word);
writeFileSync(path, `# ${title}\n\n`);
return path;
}
};
}

const capitalize = (word: string): string => {
if (!word) {
return word;
}
return `${word[0].toUpperCase()}${word.slice(1)}`;
};

export const titleCase = (sentence: string): string => {
if (!sentence) {
return sentence;
}
const chicagoStyleNoCap = `
a aboard about above across after against along amid among an and anti around as at before behind
below beneath beside besides between beyond but by concerning considering despite down during except
excepting excluding following for from in inside into like minus near of off on onto opposite or
outside over past per plus regarding round save since so than the through to toward towards under
underneath unlike until up upon versus via with within without yet
`.split(/\s/);
let words = sentence.split(/\s/);
return words
.map((word, i) => {
if (i == 0 || i == words.length - 1) {
return capitalize(word);
} else if (chicagoStyleNoCap.includes(word.toLocaleLowerCase())) {
return word;
} else {
return capitalize(word);
}
})
.join(' ');
};

export const titleCaseFilename = (filename: string): string => {
if (!filename) {
return filename;
}
return titleCase(
filename
.replace(/\.(md|markdown)$/, '')
.replace(/[-_]/gi, ' ')
.replace(/\s+/, ' ')
);
};

class MarkdownReferenceProvider implements vscode.ReferenceProvider {
public provideReferences(
document: TextDocument,
position: Position,
context: vscode.ReferenceContext,
token: CancellationToken
): vscode.ProviderResult<vscode.Location[]> {
// console.debug('MarkdownReferenceProvider.provideReferences');
const contextWord = getContextWord(document, position);
// debugContextWord(contextWord);
return ReferenceSearch.search(contextWord);
}
}

function newNote(context: vscode.ExtensionContext) {
@@ -327,11 +523,19 @@ export function activate(context: vscode.ExtensionContext) {
vscode.languages.registerDefinitionProvider(md, new MarkdownDefinitionProvider())
);

context.subscriptions.push(
vscode.languages.registerReferenceProvider(md, new MarkdownReferenceProvider())
);

let newNoteDisposable = vscode.commands.registerCommand('vscodeMarkdownNotes.newNote', newNote);
context.subscriptions.push(newNoteDisposable);

// parse the tags from every file in the workspace
// console.log(`WorkspaceTagList.STARTED_INIT.1: ${WorkspaceTagList.STARTED_INIT}`);
WorkspaceTagList.initSet();
// console.log(`WorkspaceTagList.STARTED_INIT.2: ${WorkspaceTagList.STARTED_INIT}`);

// const treeView = vscode.window.createTreeView('vscodeMarkdownNotesReferences', {
// treeDataProvider: new NoteRefsTreeDataProvider(vscode.workspace.rootPath || null),
// });
}
23 changes: 23 additions & 0 deletions src/test/runTest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import * as path from 'path';

import { runTests } from 'vscode-test';

async function main() {
try {
// The folder containing the Extension Manifest package.json
// Passed to `--extensionDevelopmentPath`
const extensionDevelopmentPath = path.resolve(__dirname, '../../');

// The path to the extension test script
// Passed to --extensionTestsPath
const extensionTestsPath = path.resolve(__dirname, './suite/index');

// Download VS Code, unzip it and run the integration test
await runTests({ extensionDevelopmentPath, extensionTestsPath });
} catch (err) {
console.error('Failed to run tests');
process.exit(1);
}
}

main();
38 changes: 38 additions & 0 deletions src/test/suite/extension.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import * as assert from 'assert';
var chai = require('chai');
var expect = chai.expect; // Using Expect style

// You can import and use all API from the 'vscode' module
// as well as import your extension to test it
import * as vscode from 'vscode';
// import * as myExtension from '../../extension';
import { ReferenceSearch, titleCaseFilename } from '../../extension';

suite('titleCase', () => {
test('titleCaseFilename', () => {
expect(titleCaseFilename('the-heat-is-on.md')).to.equal('The Heat Is On');
expect(titleCaseFilename('in-the-heat-of-the-night.md')).to.equal('In the Heat of the Night');
});
});

let document = `line0 word1
line1 word1 word2
[[test.md]] #tag #another_tag <- link at line2, chars 2-12
^ tags at line2 chars 15-19 and 21-32
[[test.md]] <- link at line4, chars 0-11
[[demo.md]] <- link at line5, chars 0-11
#tag word`; // line 5, chars 0-3
suite('ReferenceSearch', () => {
// vscode.window.showInformationMessage('Start ReferenceSearch.');

test('rangesForWordInDocumentData', () => {
expect(ReferenceSearch.rangesForWordInDocumentData('[[test.md]]', document)).to.eql([
new vscode.Range(2, 2, 2, 13),
new vscode.Range(4, 0, 4, 11),
]);
expect(ReferenceSearch.rangesForWordInDocumentData('#tag', document)).to.eql([
new vscode.Range(2, 15, 2, 19),
new vscode.Range(6, 0, 6, 4),
]);
});
});
38 changes: 38 additions & 0 deletions src/test/suite/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import * as path from 'path';
import * as Mocha from 'mocha';
import * as glob from 'glob';

export function run(): Promise<void> {
// Create the mocha test
const mocha = new Mocha({
ui: 'tdd',
});
mocha.useColors(true);

const testsRoot = path.resolve(__dirname, '..');

return new Promise((c, e) => {
glob('**/**.test.js', { cwd: testsRoot }, (err, files) => {
if (err) {
return e(err);
}

// Add files to the test suite
files.forEach((f) => mocha.addFile(path.resolve(testsRoot, f)));

try {
// Run the mocha test
mocha.run((failures) => {
if (failures > 0) {
e(new Error(`${failures} tests failed.`));
} else {
c();
}
});
} catch (err) {
console.error(err);
e(err);
}
});
});
}
54 changes: 54 additions & 0 deletions src/treeViewReferences.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import * as vscode from 'vscode';
import * as fs from 'fs';
import * as path from 'path';

export class NoteRefsTreeDataProvider implements vscode.TreeDataProvider<NoteRefTreeItem> {
constructor(private workspaceRoot: string | null) {}

getTreeItem(element: NoteRefTreeItem): vscode.TreeItem {
return element;
}

getChildren(element?: NoteRefTreeItem): Thenable<NoteRefTreeItem[]> {
if (!this.workspaceRoot) {
vscode.window.showInformationMessage('No refs in empty workspace');
return Promise.resolve([]);
}

if (!element) {
return Promise.resolve([
new NoteRefTreeItem(`root`, vscode.TreeItemCollapsibleState.Expanded),
]);
} else if (element.label == `root`) {
return Promise.resolve([
new NoteRefTreeItem(`child-1`, vscode.TreeItemCollapsibleState.None),
new NoteRefTreeItem(`child-2`, vscode.TreeItemCollapsibleState.None),
new NoteRefTreeItem(`child-3`, vscode.TreeItemCollapsibleState.None),
]);
} else {
return Promise.resolve([]);
}
}
}

class NoteRefTreeItem extends vscode.TreeItem {
constructor(
public readonly label: string,
public readonly collapsibleState: vscode.TreeItemCollapsibleState
) {
super(label, collapsibleState);
}

get tooltip(): string {
return `${this.label}`;
}

get description(): string {
return this.label;
}

iconPath = {
light: path.join(__filename, '..', '..', 'resources', 'light', 'dependency.svg'),
dark: path.join(__filename, '..', '..', 'resources', 'dark', 'dependency.svg'),
};
}
14 changes: 11 additions & 3 deletions test/sub/demo.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,15 @@
# Demo

`⌥F12` (Peek Definition) to preview Wiki Link inline
`⌥F12` (Peek Definition) to preview Wiki Link inline [[sub.md]] [[test.md]] // `cmd+k cmd+k`

`F12` (Go to Definition) to open Wiki Link
`F12` (Go to Definition) to open Wiki Link [[sub.md]] [[test.md]] // `ctrl+]`
`F12` (Go to Definition) createNoteOnGoToDefinitionWhenMissing [[in-the-heat-of-the-night.md]] // `ctrl+]`

[[sub.md]]
(Peek References) for Wiki Link [[test.md]] // `cmd+k cmd+n`
(Peek References) for Tag #tag // `cmd+k cmd+n`

`⇧F12` (Go To References) for Wiki Link [[sub.md]]
`⇧F12` (Go To References) for Tag #tag

`⇧⌥F12` (Find All References) for Wiki Link [[sub.md]]
`⇧⌥F12` (Find All References) for Tag #tag
7 changes: 7 additions & 0 deletions test/sub/extension-test-mirror.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
line0 word1
line1 word1 word2
[[test.md]] #tag #another_tag <- link at line2, chars 2-12
^ tags at line2 chars 15-19 and 21-32
[[test.md]] <- link at line4, chars 0-11
[[demo.md]] <- link at line5, chars 0-11
#tag word
2 changes: 2 additions & 0 deletions test/sub/sub.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
# sub

[[sub2.md]]

#tag
4 changes: 3 additions & 1 deletion test/sub/sub2.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
# sub2

[[demo.md]]
[[demo.md]] [[sub.md]] [[test.md]]

#another_tag

0 comments on commit db5f3c4

Please sign in to comment.