Keep foam in sync with file system (#349)

* added common code from vscode repo

lots of good utility functions and objects, especially around lifecycle and event management

* added datastore and logger services

* refactored bootstrap to consolidate behavior in foam-core

* tags treeview now updates when files are saved

* updated node engine version to match vscode's

* using new event model for foam graph events
This commit is contained in:
Riccardo
2020-11-20 12:04:07 +01:00
committed by GitHub
parent 846908e9d2
commit d054e19eae
35 changed files with 2310 additions and 269 deletions

View File

@@ -14,7 +14,8 @@ jobs:
steps:
- uses: actions/checkout@v1
- uses: actions/setup-node@v1
with:
node-version: '12'
- name: Install dependencies
run: yarn

View File

@@ -14,6 +14,8 @@ jobs:
steps:
- uses: actions/checkout@v1
- uses: actions/setup-node@v1
with:
node-version: '12'
- name: Install dependencies
run: yarn

View File

@@ -14,6 +14,8 @@ jobs:
steps:
- uses: actions/checkout@v1
- uses: actions/setup-node@v1
with:
node-version: '12'
- name: Install dependencies
run: yarn

View File

@@ -28,7 +28,7 @@
"lerna": "^3.22.1"
},
"engines": {
"node": ">=10"
"node": ">=12"
},
"husky": {
"hooks": {

View File

@@ -36,7 +36,7 @@
"foam-core": "*"
},
"engines": {
"node": ">=8.0.0"
"node": ">=12.0.0"
},
"files": [
"/bin",

View File

@@ -6,6 +6,8 @@ import {
generateLinkReferences,
generateHeading,
applyTextEdit,
Services,
FileDataStore,
} from 'foam-core';
import { writeFileToDisk } from '../utils/write-file-to-disk';
import { isValidDirectory } from '../utils';
@@ -38,8 +40,12 @@ export default class Janitor extends Command {
const { workspacePath = './' } = args;
if (isValidDirectory(workspacePath)) {
const graph = (await bootstrap(createConfigFromFolders([workspacePath])))
.notes;
const config = createConfigFromFolders([workspacePath]);
const services: Services = {
logger: console,
dataStore: new FileDataStore(config),
};
const graph = (await bootstrap(config, services)).notes;
const notes = graph.getNotes().filter(Boolean); // removes undefined notes

View File

@@ -7,6 +7,8 @@ import {
generateHeading,
getKebabCaseFileName,
applyTextEdit,
Services,
FileDataStore,
} from 'foam-core';
import { writeFileToDisk } from '../utils/write-file-to-disk';
import { renameFile } from '../utils/rename-file';
@@ -43,7 +45,11 @@ Successfully generated link references and heading!
const config = createConfigFromFolders([workspacePath]);
if (isValidDirectory(workspacePath)) {
let graph = (await bootstrap(config)).notes;
const services: Services = {
logger: console,
dataStore: new FileDataStore(config),
};
let graph = (await bootstrap(config, services)).notes;
let notes = graph.getNotes().filter(Boolean); // removes undefined notes
@@ -72,7 +78,7 @@ Successfully generated link references and heading!
spinner.text = 'Renaming files';
// Reinitialize the graph after renaming files
graph = (await bootstrap(config)).notes;
graph = (await bootstrap(config, services)).notes;
notes = graph.getNotes().filter(Boolean); // remove undefined notes

View File

@@ -19,6 +19,8 @@
"@types/github-slugger": "^1.3.0",
"@types/graphlib": "^2.1.6",
"@types/lodash": "^4.14.157",
"@types/micromatch": "^4.0.1",
"@types/picomatch": "^2.2.1",
"@types/string.prototype.matchall": "^4.0.0",
"husky": "^4.2.5",
"tsdx": "^0.13.2",
@@ -31,6 +33,7 @@
"glob": "^7.1.6",
"graphlib": "^2.1.8",
"lodash": "^4.17.19",
"micromatch": "^4.0.2",
"remark-frontmatter": "^2.0.0",
"remark-parse": "^8.0.2",
"remark-wiki-link": "^0.0.4",

View File

@@ -1,59 +1,47 @@
import glob from 'glob';
import { promisify } from 'util';
import fs from 'fs';
import os from 'os';
import detectNewline from 'detect-newline';
import { createGraph, NoteGraphAPI } from './note-graph';
import { createGraph } from './note-graph';
import { createMarkdownParser } from './markdown-provider';
import { FoamConfig, Foam } from './index';
import { FoamConfig, Foam, Services } from './index';
import { loadPlugins } from './plugins';
import { isNotNull } from './utils';
import { NoteParser } from './types';
import { isSome } from './utils';
import { isDisposable } from './common/lifecycle';
const findAllFiles = promisify(glob);
const loadNoteGraph = (
graph: NoteGraphAPI,
parser: NoteParser,
files: string[]
) => {
return Promise.all(
files.map(f => {
return fs.promises.readFile(f).then(data => {
const markdown = (data || '').toString();
const eol = detectNewline(markdown) || os.EOL;
graph.setNote(parser.parse(f, markdown, eol));
});
})
).then(() => graph);
};
export const bootstrap = async (config: FoamConfig) => {
export const bootstrap = async (config: FoamConfig, services: Services) => {
const plugins = await loadPlugins(config);
const middlewares = plugins
.map(p => p.graphMiddleware || null)
.filter(isNotNull);
const parserPlugins = plugins.map(p => p.parser || null).filter(isNotNull);
const parserPlugins = plugins.map(p => p.parser).filter(isSome);
const parser = createMarkdownParser(parserPlugins);
const files = await Promise.all(
config.workspaceFolders.map(folder => {
if (folder.substr(-1) === '/') {
folder = folder.slice(0, -1);
const graphMiddlewares = plugins.map(p => p.graphMiddleware).filter(isSome);
const graph = createGraph(graphMiddlewares);
const files = await services.dataStore.listFiles();
await Promise.all(
files.map(async uri => {
const content = await services.dataStore.read(uri);
if (isSome(content)) {
graph.setNote(parser.parse(uri, content));
}
return findAllFiles(`${folder}/**/*.md`, {});
})
);
const graph = await loadNoteGraph(
createGraph(middlewares),
parser,
([] as string[]).concat(...files)
);
services.dataStore.onDidChange(async uri => {
const content = await services.dataStore.read(uri);
graph.setNote(await parser.parse(uri, content));
});
services.dataStore.onDidCreate(async uri => {
const content = await services.dataStore.read(uri);
graph.setNote(await parser.parse(uri, content));
});
services.dataStore.onDidDelete(async uri => {
// TODO add deleteNote to graph
});
return {
notes: graph,
config: config,
parse: parser.parse,
dispose: () => {
isDisposable(services.dataStore) && services.dataStore.dispose();
},
} as Foam;
};

View File

@@ -0,0 +1,159 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
// taken from https://github.com/microsoft/vscode/tree/master/src/vs/base/common
import { Emitter, Event } from './event';
import { IDisposable } from './lifecycle';
export interface CancellationToken {
/**
* A flag signalling is cancellation has been requested.
*/
readonly isCancellationRequested: boolean;
/**
* An event which fires when cancellation is requested. This event
* only ever fires `once` as cancellation can only happen once. Listeners
* that are registered after cancellation will be called (next event loop run),
* but also only once.
*
* @event
*/
readonly onCancellationRequested: (
listener: (e: any) => any,
thisArgs?: any,
disposables?: IDisposable[]
) => IDisposable;
}
const shortcutEvent: Event<any> = Object.freeze(function(
callback,
context?
): IDisposable {
const handle = setTimeout(callback.bind(context), 0);
return {
dispose() {
clearTimeout(handle);
},
};
});
export namespace CancellationToken {
export function isCancellationToken(
thing: unknown
): thing is CancellationToken {
if (
thing === CancellationToken.None ||
thing === CancellationToken.Cancelled
) {
return true;
}
if (thing instanceof MutableToken) {
return true;
}
if (!thing || typeof thing !== 'object') {
return false;
}
return (
typeof (thing as CancellationToken).isCancellationRequested ===
'boolean' &&
typeof (thing as CancellationToken).onCancellationRequested === 'function'
);
}
export const None: CancellationToken = Object.freeze({
isCancellationRequested: false,
onCancellationRequested: Event.None,
});
export const Cancelled: CancellationToken = Object.freeze({
isCancellationRequested: true,
onCancellationRequested: shortcutEvent,
});
}
class MutableToken implements CancellationToken {
private _isCancelled: boolean = false;
private _emitter: Emitter<any> | null = null;
public cancel() {
if (!this._isCancelled) {
this._isCancelled = true;
if (this._emitter) {
this._emitter.fire(undefined);
this.dispose();
}
}
}
get isCancellationRequested(): boolean {
return this._isCancelled;
}
get onCancellationRequested(): Event<any> {
if (this._isCancelled) {
return shortcutEvent;
}
if (!this._emitter) {
this._emitter = new Emitter<any>();
}
return this._emitter.event;
}
public dispose(): void {
if (this._emitter) {
this._emitter.dispose();
this._emitter = null;
}
}
}
export class CancellationTokenSource {
private _token?: CancellationToken = undefined;
private _parentListener?: IDisposable = undefined;
constructor(parent?: CancellationToken) {
this._parentListener =
parent && parent.onCancellationRequested(this.cancel, this);
}
get token(): CancellationToken {
if (!this._token) {
// be lazy and create the token only when
// actually needed
this._token = new MutableToken();
}
return this._token;
}
cancel(): void {
if (!this._token) {
// save an object by returning the default
// cancelled token when cancellation happens
// before someone asks for the token
this._token = CancellationToken.Cancelled;
} else if (this._token instanceof MutableToken) {
// actually cancel
this._token.cancel();
}
}
dispose(cancel: boolean = false): void {
if (cancel) {
this.cancel();
}
if (this._parentListener) {
this._parentListener.dispose();
}
if (!this._token) {
// ensure to initialize with an empty token if we had none
this._token = CancellationToken.None;
} else if (this._token instanceof MutableToken) {
// actually dispose
this._token.dispose();
}
}
}

View File

@@ -0,0 +1,221 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
// taken from https://github.com/microsoft/vscode/tree/master/src/vs/base/common
export interface ErrorListenerCallback {
(error: any): void;
}
export interface ErrorListenerUnbind {
(): void;
}
// Avoid circular dependency on EventEmitter by implementing a subset of the interface.
export class ErrorHandler {
private unexpectedErrorHandler: (e: any) => void;
private listeners: ErrorListenerCallback[];
constructor() {
this.listeners = [];
this.unexpectedErrorHandler = function(e: any) {
setTimeout(() => {
if (e.stack) {
throw new Error(e.message + '\n\n' + e.stack);
}
throw e;
}, 0);
};
}
addListener(listener: ErrorListenerCallback): ErrorListenerUnbind {
this.listeners.push(listener);
return () => {
this._removeListener(listener);
};
}
private emit(e: any): void {
this.listeners.forEach(listener => {
listener(e);
});
}
private _removeListener(listener: ErrorListenerCallback): void {
this.listeners.splice(this.listeners.indexOf(listener), 1);
}
setUnexpectedErrorHandler(newUnexpectedErrorHandler: (e: any) => void): void {
this.unexpectedErrorHandler = newUnexpectedErrorHandler;
}
getUnexpectedErrorHandler(): (e: any) => void {
return this.unexpectedErrorHandler;
}
onUnexpectedError(e: any): void {
this.unexpectedErrorHandler(e);
this.emit(e);
}
// For external errors, we don't want the listeners to be called
onUnexpectedExternalError(e: any): void {
this.unexpectedErrorHandler(e);
}
}
export const errorHandler = new ErrorHandler();
export function setUnexpectedErrorHandler(
newUnexpectedErrorHandler: (e: any) => void
): void {
errorHandler.setUnexpectedErrorHandler(newUnexpectedErrorHandler);
}
export function onUnexpectedError(e: any): undefined {
// ignore errors from cancelled promises
if (!isPromiseCanceledError(e)) {
errorHandler.onUnexpectedError(e);
}
return undefined;
}
export function onUnexpectedExternalError(e: any): undefined {
// ignore errors from cancelled promises
if (!isPromiseCanceledError(e)) {
errorHandler.onUnexpectedExternalError(e);
}
return undefined;
}
export interface SerializedError {
readonly $isError: true;
readonly name: string;
readonly message: string;
readonly stack: string;
}
export function transformErrorForSerialization(error: Error): SerializedError;
export function transformErrorForSerialization(error: any): any;
export function transformErrorForSerialization(error: any): any {
if (error instanceof Error) {
let { name, message } = error;
const stack: string = (error as any).stacktrace || (error as any).stack;
return {
$isError: true,
name,
message,
stack,
};
}
// return as is
return error;
}
// see https://github.com/v8/v8/wiki/Stack%20Trace%20API#basic-stack-traces
export interface V8CallSite {
getThis(): any;
getTypeName(): string;
getFunction(): string;
getFunctionName(): string;
getMethodName(): string;
getFileName(): string;
getLineNumber(): number;
getColumnNumber(): number;
getEvalOrigin(): string;
isToplevel(): boolean;
isEval(): boolean;
isNative(): boolean;
isConstructor(): boolean;
toString(): string;
}
const canceledName = 'Canceled';
/**
* Checks if the given error is a promise in canceled state
*/
export function isPromiseCanceledError(error: any): boolean {
return (
error instanceof Error &&
error.name === canceledName &&
error.message === canceledName
);
}
/**
* Returns an error that signals cancellation.
*/
export function canceled(): Error {
const error = new Error(canceledName);
error.name = error.message;
return error;
}
export function illegalArgument(name?: string): Error {
if (name) {
return new Error(`Illegal argument: ${name}`);
} else {
return new Error('Illegal argument');
}
}
export function illegalState(name?: string): Error {
if (name) {
return new Error(`Illegal state: ${name}`);
} else {
return new Error('Illegal state');
}
}
export function readonly(name?: string): Error {
return name
? new Error(`readonly property '${name} cannot be changed'`)
: new Error('readonly property cannot be changed');
}
export function disposed(what: string): Error {
const result = new Error(`${what} has been disposed`);
result.name = 'DISPOSED';
return result;
}
export function getErrorMessage(err: any): string {
if (!err) {
return 'Error';
}
if (err.message) {
return err.message;
}
if (err.stack) {
return err.stack.split('\n')[0];
}
return String(err);
}
export class NotImplementedError extends Error {
constructor(message?: string) {
super('NotImplemented');
if (message) {
this.message = message;
}
}
}
export class NotSupportedError extends Error {
constructor(message?: string) {
super('NotSupported');
if (message) {
this.message = message;
}
}
}

View File

@@ -0,0 +1,954 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
// taken from https://github.com/microsoft/vscode/tree/master/src/vs/base/common
import { onUnexpectedError } from './errors';
import { once as onceFn } from './functional';
import {
Disposable,
IDisposable,
toDisposable,
combinedDisposable,
DisposableStore,
} from './lifecycle';
import { LinkedList } from './linkedList';
/**
* To an event a function with one or zero parameters
* can be subscribed. The event is the subscriber function itself.
*/
export interface Event<T> {
(
listener: (e: T) => any,
thisArgs?: any,
disposables?: IDisposable[] | DisposableStore
): IDisposable;
}
export namespace Event {
export const None: Event<any> = () => Disposable.None;
/**
* Given an event, returns another event which only fires once.
*/
export function once<T>(event: Event<T>): Event<T> {
return (listener, thisArgs = null, disposables?) => {
// we need this, in case the event fires during the listener call
let didFire = false;
let result: IDisposable;
result = event(
e => {
if (didFire) {
return;
} else if (result) {
result.dispose();
} else {
didFire = true;
}
return listener.call(thisArgs, e);
},
null,
disposables
);
if (didFire) {
result.dispose();
}
return result;
};
}
/**
* Given an event and a `map` function, returns another event which maps each element
* through the mapping function.
*/
export function map<I, O>(event: Event<I>, map: (i: I) => O): Event<O> {
return snapshot((listener, thisArgs = null, disposables?) =>
event(i => listener.call(thisArgs, map(i)), null, disposables)
);
}
/**
* Given an event and an `each` function, returns another identical event and calls
* the `each` function per each element.
*/
export function forEach<I>(event: Event<I>, each: (i: I) => void): Event<I> {
return snapshot((listener, thisArgs = null, disposables?) =>
event(
i => {
each(i);
listener.call(thisArgs, i);
},
null,
disposables
)
);
}
/**
* Given an event and a `filter` function, returns another event which emits those
* elements for which the `filter` function returns `true`.
*/
export function filter<T>(
event: Event<T>,
filter: (e: T) => boolean
): Event<T>;
export function filter<T, R>(
event: Event<T | R>,
filter: (e: T | R) => e is R
): Event<R>;
export function filter<T>(
event: Event<T>,
filter: (e: T) => boolean
): Event<T> {
return snapshot((listener, thisArgs = null, disposables?) =>
event(e => filter(e) && listener.call(thisArgs, e), null, disposables)
);
}
/**
* Given an event, returns the same event but typed as `Event<void>`.
*/
export function signal<T>(event: Event<T>): Event<void> {
return (event as Event<any>) as Event<void>;
}
/**
* Given a collection of events, returns a single event which emits
* whenever any of the provided events emit.
*/
export function any<T>(...events: Event<T>[]): Event<T>;
export function any(...events: Event<any>[]): Event<void>;
export function any<T>(...events: Event<T>[]): Event<T> {
return (listener, thisArgs = null, disposables?) =>
combinedDisposable(
...events.map(event =>
event(e => listener.call(thisArgs, e), null, disposables)
)
);
}
/**
* Given an event and a `merge` function, returns another event which maps each element
* and the cumulative result through the `merge` function. Similar to `map`, but with memory.
*/
export function reduce<I, O>(
event: Event<I>,
merge: (last: O | undefined, event: I) => O,
initial?: O
): Event<O> {
let output: O | undefined = initial;
return map<I, O>(event, e => {
output = merge(output, e);
return output;
});
}
/**
* Given a chain of event processing functions (filter, map, etc), each
* function will be invoked per event & per listener. Snapshotting an event
* chain allows each function to be invoked just once per event.
*/
export function snapshot<T>(event: Event<T>): Event<T> {
let listener: IDisposable;
const emitter = new Emitter<T>({
onFirstListenerAdd() {
listener = event(emitter.fire, emitter);
},
onLastListenerRemove() {
listener.dispose();
},
});
return emitter.event;
}
/**
* Debounces the provided event, given a `merge` function.
*
* @param event The input event.
* @param merge The reducing function.
* @param delay The debouncing delay in millis.
* @param leading Whether the event should fire in the leading phase of the timeout.
* @param leakWarningThreshold The leak warning threshold override.
*/
export function debounce<T>(
event: Event<T>,
merge: (last: T | undefined, event: T) => T,
delay?: number,
leading?: boolean,
leakWarningThreshold?: number
): Event<T>;
export function debounce<I, O>(
event: Event<I>,
merge: (last: O | undefined, event: I) => O,
delay?: number,
leading?: boolean,
leakWarningThreshold?: number
): Event<O>;
export function debounce<I, O>(
event: Event<I>,
merge: (last: O | undefined, event: I) => O,
delay: number = 100,
leading = false,
leakWarningThreshold?: number
): Event<O> {
let subscription: IDisposable;
let output: O | undefined = undefined;
let handle: any = undefined;
let numDebouncedCalls = 0;
const emitter = new Emitter<O>({
leakWarningThreshold,
onFirstListenerAdd() {
subscription = event(cur => {
numDebouncedCalls++;
output = merge(output, cur);
if (leading && !handle) {
emitter.fire(output);
output = undefined;
}
clearTimeout(handle);
handle = setTimeout(() => {
const _output = output;
output = undefined;
handle = undefined;
if (!leading || numDebouncedCalls > 1) {
emitter.fire(_output!);
}
numDebouncedCalls = 0;
}, delay);
});
},
onLastListenerRemove() {
subscription.dispose();
},
});
return emitter.event;
}
/**
* Given an event, it returns another event which fires only once and as soon as
* the input event emits. The event data is the number of millis it took for the
* event to fire.
*/
export function stopwatch<T>(event: Event<T>): Event<number> {
const start = new Date().getTime();
return map(once(event), _ => new Date().getTime() - start);
}
/**
* Given an event, it returns another event which fires only when the event
* element changes.
*/
export function latch<T>(event: Event<T>): Event<T> {
let firstCall = true;
let cache: T;
return filter(event, value => {
const shouldEmit = firstCall || value !== cache;
firstCall = false;
cache = value;
return shouldEmit;
});
}
/**
* Buffers the provided event until a first listener comes
* along, at which point fire all the events at once and
* pipe the event from then on.
*
* ```typescript
* const emitter = new Emitter<number>();
* const event = emitter.event;
* const bufferedEvent = buffer(event);
*
* emitter.fire(1);
* emitter.fire(2);
* emitter.fire(3);
* // nothing...
*
* const listener = bufferedEvent(num => console.log(num));
* // 1, 2, 3
*
* emitter.fire(4);
* // 4
* ```
*/
export function buffer<T>(
event: Event<T>,
nextTick = false,
_buffer: T[] = []
): Event<T> {
let buffer: T[] | null = _buffer.slice();
let listener: IDisposable | null = event(e => {
if (buffer) {
buffer.push(e);
} else {
emitter.fire(e);
}
});
const flush = () => {
if (buffer) {
buffer.forEach(e => emitter.fire(e));
}
buffer = null;
};
const emitter = new Emitter<T>({
onFirstListenerAdd() {
if (!listener) {
listener = event(e => emitter.fire(e));
}
},
onFirstListenerDidAdd() {
if (buffer) {
if (nextTick) {
setTimeout(flush, 0);
} else {
flush();
}
}
},
onLastListenerRemove() {
if (listener) {
listener.dispose();
}
listener = null;
},
});
return emitter.event;
}
export interface IChainableEvent<T> {
event: Event<T>;
map<O>(fn: (i: T) => O): IChainableEvent<O>;
forEach(fn: (i: T) => void): IChainableEvent<T>;
filter(fn: (e: T) => boolean): IChainableEvent<T>;
filter<R>(fn: (e: T | R) => e is R): IChainableEvent<R>;
reduce<R>(
merge: (last: R | undefined, event: T) => R,
initial?: R
): IChainableEvent<R>;
latch(): IChainableEvent<T>;
debounce(
merge: (last: T | undefined, event: T) => T,
delay?: number,
leading?: boolean,
leakWarningThreshold?: number
): IChainableEvent<T>;
debounce<R>(
merge: (last: R | undefined, event: T) => R,
delay?: number,
leading?: boolean,
leakWarningThreshold?: number
): IChainableEvent<R>;
on(
listener: (e: T) => any,
thisArgs?: any,
disposables?: IDisposable[] | DisposableStore
): IDisposable;
once(
listener: (e: T) => any,
thisArgs?: any,
disposables?: IDisposable[]
): IDisposable;
}
class ChainableEvent<T> implements IChainableEvent<T> {
constructor(readonly event: Event<T>) {}
map<O>(fn: (i: T) => O): IChainableEvent<O> {
return new ChainableEvent(map(this.event, fn));
}
forEach(fn: (i: T) => void): IChainableEvent<T> {
return new ChainableEvent(forEach(this.event, fn));
}
filter(fn: (e: T) => boolean): IChainableEvent<T>;
filter<R>(fn: (e: T | R) => e is R): IChainableEvent<R>;
filter(fn: (e: T) => boolean): IChainableEvent<T> {
return new ChainableEvent(filter(this.event, fn));
}
reduce<R>(
merge: (last: R | undefined, event: T) => R,
initial?: R
): IChainableEvent<R> {
return new ChainableEvent(reduce(this.event, merge, initial));
}
latch(): IChainableEvent<T> {
return new ChainableEvent(latch(this.event));
}
debounce(
merge: (last: T | undefined, event: T) => T,
delay?: number,
leading?: boolean,
leakWarningThreshold?: number
): IChainableEvent<T>;
debounce<R>(
merge: (last: R | undefined, event: T) => R,
delay?: number,
leading?: boolean,
leakWarningThreshold?: number
): IChainableEvent<R>;
debounce<R>(
merge: (last: R | undefined, event: T) => R,
delay: number = 100,
leading = false,
leakWarningThreshold?: number
): IChainableEvent<R> {
return new ChainableEvent(
debounce(this.event, merge, delay, leading, leakWarningThreshold)
);
}
on(
listener: (e: T) => any,
thisArgs: any,
disposables: IDisposable[] | DisposableStore
) {
return this.event(listener, thisArgs, disposables);
}
once(listener: (e: T) => any, thisArgs: any, disposables: IDisposable[]) {
return once(this.event)(listener, thisArgs, disposables);
}
}
export function chain<T>(event: Event<T>): IChainableEvent<T> {
return new ChainableEvent(event);
}
export interface NodeEventEmitter {
on(event: string | symbol, listener: Function): unknown;
removeListener(event: string | symbol, listener: Function): unknown;
}
export function fromNodeEventEmitter<T>(
emitter: NodeEventEmitter,
eventName: string,
map: (...args: any[]) => T = id => id
): Event<T> {
const fn = (...args: any[]) => result.fire(map(...args));
const onFirstListenerAdd = () => emitter.on(eventName, fn);
const onLastListenerRemove = () => emitter.removeListener(eventName, fn);
const result = new Emitter<T>({ onFirstListenerAdd, onLastListenerRemove });
return result.event;
}
export interface DOMEventEmitter {
addEventListener(event: string | symbol, listener: Function): void;
removeEventListener(event: string | symbol, listener: Function): void;
}
export function fromDOMEventEmitter<T>(
emitter: DOMEventEmitter,
eventName: string,
map: (...args: any[]) => T = id => id
): Event<T> {
const fn = (...args: any[]) => result.fire(map(...args));
const onFirstListenerAdd = () => emitter.addEventListener(eventName, fn);
const onLastListenerRemove = () =>
emitter.removeEventListener(eventName, fn);
const result = new Emitter<T>({ onFirstListenerAdd, onLastListenerRemove });
return result.event;
}
export function fromPromise<T = any>(promise: Promise<T>): Event<undefined> {
const emitter = new Emitter<undefined>();
let shouldEmit = false;
promise
.then(undefined, () => null)
.then(() => {
if (!shouldEmit) {
setTimeout(() => emitter.fire(undefined), 0);
} else {
emitter.fire(undefined);
}
});
shouldEmit = true;
return emitter.event;
}
export function toPromise<T>(event: Event<T>): Promise<T> {
return new Promise(c => once(event)(c));
}
}
type Listener<T> = [(e: T) => void, any] | ((e: T) => void);
export interface EmitterOptions {
onFirstListenerAdd?: Function;
onFirstListenerDidAdd?: Function;
onListenerDidAdd?: Function;
onLastListenerRemove?: Function;
leakWarningThreshold?: number;
}
let _globalLeakWarningThreshold = -1;
export function setGlobalLeakWarningThreshold(n: number): IDisposable {
const oldValue = _globalLeakWarningThreshold;
_globalLeakWarningThreshold = n;
return {
dispose() {
_globalLeakWarningThreshold = oldValue;
},
};
}
class LeakageMonitor {
private _stacks: Map<string, number> | undefined;
private _warnCountdown: number = 0;
constructor(
readonly customThreshold?: number,
readonly name: string = Math.random()
.toString(18)
.slice(2, 5)
) {}
dispose(): void {
if (this._stacks) {
this._stacks.clear();
}
}
check(listenerCount: number): undefined | (() => void) {
let threshold = _globalLeakWarningThreshold;
if (typeof this.customThreshold === 'number') {
threshold = this.customThreshold;
}
if (threshold <= 0 || listenerCount < threshold) {
return undefined;
}
if (!this._stacks) {
this._stacks = new Map();
}
const stack = new Error()
.stack!.split('\n')
.slice(3)
.join('\n');
const count = this._stacks.get(stack) || 0;
this._stacks.set(stack, count + 1);
this._warnCountdown -= 1;
if (this._warnCountdown <= 0) {
// only warn on first exceed and then every time the limit
// is exceeded by 50% again
this._warnCountdown = threshold * 0.5;
// find most frequent listener and print warning
let topStack: string | undefined;
let topCount: number = 0;
for (const [stack, count] of this._stacks) {
if (!topStack || topCount < count) {
topStack = stack;
topCount = count;
}
}
console.warn(
`[${this.name}] potential listener LEAK detected, having ${listenerCount} listeners already. MOST frequent listener (${topCount}):`
);
console.warn(topStack!);
}
return () => {
const count = this._stacks!.get(stack) || 0;
this._stacks!.set(stack, count - 1);
};
}
}
/**
* The Emitter can be used to expose an Event to the public
* to fire it from the insides.
* Sample:
class Document {
private readonly _onDidChange = new Emitter<(value:string)=>any>();
public onDidChange = this._onDidChange.event;
// getter-style
// get onDidChange(): Event<(value:string)=>any> {
// return this._onDidChange.event;
// }
private _doIt() {
//...
this._onDidChange.fire(value);
}
}
*/
export class Emitter<T> {
private static readonly _noop = function() {};
private readonly _options?: EmitterOptions;
private readonly _leakageMon?: LeakageMonitor;
private _disposed: boolean = false;
private _event?: Event<T>;
private _deliveryQueue?: LinkedList<[Listener<T>, T]>;
protected _listeners?: LinkedList<Listener<T>>;
constructor(options?: EmitterOptions) {
this._options = options;
this._leakageMon =
_globalLeakWarningThreshold > 0
? new LeakageMonitor(
this._options && this._options.leakWarningThreshold
)
: undefined;
}
/**
* For the public to allow to subscribe
* to events from this Emitter
*/
get event(): Event<T> {
if (!this._event) {
this._event = (
listener: (e: T) => any,
thisArgs?: any,
disposables?: IDisposable[] | DisposableStore
) => {
if (!this._listeners) {
this._listeners = new LinkedList();
}
const firstListener = this._listeners.isEmpty();
if (
firstListener &&
this._options &&
this._options.onFirstListenerAdd
) {
this._options.onFirstListenerAdd(this);
}
const remove = this._listeners.push(
!thisArgs ? listener : [listener, thisArgs]
);
if (
firstListener &&
this._options &&
this._options.onFirstListenerDidAdd
) {
this._options.onFirstListenerDidAdd(this);
}
if (this._options && this._options.onListenerDidAdd) {
this._options.onListenerDidAdd(this, listener, thisArgs);
}
// check and record this emitter for potential leakage
let removeMonitor: (() => void) | undefined;
if (this._leakageMon) {
removeMonitor = this._leakageMon.check(this._listeners.size);
}
let result: IDisposable;
result = {
dispose: () => {
if (removeMonitor) {
removeMonitor();
}
result.dispose = Emitter._noop;
if (!this._disposed) {
remove();
if (this._options && this._options.onLastListenerRemove) {
const hasListeners =
this._listeners && !this._listeners.isEmpty();
if (!hasListeners) {
this._options.onLastListenerRemove(this);
}
}
}
},
};
if (disposables instanceof DisposableStore) {
disposables.add(result);
} else if (Array.isArray(disposables)) {
disposables.push(result);
}
return result;
};
}
return this._event;
}
/**
* To be kept private to fire an event to
* subscribers
*/
fire(event: T): void {
if (this._listeners) {
// put all [listener,event]-pairs into delivery queue
// then emit all event. an inner/nested event might be
// the driver of this
if (!this._deliveryQueue) {
this._deliveryQueue = new LinkedList();
}
for (let listener of this._listeners) {
this._deliveryQueue.push([listener, event]);
}
while (this._deliveryQueue.size > 0) {
const [listener, event] = this._deliveryQueue.shift()!;
try {
if (typeof listener === 'function') {
listener.call(undefined, event);
} else {
listener[0].call(listener[1], event);
}
} catch (e) {
onUnexpectedError(e);
}
}
}
}
dispose() {
if (this._listeners) {
this._listeners.clear();
}
if (this._deliveryQueue) {
this._deliveryQueue.clear();
}
if (this._leakageMon) {
this._leakageMon.dispose();
}
this._disposed = true;
}
}
export class PauseableEmitter<T> extends Emitter<T> {
private _isPaused = 0;
private _eventQueue = new LinkedList<T>();
private _mergeFn?: (input: T[]) => T;
constructor(options?: EmitterOptions & { merge?: (input: T[]) => T }) {
super(options);
this._mergeFn = options && options.merge;
}
pause(): void {
this._isPaused++;
}
resume(): void {
if (this._isPaused !== 0 && --this._isPaused === 0) {
if (this._mergeFn) {
// use the merge function to create a single composite
// event. make a copy in case firing pauses this emitter
const events = Array.from(this._eventQueue);
this._eventQueue.clear();
super.fire(this._mergeFn(events));
} else {
// no merging, fire each event individually and test
// that this emitter isn't paused halfway through
while (!this._isPaused && this._eventQueue.size !== 0) {
super.fire(this._eventQueue.shift()!);
}
}
}
}
fire(event: T): void {
if (this._listeners) {
if (this._isPaused !== 0) {
this._eventQueue.push(event);
} else {
super.fire(event);
}
}
}
}
export interface IWaitUntil {
waitUntil(thenable: Promise<any>): void;
}
export class EventMultiplexer<T> implements IDisposable {
private readonly emitter: Emitter<T>;
private hasListeners = false;
private events: { event: Event<T>; listener: IDisposable | null }[] = [];
constructor() {
this.emitter = new Emitter<T>({
onFirstListenerAdd: () => this.onFirstListenerAdd(),
onLastListenerRemove: () => this.onLastListenerRemove(),
});
}
get event(): Event<T> {
return this.emitter.event;
}
add(event: Event<T>): IDisposable {
const e = { event: event, listener: null };
this.events.push(e);
if (this.hasListeners) {
this.hook(e);
}
const dispose = () => {
if (this.hasListeners) {
this.unhook(e);
}
const idx = this.events.indexOf(e);
this.events.splice(idx, 1);
};
return toDisposable(onceFn(dispose));
}
private onFirstListenerAdd(): void {
this.hasListeners = true;
this.events.forEach(e => this.hook(e));
}
private onLastListenerRemove(): void {
this.hasListeners = false;
this.events.forEach(e => this.unhook(e));
}
private hook(e: { event: Event<T>; listener: IDisposable | null }): void {
e.listener = e.event(r => this.emitter.fire(r));
}
private unhook(e: { event: Event<T>; listener: IDisposable | null }): void {
if (e.listener) {
e.listener.dispose();
}
e.listener = null;
}
dispose(): void {
this.emitter.dispose();
}
}
/**
* The EventBufferer is useful in situations in which you want
* to delay firing your events during some code.
* You can wrap that code and be sure that the event will not
* be fired during that wrap.
*
* ```
* const emitter: Emitter;
* const delayer = new EventDelayer();
* const delayedEvent = delayer.wrapEvent(emitter.event);
*
* delayedEvent(console.log);
*
* delayer.bufferEvents(() => {
* emitter.fire(); // event will not be fired yet
* });
*
* // event will only be fired at this point
* ```
*/
export class EventBufferer {
private buffers: Function[][] = [];
wrapEvent<T>(event: Event<T>): Event<T> {
return (listener, thisArgs?, disposables?) => {
return event(
i => {
const buffer = this.buffers[this.buffers.length - 1];
if (buffer) {
buffer.push(() => listener.call(thisArgs, i));
} else {
listener.call(thisArgs, i);
}
},
undefined,
disposables
);
};
}
bufferEvents<R = void>(fn: () => R): R {
const buffer: Array<() => R> = [];
this.buffers.push(buffer);
const r = fn();
this.buffers.pop();
buffer.forEach(flush => flush());
return r;
}
}
/**
* A Relay is an event forwarder which functions as a replugabble event pipe.
* Once created, you can connect an input event to it and it will simply forward
* events from that input event through its own `event` property. The `input`
* can be changed at any point in time.
*/
export class Relay<T> implements IDisposable {
private listening = false;
private inputEvent: Event<T> = Event.None;
private inputEventListener: IDisposable = Disposable.None;
private readonly emitter = new Emitter<T>({
onFirstListenerDidAdd: () => {
this.listening = true;
this.inputEventListener = this.inputEvent(
this.emitter.fire,
this.emitter
);
},
onLastListenerRemove: () => {
this.listening = false;
this.inputEventListener.dispose();
},
});
readonly event: Event<T> = this.emitter.event;
set input(event: Event<T>) {
this.inputEvent = event;
if (this.listening) {
this.inputEventListener.dispose();
this.inputEventListener = event(this.emitter.fire, this.emitter);
}
}
dispose() {
this.inputEventListener.dispose();
this.emitter.dispose();
}
}

View File

@@ -0,0 +1,23 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
// taken from https://github.com/microsoft/vscode/tree/master/src/vs/base/common
export function once<T extends Function>(this: unknown, fn: T): T {
const _this = this;
let didCall = false;
let result: unknown;
return (function() {
if (didCall) {
return result;
}
didCall = true;
result = fn.apply(_this, arguments);
return result;
} as unknown) as T;
}

View File

@@ -0,0 +1,111 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
// taken from https://github.com/microsoft/vscode/tree/master/src/vs/base/common
export namespace Iterable {
export function is<T = any>(thing: any): thing is IterableIterator<T> {
return (
thing &&
typeof thing === 'object' &&
typeof thing[Symbol.iterator] === 'function'
);
}
const _empty: Iterable<any> = Object.freeze([]);
export function empty<T = any>(): Iterable<T> {
return _empty;
}
export function* single<T>(element: T): Iterable<T> {
yield element;
}
export function from<T>(
iterable: Iterable<T> | undefined | null
): Iterable<T> {
return iterable || _empty;
}
export function first<T>(iterable: Iterable<T>): T | undefined {
return iterable[Symbol.iterator]().next().value;
}
export function some<T>(
iterable: Iterable<T>,
predicate: (t: T) => boolean
): boolean {
for (const element of iterable) {
if (predicate(element)) {
return true;
}
}
return false;
}
export function* filter<T>(
iterable: Iterable<T>,
predicate: (t: T) => boolean
): Iterable<T> {
for (const element of iterable) {
if (predicate(element)) {
yield element;
}
}
}
export function* map<T, R>(
iterable: Iterable<T>,
fn: (t: T) => R
): Iterable<R> {
for (const element of iterable) {
yield fn(element);
}
}
export function* concat<T>(...iterables: Iterable<T>[]): Iterable<T> {
for (const iterable of iterables) {
for (const element of iterable) {
yield element;
}
}
}
/**
* Consumes `atMost` elements from iterable and returns the consumed elements,
* and an iterable for the rest of the elements.
*/
export function consume<T>(
iterable: Iterable<T>,
atMost: number = Number.POSITIVE_INFINITY
): [T[], Iterable<T>] {
const consumed: T[] = [];
if (atMost === 0) {
return [consumed, iterable];
}
const iterator = iterable[Symbol.iterator]();
for (let i = 0; i < atMost; i++) {
const next = iterator.next();
if (next.done) {
return [consumed, Iterable.empty()];
}
consumed.push(next.value);
}
return [
consumed,
{
[Symbol.iterator]() {
return iterator;
},
},
];
}
}

View File

@@ -0,0 +1,300 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
// taken from https://github.com/microsoft/vscode/tree/master/src/vs/base/common
import { once } from './functional';
import { Iterable } from './iterator';
/**
* Enables logging of potentially leaked disposables.
*
* A disposable is considered leaked if it is not disposed or not registered as the child of
* another disposable. This tracking is very simple an only works for classes that either
* extend Disposable or use a DisposableStore. This means there are a lot of false positives.
*/
const TRACK_DISPOSABLES = false;
const __is_disposable_tracked__ = '__is_disposable_tracked__';
function markTracked<T extends IDisposable>(x: T): void {
if (!TRACK_DISPOSABLES) {
return;
}
if (x && x !== Disposable.None) {
try {
(x as any)[__is_disposable_tracked__] = true;
} catch {
// noop
}
}
}
function trackDisposable<T extends IDisposable>(x: T): T {
if (!TRACK_DISPOSABLES) {
return x;
}
const stack = new Error('Potentially leaked disposable').stack!;
setTimeout(() => {
if (!(x as any)[__is_disposable_tracked__]) {
console.log(stack);
}
}, 3000);
return x;
}
export class MultiDisposeError extends Error {
constructor(public readonly errors: any[]) {
super(
`Encounter errors while disposing of store. Errors: [${errors.join(
', '
)}]`
);
}
}
export interface IDisposable {
dispose(): void;
}
export function isDisposable<E extends object>(
thing: E
): thing is E & IDisposable {
return (
typeof (thing as IDisposable).dispose === 'function' &&
(thing as IDisposable).dispose.length === 0
);
}
export function dispose<T extends IDisposable>(disposable: T): T;
export function dispose<T extends IDisposable>(
disposable: T | undefined
): T | undefined;
export function dispose<
T extends IDisposable,
A extends IterableIterator<T> = IterableIterator<T>
>(disposables: IterableIterator<T>): A;
export function dispose<T extends IDisposable>(disposables: Array<T>): Array<T>;
export function dispose<T extends IDisposable>(
disposables: ReadonlyArray<T>
): ReadonlyArray<T>;
export function dispose<T extends IDisposable>(
arg: T | IterableIterator<T> | undefined
): any {
if (Iterable.is(arg)) {
let errors: any[] = [];
for (const d of arg) {
if (d) {
markTracked(d);
try {
d.dispose();
} catch (e) {
errors.push(e);
}
}
}
if (errors.length === 1) {
throw errors[0];
} else if (errors.length > 1) {
throw new MultiDisposeError(errors);
}
return Array.isArray(arg) ? [] : arg;
} else if (arg) {
markTracked(arg);
arg.dispose();
return arg;
}
}
export function combinedDisposable(...disposables: IDisposable[]): IDisposable {
disposables.forEach(markTracked);
return trackDisposable({ dispose: () => dispose(disposables) });
}
export function toDisposable(fn: () => void): IDisposable {
const self = trackDisposable({
dispose: () => {
markTracked(self);
fn();
},
});
return self;
}
export class DisposableStore implements IDisposable {
static DISABLE_DISPOSED_WARNING = false;
private _toDispose = new Set<IDisposable>();
private _isDisposed = false;
/**
* Dispose of all registered disposables and mark this object as disposed.
*
* Any future disposables added to this object will be disposed of on `add`.
*/
public dispose(): void {
if (this._isDisposed) {
return;
}
markTracked(this);
this._isDisposed = true;
this.clear();
}
/**
* Dispose of all registered disposables but do not mark this object as disposed.
*/
public clear(): void {
try {
dispose(this._toDispose.values());
} finally {
this._toDispose.clear();
}
}
public add<T extends IDisposable>(t: T): T {
if (!t) {
return t;
}
if (((t as unknown) as DisposableStore) === this) {
throw new Error('Cannot register a disposable on itself!');
}
markTracked(t);
if (this._isDisposed) {
if (!DisposableStore.DISABLE_DISPOSED_WARNING) {
console.warn(
new Error(
'Trying to add a disposable to a DisposableStore that has already been disposed of. The added object will be leaked!'
).stack
);
}
} else {
this._toDispose.add(t);
}
return t;
}
}
export abstract class Disposable implements IDisposable {
static readonly None = Object.freeze<IDisposable>({ dispose() {} });
private readonly _store = new DisposableStore();
constructor() {
trackDisposable(this);
}
public dispose(): void {
markTracked(this);
this._store.dispose();
}
protected _register<T extends IDisposable>(t: T): T {
if (((t as unknown) as Disposable) === this) {
throw new Error('Cannot register a disposable on itself!');
}
return this._store.add(t);
}
}
/**
* Manages the lifecycle of a disposable value that may be changed.
*
* This ensures that when the disposable value is changed, the previously held disposable is disposed of. You can
* also register a `MutableDisposable` on a `Disposable` to ensure it is automatically cleaned up.
*/
export class MutableDisposable<T extends IDisposable> implements IDisposable {
private _value?: T;
private _isDisposed = false;
constructor() {
trackDisposable(this);
}
get value(): T | undefined {
return this._isDisposed ? undefined : this._value;
}
set value(value: T | undefined) {
if (this._isDisposed || value === this._value) {
return;
}
if (this._value) {
this._value.dispose();
}
if (value) {
markTracked(value);
}
this._value = value;
}
clear() {
this.value = undefined;
}
dispose(): void {
this._isDisposed = true;
markTracked(this);
if (this._value) {
this._value.dispose();
}
this._value = undefined;
}
}
export interface IReference<T> extends IDisposable {
readonly object: T;
}
export abstract class ReferenceCollection<T> {
private readonly references: Map<
string,
{ readonly object: T; counter: number }
> = new Map();
acquire(key: string, ...args: any[]): IReference<T> {
let reference = this.references.get(key);
if (!reference) {
reference = {
counter: 0,
object: this.createReferencedObject(key, ...args),
};
this.references.set(key, reference);
}
const { object } = reference;
const dispose = once(() => {
if (--reference!.counter === 0) {
this.destroyReferencedObject(key, reference!.object);
this.references.delete(key);
}
});
reference.counter++;
return { object, dispose };
}
protected abstract createReferencedObject(key: string, ...args: any[]): T;
protected abstract destroyReferencedObject(key: string, object: T): void;
}
export class ImmortalReference<T> implements IReference<T> {
constructor(public object: T) {}
dispose(): void {
/* noop */
}
}

View File

@@ -0,0 +1,129 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
// taken from https://github.com/microsoft/vscode/tree/master/src/vs/base/common
class Node<E> {
static readonly Undefined = new Node<any>(undefined);
element: E;
next: Node<E>;
prev: Node<E>;
constructor(element: E) {
this.element = element;
this.next = Node.Undefined;
this.prev = Node.Undefined;
}
}
export class LinkedList<E> {
private _first: Node<E> = Node.Undefined;
private _last: Node<E> = Node.Undefined;
private _size: number = 0;
get size(): number {
return this._size;
}
isEmpty(): boolean {
return this._first === Node.Undefined;
}
clear(): void {
this._first = Node.Undefined;
this._last = Node.Undefined;
this._size = 0;
}
unshift(element: E): () => void {
return this._insert(element, false);
}
push(element: E): () => void {
return this._insert(element, true);
}
private _insert(element: E, atTheEnd: boolean): () => void {
const newNode = new Node(element);
if (this._first === Node.Undefined) {
this._first = newNode;
this._last = newNode;
} else if (atTheEnd) {
// push
const oldLast = this._last!;
this._last = newNode;
newNode.prev = oldLast;
oldLast.next = newNode;
} else {
// unshift
const oldFirst = this._first;
this._first = newNode;
newNode.next = oldFirst;
oldFirst.prev = newNode;
}
this._size += 1;
let didRemove = false;
return () => {
if (!didRemove) {
didRemove = true;
this._remove(newNode);
}
};
}
shift(): E | undefined {
if (this._first === Node.Undefined) {
return undefined;
} else {
const res = this._first.element;
this._remove(this._first);
return res;
}
}
pop(): E | undefined {
if (this._last === Node.Undefined) {
return undefined;
} else {
const res = this._last.element;
this._remove(this._last);
return res;
}
}
private _remove(node: Node<E>): void {
if (node.prev !== Node.Undefined && node.next !== Node.Undefined) {
// middle
const anchor = node.prev;
anchor.next = node.next;
node.next.prev = anchor;
} else if (node.prev === Node.Undefined && node.next === Node.Undefined) {
// only node
this._first = Node.Undefined;
this._last = Node.Undefined;
} else if (node.next === Node.Undefined) {
// last
this._last = this._last!.prev!;
this._last.next = Node.Undefined;
} else if (node.prev === Node.Undefined) {
// first
this._first = this._first!.next!;
this._first.prev = Node.Undefined;
}
// done
this._size -= 1;
}
*[Symbol.iterator](): Iterator<E> {
let node = this._first;
while (node !== Node.Undefined) {
yield node.element;
node = node.next;
}
}
}

View File

@@ -3,16 +3,26 @@ import { merge } from 'lodash';
export interface FoamConfig {
workspaceFolders: string[];
includeGlobs: string[];
ignoreGlobs: string[];
get<T>(path: string): T | undefined;
get<T>(path: string, defaultValue: T): T;
}
const DEFAULT_INCLUDES = ['**/*'];
const DEFAULT_IGNORES = ['**/node_modules/**'];
export const createConfigFromObject = (
workspaceFolders: string[],
include: string[],
ignore: string[],
settings: any
) => {
const config: FoamConfig = {
workspaceFolders: workspaceFolders,
includeGlobs: include,
ignoreGlobs: ignore,
get: <T>(path: string, defaultValue?: T) => {
const tokens = path.split('.');
const value = tokens.reduce((acc, t) => acc?.[t], settings);
@@ -23,7 +33,11 @@ export const createConfigFromObject = (
};
export const createConfigFromFolders = (
workspaceFolders: string[] | string
workspaceFolders: string[] | string,
options: {
include?: string[];
ignore?: string[];
} = {}
): FoamConfig => {
if (!Array.isArray(workspaceFolders)) {
workspaceFolders = [workspaceFolders];
@@ -42,7 +56,12 @@ export const createConfigFromFolders = (
const settings = merge(workspaceConfig, userConfig);
return createConfigFromObject(workspaceFolders, settings);
return createConfigFromObject(
workspaceFolders,
options.include ?? DEFAULT_INCLUDES,
options.ignore ?? DEFAULT_IGNORES,
settings
);
};
const parseConfig = (path: string) => {

View File

@@ -1,7 +1,13 @@
import { Note, NoteLink } from './types';
import { Note, NoteLink, URI } from './types';
import { NoteGraph, NoteGraphAPI } from './note-graph';
import { FoamConfig } from './config';
import { IDataStore, FileDataStore } from './services/datastore';
import { ILogger } from './services/logger';
export { IDataStore, FileDataStore };
export { ILogger };
export { IDisposable, isDisposable } from './common/lifecycle';
export { Event, Emitter } from './common/event';
export { FoamConfig };
export {
@@ -22,15 +28,20 @@ export { createConfigFromFolders } from './config';
export { bootstrap } from './bootstrap';
export { NoteGraph, NoteGraphAPI, Note, NoteLink };
export { NoteGraph, NoteGraphAPI, Note, NoteLink, URI };
export {
LINK_REFERENCE_DEFINITION_HEADER,
LINK_REFERENCE_DEFINITION_FOOTER,
} from './definitions';
export interface Services {
dataStore: IDataStore;
logger: ILogger;
}
export interface Foam {
notes: NoteGraphAPI;
config: FoamConfig;
parse: (uri: string, text: string, eol: string) => Note;
parse: (uri: URI, text: string, eol: string) => Note;
}

View File

@@ -5,6 +5,8 @@ import frontmatterPlugin from 'remark-frontmatter';
import { parse as parseYAML } from 'yaml';
import visit from 'unist-util-visit';
import { Parent, Point } from 'unist';
import detectNewline from 'detect-newline';
import os from 'os';
import * as path from 'path';
import { NoteGraphAPI } from './note-graph';
import { NoteLinkDefinition, Note, NoteParser } from './types';
@@ -113,8 +115,8 @@ export function createMarkdownParser(extraPlugins: ParserPlugin[]): NoteParser {
}
});
return {
parse: (uri: string, markdown: string, eol: string): Note => {
const foamParser: NoteParser = {
parse: (uri: string, markdown: string): Note => {
markdown = plugins.reduce((acc, plugin) => {
try {
return plugin.onWillParseMarkdown?.(acc) || acc;
@@ -124,6 +126,7 @@ export function createMarkdownParser(extraPlugins: ParserPlugin[]): NoteParser {
}
}, markdown);
const tree = parser.parse(markdown);
const eol = detectNewline(markdown) || os.EOL;
var note: Note = {
slug: uriToSlug(uri),
@@ -195,6 +198,7 @@ export function createMarkdownParser(extraPlugins: ParserPlugin[]): NoteParser {
return note;
},
};
return foamParser;
}
function getFoamDefinitions(

View File

@@ -1,7 +1,7 @@
import { Graph } from 'graphlib';
import { EventEmitter } from 'events';
import { URI, ID, Note, NoteLink } from './types';
import { computeRelativeURI } from './utils';
import { Event, Emitter } from './common/event';
export type GraphNote = Note & {
id: ID;
@@ -25,9 +25,9 @@ export interface NoteGraphAPI {
getAllLinks(noteId: ID): GraphConnection[];
getForwardLinks(noteId: ID): GraphConnection[];
getBacklinks(noteId: ID): GraphConnection[];
unstable_onNoteAdded(callback: NoteGraphEventHandler): void;
unstable_onNoteUpdated(callback: NoteGraphEventHandler): void;
unstable_removeEventListener(callback: NoteGraphEventHandler): void;
onDidAddNote: Event<GraphNote>;
onDidUpdateNote: Event<GraphNote>;
onDidRemoveNote: Event<GraphNote>;
}
export type Middleware = (next: NoteGraphAPI) => Partial<NoteGraphAPI>;
@@ -38,13 +38,21 @@ export const createGraph = (middlewares: Middleware[]): NoteGraphAPI => {
};
export class NoteGraph implements NoteGraphAPI {
onDidAddNote: Event<GraphNote>;
onDidUpdateNote: Event<GraphNote>;
onDidRemoveNote: Event<GraphNote>;
private graph: Graph;
private events: EventEmitter;
private createIdFromURI: (uri: URI) => ID;
private onDidAddNoteEmitter = new Emitter<GraphNote>();
private onDidUpdateNoteEmitter = new Emitter<GraphNote>();
private onDidRemoveNoteEmitter = new Emitter<GraphNote>();
constructor() {
this.graph = new Graph();
this.events = new EventEmitter();
this.onDidAddNote = this.onDidAddNoteEmitter.event;
this.onDidUpdateNote = this.onDidUpdateNoteEmitter.event;
this.onDidRemoveNote = this.onDidRemoveNoteEmitter.event;
this.createIdFromURI = uri => uri;
}
@@ -73,7 +81,9 @@ export class NoteGraph implements NoteGraphAPI {
};
this.graph.setEdge(graphNote.id, targetId, connection);
});
this.events.emit(noteExists ? 'update' : 'add', { note: graphNote });
noteExists
? this.onDidUpdateNoteEmitter.fire(graphNote)
: this.onDidAddNoteEmitter.fire(graphNote);
return graphNote;
}
@@ -117,21 +127,10 @@ export class NoteGraph implements NoteGraphAPI {
);
}
public unstable_onNoteAdded(callback: NoteGraphEventHandler) {
this.events.addListener('add', callback);
}
public unstable_onNoteUpdated(callback: NoteGraphEventHandler) {
this.events.addListener('update', callback);
}
public unstable_removeEventListener(callback: NoteGraphEventHandler) {
this.events.removeListener('add', callback);
this.events.removeListener('update', callback);
}
public dispose() {
this.events.removeAllListeners();
this.onDidAddNoteEmitter.dispose();
this.onDidUpdateNoteEmitter.dispose();
this.onDidRemoveNoteEmitter.dispose();
}
}
@@ -145,8 +144,8 @@ const backfill = (next: NoteGraphAPI, middleware: Middleware): NoteGraphAPI => {
getAllLinks: m.getAllLinks || next.getAllLinks,
getForwardLinks: m.getForwardLinks || next.getForwardLinks,
getBacklinks: m.getBacklinks || next.getBacklinks,
unstable_onNoteAdded: next.unstable_onNoteAdded.bind(next),
unstable_onNoteUpdated: next.unstable_onNoteUpdated.bind(next),
unstable_removeEventListener: next.unstable_removeEventListener.bind(next),
onDidAddNote: next.onDidAddNote,
onDidUpdateNote: next.onDidUpdateNote,
onDidRemoveNote: next.onDidRemoveNote,
};
};

View File

@@ -0,0 +1,119 @@
import glob from 'glob';
import { promisify } from 'util';
import micromatch from 'micromatch';
import fs from 'fs';
import { Event, Emitter } from '../common/event';
import { URI } from '../types';
import { FoamConfig } from '../config';
const findAllFiles = promisify(glob);
/**
* Represents a source of files and content
*/
export interface IDataStore {
/**
* List the files available in the store
*/
listFiles: () => Promise<URI[]>;
/**
* Read the content of the file from the store
*/
read: (uri: URI) => Promise<string>;
/**
* Returns whether the given URI is a match in
* this data store
*/
isMatch: (uri: URI) => boolean;
/**
* Filters a list of URIs based on whether they are a match
* in this data store
*/
match: (uris: URI[]) => string[];
/**
* An event which fires on file creation.
*/
onDidCreate: Event<URI>;
/**
* An event which fires on file change.
*/
onDidChange: Event<URI>;
/**
* An event which fires on file deletion.
*/
onDidDelete: Event<URI>;
}
/**
* File system based data store
*/
export class FileDataStore implements IDataStore {
readonly onDidChangeEmitter = new Emitter<URI>();
readonly onDidCreateEmitter = new Emitter<URI>();
readonly onDidDeleteEmitter = new Emitter<URI>();
readonly onDidCreate: Event<URI> = this.onDidCreateEmitter.event;
readonly onDidChange: Event<URI> = this.onDidChangeEmitter.event;
readonly onDidDelete: Event<URI> = this.onDidDeleteEmitter.event;
readonly isMatch: (uri: URI) => boolean;
readonly match: (uris: URI[]) => string[];
private _folders: readonly string[];
constructor(config: FoamConfig) {
this._folders = config.workspaceFolders;
let includeGlobs: string[] = [];
let ignoreGlobs: string[] = [];
config.workspaceFolders.forEach(folder => {
const withFolder = folderPlusGlob(folder);
includeGlobs.push(
...config.includeGlobs.map(glob => {
if (glob.endsWith('*')) {
glob = `${glob}\\.(md|mdx|markdown)`;
}
return withFolder(glob);
})
);
ignoreGlobs.push(...config.ignoreGlobs.map(withFolder));
});
this.match = (files: URI[]) => {
return micromatch(files, includeGlobs, {
ignore: ignoreGlobs,
nocase: true,
});
};
this.isMatch = uri => this.match([uri]).length > 0;
}
async listFiles() {
const files = (
await Promise.all(
this._folders.map(folder => {
return findAllFiles(folderPlusGlob(folder)('**/*'));
})
)
).flat();
return this.match(files);
}
async read(uri: URI) {
return (await fs.promises.readFile(uri)).toString();
}
}
const folderPlusGlob = (folder: string) => (glob: string): string => {
if (folder.substr(-1) === '/') {
folder = folder.slice(0, -1);
}
if (glob.startsWith('/')) {
glob = glob.slice(1);
}
return `${folder}/${glob}`;
};

View File

@@ -0,0 +1,7 @@
export interface ILogger {
log(message?: any, ...optionalParams: any[]): void;
debug(message?: any, ...optionalParams: any[]): void;
info(message?: any, ...optionalParams: any[]): void;
warn(message?: any, ...optionalParams: any[]): void;
error(message?: any, ...optionalParams: any[]): void;
}

View File

@@ -42,5 +42,5 @@ export interface Note {
}
export interface NoteParser {
parse: (uri: string, text: string, eol: string) => Note;
parse: (uri: string, text: string) => Note;
}

View File

@@ -3,14 +3,20 @@ import { NoteGraphAPI } from '../../src/note-graph';
import { generateHeading } from '../../src/janitor';
import { bootstrap } from '../../src/bootstrap';
import { createConfigFromFolders } from '../../src/config';
import { Services } from '../../src';
import { FileDataStore } from '../../src/services/datastore';
describe('generateHeadings', () => {
let _graph: NoteGraphAPI;
beforeAll(async () => {
const foam = await bootstrap(
createConfigFromFolders([path.join(__dirname, '../__scaffold__')])
);
const config = createConfigFromFolders([
path.join(__dirname, '../__scaffold__'),
]);
const services: Services = {
dataStore: new FileDataStore(config),
logger: console,
};
const foam = await bootstrap(config, services);
_graph = foam.notes;
});

View File

@@ -3,14 +3,21 @@ import { NoteGraphAPI } from '../../src/note-graph';
import { generateLinkReferences } from '../../src/janitor';
import { bootstrap } from '../../src/bootstrap';
import { createConfigFromFolders } from '../../src/config';
import { Services } from '../../src';
import { FileDataStore } from '../../src/services/datastore';
describe('generateLinkReferences', () => {
let _graph: NoteGraphAPI;
beforeAll(async () => {
_graph = await bootstrap(
createConfigFromFolders([path.join(__dirname, '../__scaffold__')])
).then(foam => foam.notes);
const config = createConfigFromFolders([
path.join(__dirname, '../__scaffold__'),
]);
const services: Services = {
dataStore: new FileDataStore(config),
logger: console,
};
_graph = await bootstrap(config, services).then(foam => foam.notes);
});
it('initialised test graph correctly', () => {

View File

@@ -37,11 +37,11 @@ const createNoteFromMarkdown = createMarkdownParser([]).parse;
describe('Markdown loader', () => {
it('Converts markdown to notes', () => {
const graph = new NoteGraph();
graph.setNote(createNoteFromMarkdown('/page-a.md', pageA, '\n'));
graph.setNote(createNoteFromMarkdown('/page-b.md', pageB, '\n'));
graph.setNote(createNoteFromMarkdown('/page-c.md', pageC, '\n'));
graph.setNote(createNoteFromMarkdown('/page-d.md', pageD, '\n'));
graph.setNote(createNoteFromMarkdown('/page-e.md', pageE, '\n'));
graph.setNote(createNoteFromMarkdown('/page-a.md', pageA));
graph.setNote(createNoteFromMarkdown('/page-b.md', pageB));
graph.setNote(createNoteFromMarkdown('/page-c.md', pageC));
graph.setNote(createNoteFromMarkdown('/page-d.md', pageD));
graph.setNote(createNoteFromMarkdown('/page-e.md', pageE));
expect(
graph
@@ -53,15 +53,11 @@ describe('Markdown loader', () => {
it('Parses wikilinks correctly', () => {
const graph = new NoteGraph();
const noteA = graph.setNote(
createNoteFromMarkdown('/page-a.md', pageA, '\n')
);
const noteB = graph.setNote(
createNoteFromMarkdown('/page-b.md', pageB, '\n')
);
graph.setNote(createNoteFromMarkdown('/page-c.md', pageC, '\n'));
graph.setNote(createNoteFromMarkdown('/page-d.md', pageD, '\n'));
graph.setNote(createNoteFromMarkdown('/page-e.md', pageE, '\n'));
const noteA = graph.setNote(createNoteFromMarkdown('/page-a.md', pageA));
const noteB = graph.setNote(createNoteFromMarkdown('/page-b.md', pageB));
graph.setNote(createNoteFromMarkdown('/page-c.md', pageC));
graph.setNote(createNoteFromMarkdown('/page-d.md', pageD));
graph.setNote(createNoteFromMarkdown('/page-e.md', pageE));
expect(
graph.getBacklinks(noteB.id).map(link => graph.getNote(link.from)!.slug)
@@ -75,9 +71,7 @@ describe('Markdown loader', () => {
describe('Note Title', () => {
it('should initialize note title if heading exists', () => {
const graph = new NoteGraph();
const note = graph.setNote(
createNoteFromMarkdown('/page-a.md', pageA, '\n')
);
const note = graph.setNote(createNoteFromMarkdown('/page-a.md', pageA));
const pageANoteTitle = graph.getNote(note.id)!.title;
expect(pageANoteTitle).toBe('Page A');
@@ -90,8 +84,7 @@ describe('Note Title', () => {
'/page-d.md',
`
This file has no heading.
`,
'\n'
`
)
);
@@ -111,8 +104,7 @@ date: 20-12-12
---
# Other Note Title
`,
'\n'
`
)
);
@@ -127,8 +119,7 @@ date: 20-12-12
#
this note has an empty title line
`,
'\n'
`
);
expect(note.title).toEqual('Hello Page');
});
@@ -146,8 +137,7 @@ title: Note Title
date: 20-12-12
---
# Other Note Title`,
'\n'
# Other Note Title`
)
);
@@ -172,8 +162,7 @@ date: 20-12-12
---
# Empty Frontmatter
`,
'\n'
`
)
);
@@ -196,8 +185,7 @@ title: - one
- #
---
`,
'\n'
`
)
);
@@ -213,10 +201,10 @@ describe('wikilinks definitions', () => {
it('can generate links without file extension when includeExtension = false', () => {
const graph = new NoteGraph();
const noteA = graph.setNote(
createNoteFromMarkdown('/dir1/page-a.md', pageA, '\n')
createNoteFromMarkdown('/dir1/page-a.md', pageA)
);
graph.setNote(createNoteFromMarkdown('/dir1/page-b.md', pageB, '\n'));
graph.setNote(createNoteFromMarkdown('/dir1/page-c.md', pageC, '\n'));
graph.setNote(createNoteFromMarkdown('/dir1/page-b.md', pageB));
graph.setNote(createNoteFromMarkdown('/dir1/page-c.md', pageC));
const noExtRefs = createMarkdownReferences(graph, noteA.id, false);
expect(noExtRefs.map(r => r.url)).toEqual(['page-b', 'page-c']);
@@ -225,10 +213,10 @@ describe('wikilinks definitions', () => {
it('can generate links with file extension when includeExtension = true', () => {
const graph = new NoteGraph();
const noteA = graph.setNote(
createNoteFromMarkdown('/dir1/page-a.md', pageA, '\n')
createNoteFromMarkdown('/dir1/page-a.md', pageA)
);
graph.setNote(createNoteFromMarkdown('/dir1/page-b.md', pageB, '\n'));
graph.setNote(createNoteFromMarkdown('/dir1/page-c.md', pageC, '\n'));
graph.setNote(createNoteFromMarkdown('/dir1/page-b.md', pageB));
graph.setNote(createNoteFromMarkdown('/dir1/page-c.md', pageC));
const extRefs = createMarkdownReferences(graph, noteA.id, true);
expect(extRefs.map(r => r.url)).toEqual(['page-b.md', 'page-c.md']);
@@ -237,10 +225,10 @@ describe('wikilinks definitions', () => {
it('use relative paths', () => {
const graph = new NoteGraph();
const noteA = graph.setNote(
createNoteFromMarkdown('/dir1/page-a.md', pageA, '\n')
createNoteFromMarkdown('/dir1/page-a.md', pageA)
);
graph.setNote(createNoteFromMarkdown('/dir2/page-b.md', pageB, '\n'));
graph.setNote(createNoteFromMarkdown('/dir3/page-c.md', pageC, '\n'));
graph.setNote(createNoteFromMarkdown('/dir2/page-b.md', pageB));
graph.setNote(createNoteFromMarkdown('/dir3/page-c.md', pageC));
const extRefs = createMarkdownReferences(graph, noteA.id, true);
expect(extRefs.map(r => r.url)).toEqual([
@@ -257,8 +245,7 @@ describe('tags plugin', () => {
`
# this is a heading
this is some #text that includes #tags we #care-about.
`,
'\n'
`
);
expect(noteA.tags).toEqual(new Set(['text', 'tags', 'care-about']));
});
@@ -272,8 +259,7 @@ tags: hello, world this_is_good
---
# this is a heading
this is some #text that includes #tags we #care-about.
`,
'\n'
`
);
expect(noteA.tags).toEqual(
new Set(['text', 'tags', 'care-about', 'hello', 'world', 'this_is_good'])
@@ -289,8 +275,7 @@ tags: [hello, world, this_is_good]
---
# this is a heading
this is some #text that includes #tags we #care-about.
`,
'\n'
`
);
expect(noteA.tags).toEqual(
new Set(['text', 'tags', 'care-about', 'hello', 'world', 'this_is_good'])
@@ -314,8 +299,7 @@ describe('parser plugins', () => {
`
This is a test note without headings.
But with some content.
`,
'\n'
`
);
expect(note1.properties.hasHeading).toBeUndefined();
@@ -323,8 +307,7 @@ But with some content.
'/path/to/a',
`
# This is a note with header
and some content`,
'\n'
and some content`
);
expect(note2.properties.hasHeading).toBeTruthy();
});

View File

@@ -5,7 +5,7 @@ import { createGraph } from '../src/note-graph';
import { createTestNote } from './core.test';
import { FoamConfig, createConfigFromObject } from '../src/config';
const config: FoamConfig = createConfigFromObject([], {
const config: FoamConfig = createConfigFromObject([], [], [], {
experimental: {
localPlugins: {
enabled: true,
@@ -16,10 +16,10 @@ const config: FoamConfig = createConfigFromObject([], {
describe('Foam plugins', () => {
it('will not load if feature is not explicitly enabled', async () => {
let plugins = await loadPlugins(createConfigFromObject([], {}));
let plugins = await loadPlugins(createConfigFromObject([], [], [], {}));
expect(plugins.length).toEqual(0);
plugins = await loadPlugins(
createConfigFromObject([], {
createConfigFromObject([], [], [], {
experimental: {
localPlugins: {},
},
@@ -27,7 +27,7 @@ describe('Foam plugins', () => {
);
expect(plugins.length).toEqual(0);
plugins = await loadPlugins(
createConfigFromObject([], {
createConfigFromObject([], [], [], {
experimental: {
localPlugins: {
enabled: false,
@@ -63,8 +63,7 @@ describe('Foam plugins', () => {
'/path/to/a',
`
# This is a note with header
and some content`,
'\n'
and some content`
);
expect(note.properties.hasHeading).toBeTruthy();
});

View File

@@ -5,7 +5,8 @@
"composite": true,
"esModuleInterop": true,
"importHelpers": true,
"downlevelIteration": true,
"target": "es2019",
// commonjs module format is used so that the incremental
// tsc build-mode ran during development can replace individual
// files (as opposed to generate the .cjs.development.js bundle.
@@ -19,11 +20,11 @@
"sourceMap": true,
"strict": true,
"lib": [
"esnext"
"ES2019"
]
},
"include": [
"src",
"types"
]
}
}

View File

@@ -1,129 +1,61 @@
/**
* Adapted from vscode-markdown/src/toc.ts
* https://github.com/yzhang-gh/vscode-markdown/blob/master/src/toc.ts
*/
"use strict";
import path from "path";
import * as fs from "fs";
import {
workspace,
ExtensionContext,
window,
EndOfLine,
Uri,
FileSystemWatcher
} from "vscode";
import { workspace, ExtensionContext } from "vscode";
import {
bootstrap as foamBootstrap,
bootstrap,
FoamConfig,
Foam,
createConfigFromFolders
FileDataStore,
Services,
isDisposable
} from "foam-core";
import { features } from "./features";
import { getIgnoredFilesSetting } from "./settings";
import { getConfigFromVscode } from "./services/config";
let workspaceWatcher: FileSystemWatcher | null = null;
let foam: Foam | null = null;
export function activate(context: ExtensionContext) {
export async function activate(context: ExtensionContext) {
try {
const foamPromise = bootstrap();
const config: FoamConfig = getConfigFromVscode();
const dataStore = new FileDataStore(config);
const watcher = workspace.createFileSystemWatcher("**/*");
watcher.onDidCreate(uri => {
if (dataStore.isMatch(uri.fsPath)) {
dataStore.onDidCreateEmitter.fire(uri.fsPath);
}
});
watcher.onDidChange(uri => {
if (dataStore.isMatch(uri.fsPath)) {
dataStore.onDidChangeEmitter.fire(uri.fsPath);
}
});
watcher.onDidDelete(uri => {
if (dataStore.isMatch(uri.fsPath)) {
dataStore.onDidDeleteEmitter.fire(uri.fsPath);
}
});
const services: Services = {
logger: console,
dataStore: dataStore
};
const foamPromise: Promise<Foam> = bootstrap(config, services);
features.forEach(f => {
f.activate(context, foamPromise);
});
foam = await foamPromise;
} catch (e) {
console.log("An error occurred while bootstrapping Foam", e);
}
}
export function deactivate() {
workspaceWatcher?.dispose();
}
function isLocalMarkdownFile(uri: Uri) {
return uri.scheme === "file" && uri.path.match(/\.(md|mdx|markdown)/i);
}
async function registerFiles(foam: Foam, localUri: Iterable<Uri>) {
for (const uri of localUri) {
registerFile(foam, uri);
if (isDisposable(foam)) {
foam?.dispose();
}
}
async function registerFile(foam: Foam, localUri: Uri) {
// read file from disk (async)
const path = localUri.fsPath;
const data = await fs.promises.readFile(path);
const markdown = (data || "").toString();
// create note
const eol =
window.activeTextEditor?.document?.eol === EndOfLine.CRLF ? "\r\n" : "\n";
const note = foam.parse(path, markdown, eol);
// add to graph
foam.notes.setNote(note);
return note;
}
/**
* Filter the files and register them in the Foam object.
* Filtering is done according to:
* 1. Extension (currently `.md`, `.mdx`, `.markdown`)
* 2. Excluded globs set by the user in `foam.files.ignore`
* @param foam the Foam object.
* @param files the list of files to be filtered and registered.
*/
async function filterAndRegister(foam: Foam, files: Uri[]) {
const excludedPaths: string[] = getIgnoredFilesSetting();
const includedFiles: Map<String, Uri> = new Map();
for (const included of files) {
if (isLocalMarkdownFile(included)) {
includedFiles.set(included.fsPath, included);
}
}
for (const excluded of excludedPaths) {
for (const file of await workspace.findFiles(excluded)) {
includedFiles.delete(file.fsPath);
}
}
registerFiles(foam, includedFiles.values());
}
const bootstrap = async () => {
const config: FoamConfig = getConfig();
const foam: Foam = await foamBootstrap(config);
await workspace
.findFiles("**/*")
.then(files => filterAndRegister(foam, files));
workspaceWatcher = workspace.createFileSystemWatcher(
"**/*",
false,
true,
true
);
workspaceWatcher.onDidCreate(uri => {
if (isLocalMarkdownFile(uri)) {
registerFile(foam, uri).then(() => {
console.log(`Added ${uri} to workspace`);
});
}
});
return foam;
};
export const getConfig = (): FoamConfig => {
const workspaceFolders = workspace
.workspaceFolders!.filter(dir => {
const foamPath = path.join(dir.uri.fsPath, ".foam");
return fs.existsSync(foamPath) && fs.statSync(foamPath).isDirectory();
})
.map(dir => dir.uri.fsPath);
return createConfigFromFolders(workspaceFolders);
};

View File

@@ -16,10 +16,8 @@ const feature: FoamFeature = {
updateGraph(panel, foam);
};
foam.notes.unstable_onNoteAdded(onNoteAdded);
panel.onDidDispose(() => {
foam.notes.unstable_removeEventListener(onNoteAdded);
});
const noteAddedListener = foam.notes.onDidAddNote(onNoteAdded);
panel.onDidDispose(() => noteAddedListener.dispose());
vscode.window.onDidChangeActiveTextEditor(e => {
if (e.document.uri.scheme === "file") {

View File

@@ -1,6 +1,5 @@
import * as vscode from "vscode";
import { FoamFeature } from "../../types";
import * as path from "path";
import { Foam, Note } from "foam-core";
const feature: FoamFeature = {
@@ -8,12 +7,15 @@ const feature: FoamFeature = {
context: vscode.ExtensionContext,
foamPromise: Promise<Foam>
) => {
const foam = await foamPromise;
const provider = new TagsProvider(foam);
context.subscriptions.push(
vscode.window.registerTreeDataProvider(
"foam-vscode.tags-explorer",
new TagsProvider(await foamPromise)
provider
)
);
foam.notes.onDidUpdateNote(() => provider.refresh());
}
};
@@ -24,20 +26,34 @@ export class TagsProvider implements vscode.TreeDataProvider<TagTreeItem> {
private _onDidChangeTreeData: vscode.EventEmitter<TagTreeItem | undefined | void> = new vscode.EventEmitter<TagTreeItem | undefined | void>();
// prettier-ignore
readonly onDidChangeTreeData: vscode.Event<TagTreeItem | undefined | void> = this._onDidChangeTreeData.event;
private _tags: { [key: string]: string[] };
private tags: {
tag: string;
noteIds: string[];
}[];
constructor(private foam: Foam) {
this._tags = foam.notes.getNotes().reduce((acc, note) => {
this.computeTags();
}
refresh(): void {
this.computeTags();
this._onDidChangeTreeData.fire();
}
private computeTags() {
const rawTags: {
[key: string]: string[];
} = this.foam.notes.getNotes().reduce((acc, note) => {
note.tags.forEach(tag => {
acc[tag] = acc[tag] ?? [];
acc[tag].push(note.id);
});
return acc;
}, {});
}
refresh(): void {
this._onDidChangeTreeData.fire();
this.tags = Object.entries(rawTags)
.map(([tag, noteIds]) => ({ tag, noteIds }))
.sort((a, b) => a.tag.localeCompare(b.tag));
}
getTreeItem(element: TagTreeItem): vscode.TreeItem {
@@ -56,8 +72,8 @@ export class TagsProvider implements vscode.TreeDataProvider<TagTreeItem> {
]);
}
if (!element) {
const tags: Tag[] = Object.entries(this._tags).map(
([tag, noteIds]) => new Tag(tag, noteIds)
const tags: Tag[] = this.tags.map(
({ tag, noteIds }) => new Tag(tag, noteIds)
);
return Promise.resolve(tags.sort((a, b) => a.tag.localeCompare(b.tag)));
}
@@ -70,7 +86,7 @@ export class Tag extends vscode.TreeItem {
constructor(public readonly tag: string, public readonly noteIds: string[]) {
super(tag, vscode.TreeItemCollapsibleState.Collapsed);
this.description = `${this.noteIds.length} reference${
this.noteIds.length != 1 ? "s" : ""
this.noteIds.length !== 1 ? "s" : ""
}`;
this.tooltip = this.description;
}

View File

@@ -58,7 +58,7 @@ const feature: FoamFeature = {
// when a file is created as a result of peekDefinition
// action on a wikilink, add definition update references
foam.notes.unstable_onNoteAdded(e => {
foam.notes.onDidAddNote(_ => {
let editor = window.activeTextEditor;
if (!editor || !isMdEditor(editor)) {
return;

View File

@@ -0,0 +1,17 @@
import { workspace } from "vscode";
import { FoamConfig, createConfigFromFolders } from "foam-core";
import { getIgnoredFilesSetting } from "../settings";
// TODO this is still to be improved - foam config should
// not be dependent on vscode but at the moment it's convenient
// to leverage it
export const getConfigFromVscode = (): FoamConfig => {
const workspaceFolders = workspace.workspaceFolders.map(
dir => dir.uri.fsPath
);
const excludeGlobs: string[] = getIgnoredFilesSetting();
return createConfigFromFolders(workspaceFolders, {
ignore: excludeGlobs
});
};

View File

@@ -157,7 +157,8 @@ export function pathExists(path: string) {
* @param value The object to verify
*/
export function isSome<T>(value: T | null | undefined | void): value is T {
return value != null;
//
return value != null; // eslint-disable-line
}
/**
@@ -168,7 +169,7 @@ export function isSome<T>(value: T | null | undefined | void): value is T {
export function isNone<T>(
value: T | null | undefined | void
): value is null | undefined | void {
return value == null;
return value == null; // eslint-disable-line
}
export async function focusNote(notePath: string, moveCursorToEnd: boolean) {

View File

@@ -2681,6 +2681,11 @@
dependencies:
"@babel/types" "^7.3.0"
"@types/braces@*":
version "3.0.0"
resolved "https://registry.yarnpkg.com/@types/braces/-/braces-3.0.0.tgz#7da1c0d44ff1c7eb660a36ec078ea61ba7eb42cb"
integrity sha512-TbH79tcyi9FHwbyboOKeRachRq63mSuWYXOflsNO9ZyE5ClQ/JaozNKl+aWUq87qPNsXasXxi2AbgfwIJ+8GQw==
"@types/color-name@^1.1.1":
version "1.1.1"
resolved "https://registry.yarnpkg.com/@types/color-name/-/color-name-1.1.1.tgz#1c1261bbeaa10a8055bbc5d8ab84b7b2afc846a0"
@@ -2773,6 +2778,13 @@
resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.157.tgz#fdac1c52448861dfde1a2e1515dbc46e54926dc8"
integrity sha512-Ft5BNFmv2pHDgxV5JDsndOWTRJ+56zte0ZpYLowp03tW+K+t8u8YMOzAnpuqPgzX6WO1XpDIUm7u04M8vdDiVQ==
"@types/micromatch@^4.0.1":
version "4.0.1"
resolved "https://registry.yarnpkg.com/@types/micromatch/-/micromatch-4.0.1.tgz#9381449dd659fc3823fd2a4190ceacc985083bc7"
integrity sha512-my6fLBvpY70KattTNzYOK6KU1oR1+UCz9ug/JbcF5UrEmeCt9P7DV2t7L8+t18mMPINqGQCE4O8PLOPbI84gxw==
dependencies:
"@types/braces" "*"
"@types/minimatch@*":
version "3.0.3"
resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-3.0.3.tgz#3dca0e3f33b200fc7d1139c0cd96c1268cadfd9d"
@@ -2808,6 +2820,11 @@
resolved "https://registry.yarnpkg.com/@types/parse-json/-/parse-json-4.0.0.tgz#2f8bb441434d163b35fb8ffdccd7138927ffb8c0"
integrity sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==
"@types/picomatch@^2.2.1":
version "2.2.1"
resolved "https://registry.yarnpkg.com/@types/picomatch/-/picomatch-2.2.1.tgz#f9e5a5e6ad03996832975ab7eadfa35791ca2a8f"
integrity sha512-26/tQcDmJXYHiaWAAIjnTVL5nwrT+IVaqFZIbBImAuKk/r/j1r/1hmZ7uaOzG6IknqP3QHcNNQ6QO8Vp28lUoA==
"@types/prettier@^2.0.0":
version "2.0.2"
resolved "https://registry.yarnpkg.com/@types/prettier/-/prettier-2.0.2.tgz#5bb52ee68d0f8efa9cc0099920e56be6cc4e37f3"