Skip to content

Commit

Permalink
PR Feedback
Browse files Browse the repository at this point in the history
- Track systems on client side so we can provide proper feedback
- Only allow import on non-System folders
- Only allow export on System folders
- Decorate system folders and dedicated directories
  • Loading branch information
martin-fleck-at committed May 27, 2024
1 parent 3fc5ab8 commit 0e60ac0
Show file tree
Hide file tree
Showing 26 changed files with 521 additions and 151 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ export class CrossModelStorage implements SourceModelStorage, ClientSessionListe
}
this.toDispose.push(await this.state.modelService.open({ uri: rootUri, clientId: this.state.clientId }));
this.toDispose.push(
this.state.modelService.onUpdate<CrossModelRoot>(rootUri, async event => {
this.state.modelService.onModelUpdated<CrossModelRoot>(rootUri, async event => {
if (this.state.clientId !== event.sourceClientId || event.reason !== 'changed') {
await this.update(rootUri, event.model);
this.actionDispatcher.dispatchAll(await this.submissionHandler.submitModel('external'));
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
/********************************************************************************
* Copyright (c) 2023 CrossBreeze.
********************************************************************************/
import { DefaultDocumentBuilder, LangiumSharedServices } from 'langium';
import { DefaultDocumentBuilder } from 'langium';
import { CancellationToken } from 'vscode-languageclient';
import { URI, Utils as UriUtils } from 'vscode-uri';
import { CrossModelSharedServices } from './cross-model-module.js';
import { isPackageUri } from './cross-model-package-manager.js';
import { Utils } from './util/uri-util.js';

Expand All @@ -13,7 +14,7 @@ import { Utils } from './util/uri-util.js';
export class CrossModelDocumentBuilder extends DefaultDocumentBuilder {
protected languageFileExtensions: string[] = [];

constructor(services: LangiumSharedServices) {
constructor(protected services: CrossModelSharedServices) {
super(services);
this.languageFileExtensions = this.serviceRegistry.all.flatMap(service => service.LanguageMetaData.fileExtensions);
}
Expand Down Expand Up @@ -49,6 +50,9 @@ export class CrossModelDocumentBuilder extends DefaultDocumentBuilder {
.filter(doc => doc.uri.path.startsWith(dirPath))
.map(doc => doc.uri)
.toArray();
return deletedDocuments || [uri];
const deletedPackages = this.services.workspace.PackageManager.getPackageInfos()
.filter(info => Utils.isChildOf(uri, info.uri))
.map(info => info.uri);
return [...deletedDocuments, ...deletedPackages, uri];
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@
* Copyright (c) 2023 CrossBreeze.
********************************************************************************/

import { DocumentState, LangiumDocument, MultiMap } from 'langium';
import { Disposable, DocumentState, LangiumDocument, MultiMap } from 'langium';
// eslint-disable-next-line import/no-unresolved
import { SystemInfo, SystemUpdatedEvent, SystemUpdateListener } from '@crossbreeze/protocol';
import { PackageJson } from 'type-fest';
import { CancellationToken, WorkspaceFolder } from 'vscode-languageserver';
import { URI, Utils as UriUtils } from 'vscode-uri';
import { CrossModelSharedServices } from './cross-model-module.js';
import { QUALIFIED_ID_SEPARATOR } from './cross-model-naming.js';
import { PackageAstNodeDescription } from './cross-model-scope.js';
import { Utils } from './util/uri-util.js';

/** Constant for representing an unknown project ID. */
Expand All @@ -17,8 +19,10 @@ export const UNKNOWN_PROJECT_ID = 'unknown';
/** Constant for representing an unknown project reference. */
export const UNKNOWN_PROJECT_REFERENCE = 'unknown';

export const PACKAGE_JSON = 'package.json';

export function isPackageUri(uri: URI): boolean {
return UriUtils.basename(uri) === 'package.json';
return UriUtils.basename(uri) === PACKAGE_JSON;
}

export function isPackageLockUri(uri: URI): boolean {
Expand Down Expand Up @@ -85,6 +89,7 @@ export class PackageInfo {
export class CrossModelPackageManager {
protected uriToPackage = new Map<string, PackageInfo>();
protected idToPackage = new MultiMap<string, PackageInfo>();
protected readonly updateListeners: SystemUpdateListener[] = [];

constructor(
protected shared: CrossModelSharedServices,
Expand Down Expand Up @@ -141,6 +146,10 @@ export class CrossModelPackageManager {
return this.getPackageInfoByURI(packageUri ?? doc.uri);
}

getPackageInfos(): PackageInfo[] {
return Array.from(this.uriToPackage.values());
}

getPackageInfoByURI(uri?: URI): PackageInfo | undefined {
if (!uri) {
return;
Expand Down Expand Up @@ -229,6 +238,7 @@ export class CrossModelPackageManager {
this.logger.warn('A package with the same id was already registered.');
}
this.idToPackage.add(packageInfo.id, packageInfo);
this.emitUpdate({ system: this.convertPackageInfoToSystemInfo(packageInfo), reason: 'added' });
return [packageInfo.id];
}
return [];
Expand All @@ -238,7 +248,9 @@ export class CrossModelPackageManager {
const packageInfo = this.uriToPackage.get(uri.toString());
if (packageInfo && !packageInfo?.isUnknown) {
this.uriToPackage.delete(uri.toString());
this.idToPackage.delete(packageInfo.id, packageInfo);
if (this.idToPackage.delete(packageInfo.id, packageInfo)) {
this.emitUpdate({ system: this.convertPackageInfoToSystemInfo(packageInfo), reason: 'removed' });
}
return [packageInfo.id];
}
return [];
Expand All @@ -260,12 +272,42 @@ export class CrossModelPackageManager {
return toUpdate;
}

protected async emitUpdate(event: SystemUpdatedEvent): Promise<void> {
await Promise.all(this.updateListeners.map(listener => listener(event)));
}

onUpdate(callback: SystemUpdateListener): Disposable {
this.updateListeners.push(callback);
return Disposable.create(() => {
const index = this.updateListeners.indexOf(callback);
if (index >= 0) {
this.updateListeners.splice(index, 1);
}
});
}

protected documentParsed(built: LangiumDocument[], _cancelToken: CancellationToken): void {
// we only do this so we can quickly find the package info for a given document
for (const doc of built) {
(doc as any)['packageUri'] = this.getPackageInfoByURI(doc.uri)?.uri;
}
}

convertPackageInfoToSystemInfo(packageInfo: PackageInfo): SystemInfo {
const packageId = packageInfo.id;
const directory = UriUtils.dirname(packageInfo.uri);
return {
id: packageInfo.id,
name: packageInfo.packageJson?.name ?? UriUtils.basename(directory) ?? 'Unknown',
directory: directory.fsPath,
packageFilePath: packageInfo.uri.fsPath,
modelFilePaths: this.shared.workspace.IndexManager.allElements()
.filter(desc => desc instanceof PackageAstNodeDescription && desc.packageId === packageId)
.map(desc => desc.documentUri.fsPath)
.distinct()
.toArray()
};
}
}

function getAndRemovePackageUris(uris: URI[]): URI[] {
Expand Down
20 changes: 14 additions & 6 deletions extensions/crossmodel-lang/src/model-server/model-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,15 @@ import {
CrossReference,
CrossReferenceContext,
FindReferenceableElements,
OnSave,
OnUpdated,
OnModelSaved,
OnModelUpdated,
OnSystemsUpdated,
OpenModel,
OpenModelArgs,
ReferenceableElement,
RequestModel,
RequestSystemInfo,
RequestSystemInfos,
ResolveReference,
ResolvedElement,
SaveModel,
Expand Down Expand Up @@ -56,12 +58,18 @@ export class ModelServer implements Disposable {
this.toDispose.push(connection.onRequest(UpdateModel, args => this.updateModel(args)));
this.toDispose.push(connection.onRequest(SaveModel, args => this.saveModel(args)));
this.toDispose.push(connection.onRequest(RequestSystemInfo, args => this.systemInfo(args)));
this.toDispose.push(connection.onRequest(RequestSystemInfos, args => this.systemInfos()));
this.toDispose.push(this.modelService.onSystemUpdated(event => this.connection.sendNotification(OnSystemsUpdated, event)));
}

protected systemInfo(args: SystemInfoArgs): Promise<SystemInfo | undefined> {
return this.modelService.getSystemInfo(args);
}

protected systemInfos(): Promise<SystemInfo[]> {
return this.modelService.getSystemInfos();
}

protected complete(args: CrossReferenceContext): Promise<ReferenceableElement[]> {
return this.modelService.findReferenceableElements(args);
}
Expand All @@ -88,15 +96,15 @@ export class ModelServer implements Disposable {
this.disposeListeners(args);
const listenersForClient = [];
listenersForClient.push(
this.modelService.onSave(args.uri, event =>
this.connection.sendNotification(OnSave, {
this.modelService.onModelSaved(args.uri, event =>
this.connection.sendNotification(OnModelSaved, {
uri: args.uri,
model: this.toSerializable(event.model) as CrossModelRoot,
sourceClientId: event.sourceClientId
})
),
this.modelService.onUpdate(args.uri, event =>
this.connection.sendNotification(OnUpdated, {
this.modelService.onModelUpdated(args.uri, event =>
this.connection.sendNotification(OnModelUpdated, {
uri: args.uri,
model: this.toSerializable(event.model) as CrossModelRoot,
sourceClientId: event.sourceClientId,
Expand Down
34 changes: 20 additions & 14 deletions extensions/crossmodel-lang/src/model-server/model-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,14 @@ import {
SaveModelArgs,
SystemInfo,
SystemInfoArgs,
SystemUpdatedEvent,
UpdateModelArgs
} from '@crossbreeze/protocol';
import { AstNode, Deferred, DocumentState, isAstNode } from 'langium';
import { Disposable, OptionalVersionedTextDocumentIdentifier, Range, TextDocumentEdit, TextEdit, uinteger } from 'vscode-languageserver';
import { URI } from 'vscode-uri';
import { URI, Utils as UriUtils } from 'vscode-uri';
import { CrossModelServices, CrossModelSharedServices } from '../language-server/cross-model-module.js';
import { PackageAstNodeDescription } from '../language-server/cross-model-scope.js';
import { PACKAGE_JSON } from '../language-server/cross-model-package-manager.js';
import { findDocument } from '../language-server/util/ast-util.js';
import { LANGUAGE_CLIENT_ID } from './openable-text-documents.js';

Expand Down Expand Up @@ -151,11 +152,11 @@ export class ModelService {
return Promise.race([pendingUpdate.promise, timeout]);
}

onUpdate<T extends AstNode>(uri: string, listener: (model: ModelUpdatedEvent<T>) => void): Disposable {
onModelUpdated<T extends AstNode>(uri: string, listener: (model: ModelUpdatedEvent<T>) => void): Disposable {
return this.documentManager.onUpdate(uri, listener);
}

onSave<T extends AstNode>(uri: string, listener: (model: ModelSavedEvent<T>) => void): Disposable {
onModelSaved<T extends AstNode>(uri: string, listener: (model: ModelSavedEvent<T>) => void): Disposable {
return this.documentManager.onSave(uri, listener);
}

Expand Down Expand Up @@ -210,19 +211,24 @@ export class ModelService {
return this.shared.CrossModel.references.ScopeProvider.resolveCrossReference(args);
}

async getSystemInfos(): Promise<SystemInfo[]> {
return this.shared.workspace.PackageManager.getPackageInfos().map(info =>
this.shared.workspace.PackageManager.convertPackageInfoToSystemInfo(info)
);
}

async getSystemInfo(args: SystemInfoArgs): Promise<SystemInfo | undefined> {
const packageInfo = this.shared.workspace.PackageManager.getPackageInfoByURI(URI.parse(args.contextUri!));
const contextUri = URI.parse(args.contextUri);
const packageInfo =
this.shared.workspace.PackageManager.getPackageInfoByURI(contextUri) ??
this.shared.workspace.PackageManager.getPackageInfoByURI(UriUtils.joinPath(contextUri, PACKAGE_JSON));
if (!packageInfo) {
return undefined;
}
const packageId = packageInfo.id;
return {
packageFilePath: packageInfo.uri.fsPath,
modelFilePaths: this.shared.workspace.IndexManager.allElements()
.filter(desc => desc instanceof PackageAstNodeDescription && desc.packageId === packageId)
.map(desc => desc.documentUri.fsPath)
.distinct()
.toArray()
};
return this.shared.workspace.PackageManager.convertPackageInfoToSystemInfo(packageInfo);
}

onSystemUpdated(listener: (event: SystemUpdatedEvent) => void): Disposable {
return this.shared.workspace.PackageManager.onUpdate(listener);
}
}
1 change: 1 addition & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
"@crossbreeze/model-service": "^1.0.0",
"@crossbreeze/protocol": "0.0.0",
"@theia/core": "1.43.1",
"@theia/filesystem": "1.43.1",
"@theia/markers": "1.43.1",
"@theia/outline-view": "1.43.1",
"@theia/plugin-ext": "1.43.1",
Expand Down
84 changes: 84 additions & 0 deletions packages/core/src/browser/cm-file-label-provider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
/********************************************************************************
* Copyright (c) 2024 CrossBreeze.
********************************************************************************/

import { ModelService } from '@crossbreeze/model-service/lib/common';
import { ModelStructure } from '@crossbreeze/protocol';
import { Emitter, MaybePromise } from '@theia/core';
import { DepthFirstTreeIterator, LabelProvider, LabelProviderContribution, Tree, TreeDecorator, TreeNode } from '@theia/core/lib/browser';
import { WidgetDecoration } from '@theia/core/lib/browser/widget-decoration';
import { inject, injectable, postConstruct } from '@theia/core/shared/inversify';
import { FileStatNode } from '@theia/filesystem/lib/browser';

@injectable()
export class CrossModelLabelProvider implements LabelProviderContribution, TreeDecorator {
id = 'CrossModelLabelProvider';

@inject(LabelProvider) protected readonly labelProvider: LabelProvider;
@inject(ModelService) protected readonly modelService: ModelService;

protected readonly decorationsChangedEmitter = new Emitter();
readonly onDidChangeDecorations = this.decorationsChangedEmitter.event;

@postConstruct()
protected init(): void {
this.modelService.onReady(() => this.fireDidChangeDecorations(tree => this.collectDecorators(tree)));
this.modelService.onSystemUpdate(() => this.fireDidChangeDecorations(tree => this.collectDecorators(tree)));
}

canHandle(element: object): number {
return FileStatNode.is(element) ? 100 : 0;
}

getIcon(node: FileStatNode): string {
if (this.isSystemDirectory(node)) {
return ModelStructure.System.ICON + ' default-folder-icon';
}
if (this.isSystemDirectory(node.parent) && node.fileStat.name === ModelStructure.Entity.FOLDER) {
return ModelStructure.Entity.ICON + ' default-folder-icon';
}
if (this.isSystemDirectory(node.parent) && node.fileStat.name === ModelStructure.Relationship.FOLDER) {
return ModelStructure.Relationship.ICON + ' default-folder-icon';
}
if (this.isSystemDirectory(node.parent) && node.fileStat.name === ModelStructure.SystemDiagram.FOLDER) {
return ModelStructure.SystemDiagram.ICON + ' default-folder-icon';
}
if (this.isSystemDirectory(node.parent) && node.fileStat.name === ModelStructure.Mapping.FOLDER) {
return ModelStructure.Mapping.ICON + ' default-folder-icon';
}
return this.labelProvider.getIcon(node.fileStat);
}

protected fireDidChangeDecorations(event: (tree: Tree) => Map<string, WidgetDecoration.Data>): void {
this.decorationsChangedEmitter.fire(event);
}

decorations(tree: Tree): MaybePromise<Map<string, WidgetDecoration.Data>> {
return this.collectDecorators(tree);
}

// Add workspace root as caption suffix and italicize if PreviewWidget
protected collectDecorators(tree: Tree): Map<string, WidgetDecoration.Data> {
const result = new Map<string, WidgetDecoration.Data>();
if (tree.root === undefined) {
return result;
}
for (const node of new DepthFirstTreeIterator(tree.root)) {
if (FileStatNode.is(node) && this.isSystemDirectory(node)) {
const decorations: WidgetDecoration.Data = {
captionSuffixes: [{ data: 'System' }]
};
result.set(node.id, decorations);
}
}
return result;
}

protected isSystemDirectory(node?: TreeNode): boolean {
return (
FileStatNode.is(node) &&
node.fileStat.isDirectory &&
this.modelService.systems.some(system => system.directory === node.fileStat.resource.path.fsPath())
);
}
}
9 changes: 7 additions & 2 deletions packages/core/src/browser/core-frontend-module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,16 @@
* Copyright (c) 2023 CrossBreeze.
********************************************************************************/
import { CommandContribution, MenuContribution } from '@theia/core';
import { LabelProviderContribution } from '@theia/core/lib/browser';
import { ContainerModule } from '@theia/core/shared/inversify';
import { FileNavigatorWidget } from '@theia/navigator/lib/browser';
import { FileNavigatorWidget, NavigatorTreeDecorator } from '@theia/navigator/lib/browser';
import { FileNavigatorContribution } from '@theia/navigator/lib/browser/navigator-contribution';
import { WorkspaceCommandContribution } from '@theia/workspace/lib/browser/workspace-commands';
import '../../style/index.css';
import { CrossModelLabelProvider } from './cm-file-label-provider';
import { createCrossModelFileNavigatorWidget } from './cm-file-navigator-tree-widget';
import { CrossModelFileNavigatorContribution, CrossModelWorkspaceContribution } from './new-element-contribution';
import { ImportExportContribution } from './import-export-contribution';
import { CrossModelFileNavigatorContribution, CrossModelWorkspaceContribution } from './new-element-contribution';

export default new ContainerModule((bind, _unbind, _isBound, rebind) => {
bind(CrossModelWorkspaceContribution).toSelf().inSingletonScope();
Expand All @@ -20,6 +22,9 @@ export default new ContainerModule((bind, _unbind, _isBound, rebind) => {
rebind(FileNavigatorContribution).toService(CrossModelFileNavigatorContribution);

rebind(FileNavigatorWidget).toDynamicValue(ctx => createCrossModelFileNavigatorWidget(ctx.container));
bind(CrossModelLabelProvider).toSelf().inSingletonScope();
bind(LabelProviderContribution).toService(CrossModelLabelProvider);
bind(NavigatorTreeDecorator).toService(CrossModelLabelProvider);

bind(ImportExportContribution).toSelf().inSingletonScope();
bind(CommandContribution).toService(ImportExportContribution);
Expand Down
Loading

0 comments on commit 0e60ac0

Please sign in to comment.