mirror of
https://github.com/foambubble/foam.git
synced 2026-01-09 14:08:13 -05:00
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:
3
.github/workflows/foam-cli.yml
vendored
3
.github/workflows/foam-cli.yml
vendored
@@ -14,7 +14,8 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v1
|
||||
- uses: actions/setup-node@v1
|
||||
|
||||
with:
|
||||
node-version: '12'
|
||||
- name: Install dependencies
|
||||
run: yarn
|
||||
|
||||
|
||||
2
.github/workflows/foam-core.yml
vendored
2
.github/workflows/foam-core.yml
vendored
@@ -14,6 +14,8 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v1
|
||||
- uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: '12'
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn
|
||||
|
||||
2
.github/workflows/foam-vscode.yml
vendored
2
.github/workflows/foam-vscode.yml
vendored
@@ -14,6 +14,8 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v1
|
||||
- uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: '12'
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
"lerna": "^3.22.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
"node": ">=12"
|
||||
},
|
||||
"husky": {
|
||||
"hooks": {
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
"foam-core": "*"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
"node": ">=12.0.0"
|
||||
},
|
||||
"files": [
|
||||
"/bin",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
159
packages/foam-core/src/common/cancellation.ts
Normal file
159
packages/foam-core/src/common/cancellation.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
221
packages/foam-core/src/common/errors.ts
Normal file
221
packages/foam-core/src/common/errors.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
954
packages/foam-core/src/common/event.ts
Normal file
954
packages/foam-core/src/common/event.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
23
packages/foam-core/src/common/functional.ts
Normal file
23
packages/foam-core/src/common/functional.ts
Normal 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;
|
||||
}
|
||||
111
packages/foam-core/src/common/iterator.ts
Normal file
111
packages/foam-core/src/common/iterator.ts
Normal 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;
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
300
packages/foam-core/src/common/lifecycle.ts
Normal file
300
packages/foam-core/src/common/lifecycle.ts
Normal 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 */
|
||||
}
|
||||
}
|
||||
129
packages/foam-core/src/common/linkedList.ts
Normal file
129
packages/foam-core/src/common/linkedList.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
119
packages/foam-core/src/services/datastore.ts
Normal file
119
packages/foam-core/src/services/datastore.ts
Normal 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}`;
|
||||
};
|
||||
7
packages/foam-core/src/services/logger.ts
Normal file
7
packages/foam-core/src/services/logger.ts
Normal 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;
|
||||
}
|
||||
@@ -42,5 +42,5 @@ export interface Note {
|
||||
}
|
||||
|
||||
export interface NoteParser {
|
||||
parse: (uri: string, text: string, eol: string) => Note;
|
||||
parse: (uri: string, text: string) => Note;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
17
packages/foam-vscode/src/services/config.ts
Normal file
17
packages/foam-vscode/src/services/config.ts
Normal 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
|
||||
});
|
||||
};
|
||||
@@ -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) {
|
||||
|
||||
17
yarn.lock
17
yarn.lock
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user