Skip to content

Commit

Permalink
PR Feedback
Browse files Browse the repository at this point in the history
- Remove @mui/lab
- Introduce dirty state into the Header part for properties
- Disable attribute move up/down when appropriate
- Warn user before unsaved changes are lost
- Further unify editor and property widget for save mechanism
- Overwrite Theia property widget to delegate Save command (Ctrl+S)
  • Loading branch information
martin-fleck-at committed Apr 17, 2024
1 parent 4ab83b1 commit 0227760
Show file tree
Hide file tree
Showing 24 changed files with 395 additions and 319 deletions.
4 changes: 4 additions & 0 deletions extensions/crossmodel-lang/src/model-server/model-service.ts
Expand Up @@ -76,6 +76,10 @@ export class ModelService {
* @param uri document URI
*/
async close(args: CloseModelArgs): Promise<void> {
if (this.documentManager.isOnlyOpenInClient(args.uri, args.clientId)) {
// we need to restore the original state without any unsaved changes
await this.update({ ...args, model: this.documentManager.readFile(args.uri) });
}
return this.documentManager.close(args);
}

Expand Down
Expand Up @@ -150,6 +150,10 @@ export class OpenTextDocumentManager {
return this.textDocuments.isOpenInLanguageClient(this.normalizedUri(uri));
}

isOnlyOpenInClient(uri: string, client: string): boolean {
return this.textDocuments.isOnlyOpenInClient(this.normalizedUri(uri), client);
}

protected createDummyDocument(uri: string): TextDocumentItem {
return TextDocumentItem.create(this.normalizedUri(uri), CrossModelLanguageMetaData.languageId, 0, '');
}
Expand All @@ -158,9 +162,11 @@ export class OpenTextDocumentManager {
uri: string,
languageId: string = CrossModelLanguageMetaData.languageId
): Promise<TextDocumentItem> {
const vscUri = URI.parse(uri);
const content = this.fileSystemProvider.readFileSync(vscUri);
return TextDocumentItem.create(vscUri.toString(), languageId, 0, content.toString());
return TextDocumentItem.create(uri, languageId, 0, this.readFile(uri));
}

readFile(uri: string): string {
return this.fileSystemProvider.readFileSync(URI.parse(uri));
}

protected normalizedUri(uri: string): string {
Expand Down
Expand Up @@ -250,6 +250,10 @@ export class OpenableTextDocuments<T extends TextDocument> extends TextDocuments
return this.isOpenInClient(uri, LANGUAGE_CLIENT_ID);
}

isOnlyOpenInClient(uri: string, client: string): boolean {
return this.__clientDocuments.get(uri)?.size === 1 && this.isOpenInClient(uri, client);
}

protected log(uri: string, message: string): void {
const full = URI.parse(uri);
this.logger.info(`[Documents][${basename(full.fsPath)}] ${message}`);
Expand Down
8 changes: 8 additions & 0 deletions packages/core/src/browser/index.ts
@@ -0,0 +1,8 @@
/********************************************************************************
* Copyright (c) 2024 CrossBreeze.
********************************************************************************/
export * from './cm-file-navigator-tree-widget';
export * from './core-frontend-module';
export * from './model-widget';
export * from './new-element-contribution';
export * from './preferences-monaco-contribution';
247 changes: 247 additions & 0 deletions packages/core/src/browser/model-widget.tsx
@@ -0,0 +1,247 @@
/********************************************************************************
* Copyright (c) 2023 CrossBreeze.
********************************************************************************/

import { ModelService, ModelServiceClient } from '@crossbreeze/model-service/lib/common';
import { CrossModelRoot } from '@crossbreeze/protocol';
import {
EntityComponent,
ErrorView,
ModelProviderProps,
OpenCallback,
RelationshipComponent,
SaveCallback
} from '@crossbreeze/react-model-ui';
import { Emitter, Event } from '@theia/core';
import { LabelProvider, Message, OpenerService, ReactWidget, Saveable, open } from '@theia/core/lib/browser';
import { ThemeService } from '@theia/core/lib/browser/theming';
import URI from '@theia/core/lib/common/uri';
import { inject, injectable, postConstruct } from '@theia/core/shared/inversify';
import * as React from '@theia/core/shared/react';
import { EditorPreferences } from '@theia/editor/lib/browser';
import * as deepEqual from 'fast-deep-equal';
import * as debounce from 'p-debounce';

export const CrossModelWidgetOptions = Symbol('FormEditorWidgetOptions');
export interface CrossModelWidgetOptions {
clientId: string;
widgetId: string;
uri?: string;
}

interface Model {
uri: URI;
root: CrossModelRoot;
}

@injectable()
export class CrossModelWidget extends ReactWidget implements Saveable {
@inject(CrossModelWidgetOptions) protected options: CrossModelWidgetOptions;
@inject(LabelProvider) protected labelProvider: LabelProvider;
@inject(ModelService) protected readonly modelService: ModelService;
@inject(ModelServiceClient) protected serviceClient: ModelServiceClient;
@inject(ThemeService) protected readonly themeService: ThemeService;
@inject(EditorPreferences) protected readonly editorPreferences: EditorPreferences;
@inject(OpenerService) protected readonly openerService: OpenerService;

protected readonly onDirtyChangedEmitter = new Emitter<void>();
onDirtyChanged: Event<void> = this.onDirtyChangedEmitter.event;
dirty = false;
autoSave: 'off' | 'afterDelay' | 'onFocusChange' | 'onWindowChange';
autoSaveDelay: number;

protected model?: Model;
protected error: string | undefined;

@postConstruct()
init(): void {
this.id = this.options.widgetId;
this.title.closable = true;

this.setModel(this.options.uri);

this.autoSave = this.editorPreferences.get('files.autoSave');
this.autoSaveDelay = this.editorPreferences.get('files.autoSaveDelay');

this.toDispose.pushAll([
this.serviceClient.onUpdate(event => {
if (event.sourceClientId !== this.options.clientId && event.uri === this.model?.uri.toString()) {
this.handleExternalUpdate(event.model);
}
}),
this.editorPreferences.onPreferenceChanged(event => {
if (event.preferenceName === 'files.autoSave') {
this.autoSave = this.editorPreferences.get('files.autoSave');
}
if (event.preferenceName === 'files.autoSaveDelay') {
this.autoSaveDelay = this.editorPreferences.get('files.autoSaveDelay');
}
}),
this.themeService.onDidColorThemeChange(() => this.update())
]);
}

protected async setModel(uri?: string): Promise<void> {
if (this.model?.uri) {
await this.closeModel(this.model.uri.toString());
}
this.model = uri ? await this.openModel(uri) : undefined;
this.updateTitle(this.model?.uri);
this.setDirty(false);
this.update();
this.focusInput();
}

private updateTitle(uri?: URI): void {
if (uri) {
this.title.label = this.labelProvider.getName(uri);
this.title.iconClass = this.labelProvider.getIcon(uri);
} else {
this.title.label = 'Model Widget';
this.title.iconClass = 'no-icon';
}
}

protected async closeModel(uri: string): Promise<void> {
this.model = undefined;
await this.modelService.close({ clientId: this.options.clientId, uri });
}

protected async openModel(uri: string): Promise<Model | undefined> {
try {
const model = await this.modelService.open({ clientId: this.options.clientId, uri });
if (model) {
return { root: model, uri: new URI(uri) };
}
return undefined;
} catch (error: any) {
this.error = error;
return undefined;
}
}

setDirty(dirty: boolean): void {
if (dirty === this.dirty) {
return;
}

this.dirty = dirty;
this.onDirtyChangedEmitter.fire();
this.update();
}

async save(): Promise<void> {
return this.saveModel();
}

protected async handleExternalUpdate(root: CrossModelRoot): Promise<void> {
if (this.model && !deepEqual(this.model.root, root)) {
this.model.root = root;
this.update();
}
}

protected async updateModel(root: CrossModelRoot): Promise<void> {
if (this.model && !deepEqual(this.model.root, root)) {
this.model.root = root;
this.setDirty(true);
await this.modelService.update({ uri: this.model.uri.toString(), model: root, clientId: this.options.clientId });
if (this.autoSave !== 'off' && this.dirty) {
const saveTimeout = setTimeout(() => {
this.save();
clearTimeout(saveTimeout);
}, this.autoSaveDelay);
}
}
}

protected async saveModel(model = this.model): Promise<void> {
if (model === undefined) {
throw new Error('Cannot save undefined model');
}

this.setDirty(false);
await this.modelService.save({ uri: model.uri.toString(), model: model.root, clientId: this.options.clientId });
}

protected async openModelInEditor(): Promise<void> {
if (this.model?.uri === undefined) {
throw new Error('Cannot open undefined model');
}
open(this.openerService, this.model.uri);
}

protected getModelProviderProps(model: CrossModelRoot): ModelProviderProps {
return {
model,
dirty: this.dirty,
onModelUpdate: this.handleUpdateRequest,
onModelSave: this.handleSaveRequest,
onModelOpen: this.handleOpenRequest,
modelQueryApi: this.modelService
};
}

protected handleUpdateRequest = debounce(async (root: CrossModelRoot): Promise<void> => {
this.updateModel(root);
}, 200);

protected handleSaveRequest?: SaveCallback = () => this.save();

protected handleOpenRequest?: OpenCallback = () => this.openModelInEditor();

override close(): void {
if (this.model) {
this.closeModel(this.model.uri.toString());
}
super.close();
}

render(): React.ReactNode {
if (this.model?.root?.entity) {
return (
<EntityComponent
dirty={this.dirty}
model={this.model.root}
onModelUpdate={this.handleUpdateRequest}
onModelSave={this.handleSaveRequest}
onModelOpen={this.handleOpenRequest}
modelQueryApi={this.modelService}
theme={this.themeService.getCurrentTheme().type}
/>
);
}
if (this.model?.root?.relationship) {
return (
<RelationshipComponent
dirty={this.dirty}
model={this.model.root}
onModelUpdate={this.handleUpdateRequest}
onModelSave={this.handleSaveRequest}
onModelOpen={this.handleOpenRequest}
modelQueryApi={this.modelService}
theme={this.themeService.getCurrentTheme().type}
/>
);
}
if (this.error) {
return <ErrorView errorMessage={this.error} />;
}
return <div className='theia-widget-noInfo'>No properties available.</div>;
}

protected focusInput(): void {
setTimeout(() => {
document.activeElement;
const inputs = this.node.getElementsByTagName('input');
if (inputs.length > 0) {
inputs[0].focus();
}
}, 50);
}

protected override onActivateRequest(msg: Message): void {
super.onActivateRequest(msg);
this.focusInput();
}
}
1 change: 1 addition & 0 deletions packages/form-client/package.json
Expand Up @@ -29,6 +29,7 @@
"watch": "tsc -w"
},
"dependencies": {
"@crossbreeze/core": "0.0.0",
"@crossbreeze/model-service": "^1.0.0",
"@crossbreeze/protocol": "0.0.0",
"@crossbreeze/react-model-ui": "0.0.0",
Expand Down
14 changes: 9 additions & 5 deletions packages/form-client/src/browser/form-client-frontend-module.ts
Expand Up @@ -2,22 +2,26 @@
* Copyright (c) 2023 CrossBreeze.
********************************************************************************/

import { URI } from '@theia/core';
import { NavigatableWidgetOptions, OpenHandler, WidgetFactory } from '@theia/core/lib/browser';
import { ContainerModule } from '@theia/core/shared/inversify';
import { FormEditorOpenHandler, createFormEditorId } from './form-editor-open-handler';
import { FormEditorWidget, FormEditorWidgetOptions } from './form-editor-widget';
import { CrossModelWidgetOptions } from '@crossbreeze/core/lib/browser';

export default new ContainerModule(bind => {
bind(OpenHandler).to(FormEditorOpenHandler).inSingletonScope();
bind<WidgetFactory>(WidgetFactory).toDynamicValue(context => ({
id: FormEditorOpenHandler.ID, // must match the id in the open handler
createWidget: (options: NavigatableWidgetOptions) => {
createWidget: (navigatableOptions: NavigatableWidgetOptions) => {
// create a child container so we can bind unique form editor widget options for each widget
const container = context.container.createChild();
const uri = new URI(options.uri);
const id = createFormEditorId(uri, options.counter);
container.bind(FormEditorWidgetOptions).toConstantValue({ ...options, id });
const widgetId = createFormEditorId(navigatableOptions.uri, navigatableOptions.counter);
const options: FormEditorWidgetOptions = {
...navigatableOptions,
widgetId,
clientId: 'form-editor'
};
container.bind(CrossModelWidgetOptions).toConstantValue(options);
container.bind(FormEditorWidget).toSelf();
return container.get(FormEditorWidget);
}
Expand Down
4 changes: 2 additions & 2 deletions packages/form-client/src/browser/form-editor-open-handler.ts
Expand Up @@ -20,7 +20,7 @@ export class FormEditorOpenHandler extends NavigatableWidgetOpenHandler<FormEdit
}
}

export function createFormEditorId(uri: URI, counter?: number): string {
export function createFormEditorId(uri: string, counter?: number): string {
// ensure we create a unique ID
return FormEditorOpenHandler.ID + `:${uri.toString()}` + (counter !== undefined ? `:${counter}` : '');
return FormEditorOpenHandler.ID + `:${uri}` + (counter !== undefined ? `:${counter}` : '');
}

0 comments on commit 0227760

Please sign in to comment.