diff --git a/app/lib/dialog.js b/app/lib/dialog.js index 3e5e097f9a..7c007a8200 100644 --- a/app/lib/dialog.js +++ b/app/lib/dialog.js @@ -105,6 +105,7 @@ class Dialog { showOpenDialog(options) { const { filters, + properties, title } = options; @@ -117,7 +118,7 @@ class Dialog { return this.electronDialog.showOpenDialog(this.browserWindow, { defaultPath, filters, - properties: [ 'openFile', 'multiSelections' ], + properties: properties || [ 'openFile', 'multiSelections' ], title: title || 'Open File' }).then(response => { diff --git a/app/lib/file-context/__tests__/file-context-spec.js b/app/lib/file-context/__tests__/file-context-spec.js new file mode 100644 index 0000000000..c9ea8b3e44 --- /dev/null +++ b/app/lib/file-context/__tests__/file-context-spec.js @@ -0,0 +1,184 @@ +/** + * Copyright Camunda Services GmbH and/or licensed to Camunda Services GmbH + * under one or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information regarding copyright + * ownership. + * + * Camunda licenses this file to you under the MIT; you may not use this file + * except in compliance with the MIT License. + */ + +const path = require('path'); + +const FileContext = require('../file-context'); + +const { toFileUrl } = require('../util'); + +describe('FileContext', function() { + + let fileContext, waitFor; + + beforeEach(function() { + fileContext = new FileContext(console); + + waitFor = createWaitFor(fileContext); + }); + + afterEach(function() { + return fileContext.close(); + }); + + + it('should watch by default', function() { + + // then + expect(fileContext._watcher).to.exist; + }); + + + it('adding file', async function() { + + // given + const filePath = path.resolve(__dirname, './fixtures/foo-process-application/foo.bpmn'); + + // when + await waitFor(() => { + fileContext.addFile(filePath); + }); + + // then + expectItems(fileContext, [ + { + uri: toFileUrl(path.resolve(__dirname, './fixtures/foo-process-application/foo.bpmn')), + localValue: undefined + } + ]); + }); + + + it('adding file with local value', async function() { + + // given + const filePath = path.resolve(__dirname, './fixtures/foo-process-application/foo.bpmn'); + + // when + await waitFor(() => { + fileContext.addFile(filePath, 'foo'); + }); + + // then + expectItems(fileContext, [ + { + uri: toFileUrl(path.resolve(__dirname, './fixtures/foo-process-application/foo.bpmn')), + localValue: 'foo' + } + ]); + }); + + + it('removing file', async function() { + + // given + const filePath = path.resolve(__dirname, './fixtures/foo-process-application/foo.bpmn'); + + // when + await waitFor(() => { + fileContext.addFile(filePath); + }); + + // then + expectItems(fileContext, [ + { + uri: toFileUrl(path.resolve(__dirname, './fixtures/foo-process-application/foo.bpmn')), + localValue: undefined + } + ]); + + // when + fileContext.removeFile(filePath); + + // then + expectItems(fileContext, []); + }); + + + it('adding root', async function() { + + // given + const directoryPath = path.resolve(__dirname, './fixtures/foo-process-application'); + + // when + await waitFor(() => { + fileContext.addRoot(directoryPath); + }, 'watcher:ready'); + + // then + expect(fileContext._indexer.getRoots()).to.have.length(1); + + expectItems(fileContext, [ + { + uri: toFileUrl(path.resolve(__dirname, './fixtures/foo-process-application/.process-application')) + }, + { + uri: toFileUrl(path.resolve(__dirname, './fixtures/foo-process-application/foo.bpmn')) + }, + { + uri: toFileUrl(path.resolve(__dirname, './fixtures/foo-process-application/bar/bar.bpmn')) + }, + { + uri: toFileUrl(path.resolve(__dirname, './fixtures/foo-process-application/bar/baz/baz.dmn')) + }, + { + uri: toFileUrl(path.resolve(__dirname, './fixtures/foo-process-application/bar/baz/baz.form')) + } + ]); + }); + + + it('removing root', async function() { + + // given + const directoryPath = path.resolve(__dirname, './fixtures/foo-process-application'); + + // when + await waitFor(() => { + fileContext.addRoot(directoryPath); + }, 'watcher:ready'); + + // then + expect(fileContext._indexer.getRoots()).to.have.length(1); + expect(fileContext._indexer.getItems()).to.have.length(5); + + // when + fileContext.removeRoot(directoryPath); + + // then + expect(fileContext._indexer.getRoots()).to.have.length(0); + expect(fileContext._indexer.getItems()).to.have.length(5); + }); + +}); + +function createWaitFor(fileContext) { + return function waitFor(fn, event = 'workqueue:empty') { + return new Promise((resolve) => { + fileContext.on(event, resolve); + + fn(); + }); + }; +} + +function expectItems(fileContext, expected) { + const items = fileContext._indexer.getItems(); + + console.log('items', items.map(({ uri }) => uri)); + + expect(items).to.have.length(expected.length); + + expected.forEach((expectedItem, index) => { + const item = items[ index ]; + + expect(item).to.include(expectedItem); + }); +} \ No newline at end of file diff --git a/app/lib/file-context/__tests__/fixtures/foo-process-application/.process-application b/app/lib/file-context/__tests__/fixtures/foo-process-application/.process-application new file mode 100644 index 0000000000..a69f1e3f94 --- /dev/null +++ b/app/lib/file-context/__tests__/fixtures/foo-process-application/.process-application @@ -0,0 +1,5 @@ +{ + "name": "Foo process application", + "executionPlatform": "camunda-cloud", + "executionPlatformVersion": "8.2.0" +} \ No newline at end of file diff --git a/app/lib/file-context/__tests__/fixtures/foo-process-application/bar/bar.bpmn b/app/lib/file-context/__tests__/fixtures/foo-process-application/bar/bar.bpmn new file mode 100644 index 0000000000..f4f52fceab --- /dev/null +++ b/app/lib/file-context/__tests__/fixtures/foo-process-application/bar/bar.bpmn @@ -0,0 +1,74 @@ + + + + + SequenceFlow_1 + + + + + + SequenceFlow_1 + SequenceFlow_2 + + + + + + + SequenceFlow_2 + SequenceFlow_3 + + + + + + + SequenceFlow_3 + SequenceFlow_4 + + + + SequenceFlow_4 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/lib/file-context/__tests__/fixtures/foo-process-application/bar/bar.txt b/app/lib/file-context/__tests__/fixtures/foo-process-application/bar/bar.txt new file mode 100644 index 0000000000..e69de29bb2 diff --git a/app/lib/file-context/__tests__/fixtures/foo-process-application/bar/baz/baz.dmn b/app/lib/file-context/__tests__/fixtures/foo-process-application/bar/baz/baz.dmn new file mode 100644 index 0000000000..3be9aaeb40 --- /dev/null +++ b/app/lib/file-context/__tests__/fixtures/foo-process-application/bar/baz/baz.dmn @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/lib/file-context/__tests__/fixtures/foo-process-application/bar/baz/baz.form b/app/lib/file-context/__tests__/fixtures/foo-process-application/bar/baz/baz.form new file mode 100644 index 0000000000..fd9fa57b99 --- /dev/null +++ b/app/lib/file-context/__tests__/fixtures/foo-process-application/bar/baz/baz.form @@ -0,0 +1,52 @@ +{ + "components": [ + { + "text": "# BazForm", + "type": "text", + "layout": { + "row": "Row_1l1cdcl", + "columns": null + }, + "id": "Field_05plz1j" + }, + { + "label": "Text field", + "type": "textfield", + "layout": { + "row": "Row_1m6rjof", + "columns": null + }, + "id": "Field_1spc3pd", + "key": "textfield_obha1" + }, + { + "label": "Text area", + "type": "textarea", + "layout": { + "row": "Row_0sblw61", + "columns": null + }, + "id": "Field_0swxobm", + "key": "textarea_g3hy67" + }, + { + "label": "Number", + "type": "number", + "layout": { + "row": "Row_0y94u98", + "columns": null + }, + "id": "Field_0fk8rvs", + "key": "number_k6z07b" + } + ], + "type": "default", + "id": "BazForm", + "executionPlatform": "Camunda Cloud", + "executionPlatformVersion": "8.6.0", + "exporter": { + "name": "Camunda Modeler", + "version": "5.29.0" + }, + "schemaVersion": 17 +} \ No newline at end of file diff --git a/app/lib/file-context/__tests__/fixtures/foo-process-application/foo.bpmn b/app/lib/file-context/__tests__/fixtures/foo-process-application/foo.bpmn new file mode 100644 index 0000000000..dee9e878d8 --- /dev/null +++ b/app/lib/file-context/__tests__/fixtures/foo-process-application/foo.bpmn @@ -0,0 +1,42 @@ + + + + + SequenceFlow_1 + + + + SequenceFlow_2 + + + + + + + SequenceFlow_1 + SequenceFlow_2 + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/lib/file-context/__tests__/fixtures/foo-process-application/random.txt b/app/lib/file-context/__tests__/fixtures/foo-process-application/random.txt new file mode 100644 index 0000000000..19cec5d129 --- /dev/null +++ b/app/lib/file-context/__tests__/fixtures/foo-process-application/random.txt @@ -0,0 +1 @@ +random \ No newline at end of file diff --git a/app/lib/file-context/file-context.js b/app/lib/file-context/file-context.js new file mode 100644 index 0000000000..6501ef44cd --- /dev/null +++ b/app/lib/file-context/file-context.js @@ -0,0 +1,166 @@ +/** + * Copyright Camunda Services GmbH and/or licensed to Camunda Services GmbH + * under one or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information regarding copyright + * ownership. + * + * Camunda licenses this file to you under the MIT; you may not use this file + * except in compliance with the MIT License. + */ + +const { EventEmitter } = require('node:events'); + +const { + toFileUrl +} = require('./util'); + +const Indexer = require('./indexer.js'); +const Processor = require('./processor.js'); +const Watcher = require('./watcher.js'); +const Workqueue = require('./workqueue.js'); + +/** + * @typedef { import('./types').Processor } Processor + * @typedef { { + * watch: boolean; + * processors: Processor[]; + * } } FileContextOptions + */ + +const DEFAULT_PROCESSORS = require('./processors'); + +const DEFAULT_OPTIONS = { + watch: true, + processors: DEFAULT_PROCESSORS +}; + +/** + * File context that indexes and processes files. + */ +module.exports = class FileContext extends EventEmitter { + + /** + * @param { import('./types').Logger } logger + * @param { FileContextOptions } [options] + */ + constructor(logger, options = DEFAULT_OPTIONS) { + super(); + + const { processors } = options; + + this._logger = logger; + + this._workqueue = new Workqueue(this); + this._processor = new Processor(logger, processors); + this._indexer = new Indexer(logger, this, this._processor, this._workqueue); + + this._init(options); + } + + /** + * Add root. + * + * @param { string } uri + */ + addRoot(uri) { + return this._indexer.addRoot(toFileUrl(uri)); + } + + /** + * Remove root. + * + * @param { string } uri + */ + removeRoot(uri) { + return this._indexer.removeRoot(toFileUrl(uri)); + } + + /** + * Add file. + * + * @param { string } uri + * @param { string } [localValue] + */ + addFile(uri, localValue) { + return this._indexer.add(toFileUrl(uri), localValue); + } + + /** + * Update file. + * + * @param { string } uri + */ + updateFile(uri) { + return this.addFile(toFileUrl(uri)); + } + + /** + * Remove file. + * + * @param { string } uri + */ + removeFile(uri) { + return this._indexer.remove(toFileUrl(uri)); + } + + /** + * Handle file opened. + * + * @param { { uri: string, value: string } } fileProps + */ + fileOpened(fileProps) { + return this._indexer.fileOpened(fileProps); + } + + /** + * Handle file content changed. + * + * @param { { uri: string, value: string } } fileProps + */ + fileContentChanged(fileProps) { + return this._indexer.fileContentChanged(fileProps); + } + + /** + * Handle file closed. + * + * @param { string } uri + */ + fileClosed(uri) { + return this._indexer.fileClosed(toFileUrl(uri)); + } + + /** + * @return { Promise } + */ + close() { + if (this._watcher) { + return this._watcher.close(); + } + + return Promise.resolve(); + } + + /** + * @param {FileContextOptions} options + */ + _init(options = {}) { + const { + processors, + watch = true + } = options; + + if (watch) { + this._watcher = new Watcher(this._logger, this, processors); + + this.once('watcher:ready', () => { + this.once('workqueue:empty', () => this.emit('ready')); + }); + } else { + this.once('workqueue:empty', () => this.emit('ready')); + } + + this.on('workqueue:empty', () => this._logger.info('workqueue:empty')); + } + +}; \ No newline at end of file diff --git a/app/lib/file-context/indexer.js b/app/lib/file-context/indexer.js new file mode 100644 index 0000000000..64382dae7c --- /dev/null +++ b/app/lib/file-context/indexer.js @@ -0,0 +1,379 @@ +/** + * Copyright Camunda Services GmbH and/or licensed to Camunda Services GmbH + * under one or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information regarding copyright + * ownership. + * + * Camunda licenses this file to you under the MIT; you may not use this file + * except in compliance with the MIT License. + */ + +/** + * @typedef { import('./types').IndexItem } IndexItem + */ + +const { createFile, readFile } = require('../file-system'); + +const { + toFilePath, + toFileUrl +} = require('./util'); + +module.exports = class Indexer { + + /** + * @type { Set } + */ + roots = new Set(); + + /** + * @type { Map } + */ + items = new Map(); + + /** + * @param { import('./types').Logger } logger + * @param { import('node:events').EventEmitter } eventBus + * @param { import('./processor').default } processor + * @param { import('./workqueue').default } workqueue + */ + constructor(logger, eventBus, processor, workqueue) { + + this._logger = logger; + this._eventBus = eventBus; + this._processor = processor; + this._workqueue = workqueue; + + eventBus.on('watcher:add', (uri) => { + this.add(uri); + }); + + eventBus.on('watcher:remove', (uri) => { + this.remove(uri); + }); + } + + on(event, callback) { + this._eventBus.on('indexer:' + event, callback); + } + + once(event, callback) { + this._eventBus.once('indexer:' + event, callback); + } + + _emit(event, ...args) { + this._eventBus.emit('indexer:' + event, ...args); + } + + /** + * Add root + * + * @param { string } uri + */ + addRoot(uri) { + this.roots.add(toFileUrl(uri)); + + this._emit('roots:add', uri); + } + + /** + * Remove root + * + * @param { string } uri + */ + removeRoot(uri) { + this.roots.delete(toFileUrl(uri)); + + this._emit('roots:remove', uri); + } + + /** + * @return { string[] } roots + */ + getRoots() { + return Array.from(this.roots); + } + + /** + * @param { string } uri + * @param { string } [localValue] + */ + add(uri, localValue) { + uri = toFileUrl(uri); + + this._logger.info('indexer:add', uri, localValue); + + let indexItem = this.items.get(uri); + + if (!indexItem) { + indexItem = createIndexItem({ uri, localValue }); + + this.items.set(uri, indexItem); + } + + if (localValue) { + indexItem.value = localValue; + indexItem._read = () => Promise.resolve(indexItem); + } else { + indexItem._read = undefined; + } + + indexItem._process = undefined; + + return this._parseItem(indexItem); + } + + /** + * Notify file opened + * + * @param { { uri: string, value: string } } fileProps + */ + fileOpened(fileProps) { + + const { + uri, + value + } = fileProps; + + this._emit('file-opened', uri); + + return this.add(toFileUrl(uri), value); + } + + /** + * Notify file content changed + * + * @param { { uri: string, value: string } } fileProps + */ + fileContentChanged(fileProps) { + + const { + uri, + value + } = fileProps; + + this._emit('file-content-changed', uri); + + return this.add(toFileUrl(uri), value); + } + + /** + * Notify file closed + * + * @param {string} uri + */ + fileClosed(uri) { + this._emit('file-closed', uri); + + this.remove(toFileUrl(uri), true); + } + + /** + * @param {string} uri + * @param {boolean} [local] + */ + remove(uri, local = false) { + uri = toFileUrl(uri); + + this._logger.info('indexer:remove', uri, local); + + const item = this.items.get(uri); + + if (!item) { + return; + } + + if (local) { + item.value = undefined; + + item._read = undefined; + item._process = undefined; + + return this._parseItem(item); + } + + this.items.delete(uri); + + return this._removed(item); + } + + /** + * @internal + * + * @param {IndexItem} item + */ + async _parseItem(item) { + + let { + _read, + _process, + _parsed + } = item; + + if (!_read) { + this._logger.info('indexer:reading item ' + item.uri); + + _read = item._read = () => this._readItem(item); + _parsed = null; + } + + if (!_process) { + this._logger.info('indexer:processing item ' + item.uri); + + _process = item._process = () => this._processItem(item); + _parsed = null; + } + + if (!_parsed) { + _parsed = item._parsed = _read().then(_process).then((item) => { + this._updated(item); + + return item; + }, err => { + this._logger.error('indexer:failed to parse item ' + item.uri, err); + + throw err; + }); + } + + return this._queue(_parsed); + } + + /** + * @internal + * + * @param {IndexItem} item + * + * @return {Promise} + */ + async _readItem(item) { + + let file; + + try { + file = readFile(toFilePath(item.uri), 'utf8'); + } catch (err) { + this._logger.error('indexer:failed to read item ' + item.uri, err); + + file = createFile({ + path: toFilePath(item.uri), + contents: '' + }); + + } + + item.file = file; + + return item; + } + + /** + * @internal + * + * @param { IndexItem } item + * + * @return { Promise } + */ + async _processItem(item) { + item.metadata = await this._processor.process(item); + + return item; + } + + /** + * @internal + * + * @template T + * + * @param {Promise} value + * + * @return {Promise} + */ + _queue(value) { + return this._workqueue.add(value); + } + + /** + * @internal + * + * @param {IndexItem} item + */ + _updated(item) { + this._logger.info('indexer:updated', item.uri); + + this._emit('updated', item); + } + + /** + * @internal + * + * @param {IndexItem} item + */ + _removed(item) { + this._emit('removed', item); + } + + /** + * Get item with the given uri + * + * @param {string} uri + * + * @return { Promise } + */ + async get(uri) { + + const item = this.items.get(toFileUrl(uri)); + + if (!item) { + return null; + } + + return this._parseItem(item); + } + + /** + * Return known index items. + * + * @return { IndexItem[] } + */ + getItems() { + return Array.from(this.items.values()); + } + +}; + +/** + * @param { { + * uri: string, + * localValue?: string + * } } item + * + * @return {IndexItem} + */ +function createIndexItem(item) { + + const { + uri, + localValue, + ...rest + } = item; + + const file = createFile({ + path: toFilePath(uri), + contents: localValue + }); + + return { + ...rest, + uri, + get value() { + return this.localValue || /** @type {string|undefined} */ (this.file.contents); + }, + set value(value) { + this.localValue = value; + }, + file, + localValue + }; + +} \ No newline at end of file diff --git a/app/lib/file-context/processor.js b/app/lib/file-context/processor.js new file mode 100644 index 0000000000..4c71f2d490 --- /dev/null +++ b/app/lib/file-context/processor.js @@ -0,0 +1,45 @@ +/** + * Copyright Camunda Services GmbH and/or licensed to Camunda Services GmbH + * under one or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information regarding copyright + * ownership. + * + * Camunda licenses this file to you under the MIT; you may not use this file + * except in compliance with the MIT License. + */ + +/** + * @typedef { import('./types').IndexItem } IndexItem + * @typedef { import('./types').Logger } Logger + * @typedef { import('./types').Metadata } Metadata + * @typedef { import('./types').Processor } Processor + */ + +const { getFileExtension } = require('./util'); + +module.exports = class Processor { + + /** + * @param { Logger } logger + * @param { Processor } processors + */ + constructor(logger, processors) { + this._logger = logger; + this._processors = processors; + } + + /** + * Process item. + * + * @param { IndexItem } item + * + * @returns { Promise } + */ + process(item) { + this._logger.info('processor:process', item.uri); + + const processor = this._processors.find(processor => processor.extensions.includes(getFileExtension(item.file.path))); + + return processor.process(item); + } +}; \ No newline at end of file diff --git a/app/lib/file-context/processors/bpmnProcessor.js b/app/lib/file-context/processors/bpmnProcessor.js new file mode 100644 index 0000000000..60c8ca5b22 --- /dev/null +++ b/app/lib/file-context/processors/bpmnProcessor.js @@ -0,0 +1,118 @@ +/** + * Copyright Camunda Services GmbH and/or licensed to Camunda Services GmbH + * under one or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information regarding copyright + * ownership. + * + * Camunda licenses this file to you under the MIT; you may not use this file + * except in compliance with the MIT License. + */ + +const BpmnModdle = require('bpmn-moddle'); + +const zeebe = require('zeebe-bpmn-moddle/resources/zeebe'); + +const moddle = new BpmnModdle({ zeebe }); + +const { + findExtensionElement, + is, + traverse +} = require('./util'); + +module.exports = { + extensions: [ '.bpmn', '.xml' ], + process: async (item) => { + let rootElement, ids, linkedIds; + + try { + ({ rootElement } = await moddle.fromXML(item.file.contents)); + + ids = findProcessIds(rootElement); + linkedIds = [ + ...findLinkedProcessIds(rootElement), + ...findLinkedDecisionIds(rootElement), + ...findLinkedFormIds(rootElement) + ]; + } catch (error) { + return { + type: 'bpmn', + error: error.message, + ids: [], + linkedIds: [] + }; + }; + + return { + type: 'bpmn', + ids, + linkedIds + }; + } +}; + +function findProcessIds(definitions) { + const processIds = []; + + traverse(definitions, { + enter(element) { + if (is(element, 'bpmn:Process')) { + processIds.push(element.get('id')); + } + } + }); + + return processIds; +} + +/** + * @param {Object} definitions + * @param {string} type + * @param {string} elementType + * @param {string} extensionElementType + * @param {string} propertyName + * + * @returns { { + * type: string, + * elementId: string, + * linkedId: string + * }[] } + */ +function findLinkedIds(definitions, type, elementType, extensionElementType, propertyName) { + const linkedIds = []; + + traverse(definitions, { + enter(element) { + if (is(element, elementType)) { + const extensionElement = findExtensionElement(element, extensionElementType); + + if (extensionElement) { + const property = extensionElement.get(propertyName); + + if (property && property.length) { + linkedIds.push({ + type, + elementId: element.get('id'), + linkedId: property + }); + } + } + + } + } + }); + + return linkedIds; +} + +function findLinkedProcessIds(definitions) { + return findLinkedIds(definitions, 'bpmn', 'bpmn:CallActivity', 'zeebe:CalledElement', 'processId'); +} + +function findLinkedDecisionIds(definitions) { + return findLinkedIds(definitions, 'dmn', 'bpmn:BusinessRuleTask', 'zeebe:CalledDecision', 'decisionId'); +} + +function findLinkedFormIds(definitions) { + return findLinkedIds(definitions, 'form', 'bpmn:UserTask', 'zeebe:FormDefinition', 'formId'); +} \ No newline at end of file diff --git a/app/lib/file-context/processors/dmnProcessor.js b/app/lib/file-context/processors/dmnProcessor.js new file mode 100644 index 0000000000..980e7305a0 --- /dev/null +++ b/app/lib/file-context/processors/dmnProcessor.js @@ -0,0 +1,56 @@ +/** + * Copyright Camunda Services GmbH and/or licensed to Camunda Services GmbH + * under one or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information regarding copyright + * ownership. + * + * Camunda licenses this file to you under the MIT; you may not use this file + * except in compliance with the MIT License. + */ + +const DmnModdle = require('dmn-moddle'); + +const moddle = new DmnModdle(); + +const { + is, + traverse +} = require('./util'); + +module.exports = { + extensions: [ '.dmn' ], + process: async (item) => { + let rootElement, ids; + + try { + ({ rootElement } = await moddle.fromXML(item.file.contents)); + + ids = findDecisionIds(rootElement); + } catch (error) { + return { + type: 'dmn', + error: error.message, + ids: [] + }; + }; + + return { + type: 'dmn', + ids + }; + } +}; + +function findDecisionIds(definitions) { + const decisionIds = []; + + traverse(definitions, { + enter(element) { + if (is(element, 'dmn:Decision')) { + decisionIds.push(element.get('id')); + } + } + }); + + return decisionIds; +} \ No newline at end of file diff --git a/app/lib/file-context/processors/formProcessor.js b/app/lib/file-context/processors/formProcessor.js new file mode 100644 index 0000000000..0420d13bf1 --- /dev/null +++ b/app/lib/file-context/processors/formProcessor.js @@ -0,0 +1,31 @@ +/** + * Copyright Camunda Services GmbH and/or licensed to Camunda Services GmbH + * under one or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information regarding copyright + * ownership. + * + * Camunda licenses this file to you under the MIT; you may not use this file + * except in compliance with the MIT License. + */ + +module.exports = { + extensions: [ '.form' ], + process: async (item) => { + let form; + + try { + form = JSON.parse(item.file.contents); + } catch (error) { + return { + type: 'form', + error: error.message, + ids: [] + }; + } + + return { + type: 'form', + ids: [ form.id ] + }; + } +}; \ No newline at end of file diff --git a/app/lib/file-context/processors/index.js b/app/lib/file-context/processors/index.js new file mode 100644 index 0000000000..1cc27dfbf3 --- /dev/null +++ b/app/lib/file-context/processors/index.js @@ -0,0 +1,16 @@ +/** + * Copyright Camunda Services GmbH and/or licensed to Camunda Services GmbH + * under one or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information regarding copyright + * ownership. + * + * Camunda licenses this file to you under the MIT; you may not use this file + * except in compliance with the MIT License. + */ + +module.exports = [ + require('./bpmnProcessor'), + require('./dmnProcessor'), + require('./formProcessor'), + require('./processApplicationProcessor') +]; \ No newline at end of file diff --git a/app/lib/file-context/processors/processApplicationProcessor.js b/app/lib/file-context/processors/processApplicationProcessor.js new file mode 100644 index 0000000000..a1a73c180d --- /dev/null +++ b/app/lib/file-context/processors/processApplicationProcessor.js @@ -0,0 +1,18 @@ +/** + * Copyright Camunda Services GmbH and/or licensed to Camunda Services GmbH + * under one or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information regarding copyright + * ownership. + * + * Camunda licenses this file to you under the MIT; you may not use this file + * except in compliance with the MIT License. + */ + +module.exports = { + extensions: [ '.process-application' ], + process: async (item) => { + return { + type: 'processApplication' + }; + } +}; \ No newline at end of file diff --git a/app/lib/file-context/processors/util.js b/app/lib/file-context/processors/util.js new file mode 100644 index 0000000000..c56cf7b058 --- /dev/null +++ b/app/lib/file-context/processors/util.js @@ -0,0 +1,97 @@ +/** + * Copyright Camunda Services GmbH and/or licensed to Camunda Services GmbH + * under one or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information regarding copyright + * ownership. + * + * Camunda licenses this file to you under the MIT; you may not use this file + * except in compliance with the MIT License. + */ + +const fs = require('fs'); +const path = require('path'); + +const { getFileExtension } = require('../util'); + +function findExtensionElement(element, type) { + const extensionElements = element.get('extensionElements'); + + if (!extensionElements) { + return; + } + + return extensionElements.get('values').find(value => is(value, type)); +} + +module.exports.findExtensionElement = findExtensionElement; + +function is(node, type) { + return ( + (typeof node.$instanceOf === 'function') + ? node.$instanceOf(type) + : node.$type === type + ); +} + +module.exports.is = is; + +function traverse(element, options) { + const enter = options.enter || null; + const leave = options.leave || null; + + const enterSubTree = enter && enter(element); + + const descriptor = element.$descriptor; + + if (enterSubTree !== false && !descriptor.isGeneric) { + const containedProperties = descriptor.properties.filter(p => { + return !p.isAttr && !p.isReference && p.type !== 'String'; + }); + + containedProperties.forEach(p => { + if (p.name in element) { + const propertyValue = element[p.name]; + + if (p.isMany) { + propertyValue.forEach(child => { + traverse(child, options); + }); + } else { + traverse(propertyValue, options); + } + } + }); + } + + leave && leave(element); +} + +module.exports.traverse = traverse; + +function findProcessApplicationFile(filePath) { + let dirName = path.dirname(filePath); + + while (dirName !== path.parse(dirName).root) { + const fileNames = fs.readdirSync(dirName); + + const fileName = fileNames.find(fileName => { + return getFileExtension(fileName) === '.process-application'; + }); + + if (fileName) { + return path.join(dirName, fileName); + } + + dirName = path.dirname(dirName); + } + + return false; +} + +module.exports.findProcessApplicationFile = findProcessApplicationFile; + +function isProcessApplicationFile(filePath) { + return getFileExtension(filePath) === '.process-application'; +} + +module.exports.isProcessApplicationFile = isProcessApplicationFile; \ No newline at end of file diff --git a/app/lib/file-context/types.d.ts b/app/lib/file-context/types.d.ts new file mode 100644 index 0000000000..ca34f93534 --- /dev/null +++ b/app/lib/file-context/types.d.ts @@ -0,0 +1,33 @@ +export type Logger = { + error: (message: string) => void, + info: (message: string) => void, + warn: (message: string) => void +}; + +export type File = { + contents: string, + dirname: string, + extname: string, + lastModified?: number, + name: string, + path: string, + uri: string +}; + +export type Metadata = { + [key: string]: any +}; + +export type IndexItem = { + file: File, + localValue?: any, + metadata: Metadata, + uri: string, + value: any, + [key: string]: any +}; + +export type Processor = { + extensions: string[], + process: (item: IndexItem) => Promise +}; \ No newline at end of file diff --git a/app/lib/file-context/util.js b/app/lib/file-context/util.js new file mode 100644 index 0000000000..021c69f3f5 --- /dev/null +++ b/app/lib/file-context/util.js @@ -0,0 +1,70 @@ +/** + * Copyright Camunda Services GmbH and/or licensed to Camunda Services GmbH + * under one or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information regarding copyright + * ownership. + * + * Camunda licenses this file to you under the MIT; you may not use this file + * except in compliance with the MIT License. + */ + +const path = require('path'); + +const { + pathToFileURL, + fileURLToPath +} = require('url'); + +function isFileUrl(value) { + try { + return new URL(value).protocol === 'file:'; + } catch (error) { + return false; + } +} + +module.exports.isFileUrl = isFileUrl; + +function isFilePath(value) { + try { + new URL(value); + + return false; + } catch (error) { + return true; + } +} + +module.exports.isFilePath = isFilePath; + +function toFileUrl(value) { + if (isFileUrl(value)) { + return value; + } + + return pathToFileURL(value).toString(); +} + +module.exports.toFileUrl = toFileUrl; + +function toFilePath(value) { + if (isFilePath(value)) { + return value; + } + + return fileURLToPath(value); +} + +module.exports.toFilePath = toFilePath; + +function getFileExtension(value) { + const baseName = path.basename(value); + + if (baseName.startsWith('.')) { + return baseName; + } + + return path.extname(baseName); +} + +module.exports.getFileExtension = getFileExtension; \ No newline at end of file diff --git a/app/lib/file-context/watcher.js b/app/lib/file-context/watcher.js new file mode 100644 index 0000000000..515e4e19ca --- /dev/null +++ b/app/lib/file-context/watcher.js @@ -0,0 +1,172 @@ +/** + * Copyright Camunda Services GmbH and/or licensed to Camunda Services GmbH + * under one or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information regarding copyright + * ownership. + * + * Camunda licenses this file to you under the MIT; you may not use this file + * except in compliance with the MIT License. + */ + +const { + FSWatcher +} = require('chokidar'); + +const { + getFileExtension, + toFilePath, + toFileUrl +} = require('./util'); + +/** + * @typedef { import('./types').Processor } Processor + */ + +module.exports = class Watcher { + + /** + * @param { import('./types').Logger } logger + * @param { import('node:events').EventEmitter } eventBus + * @param { Processor[] } processors + */ + constructor(logger, eventBus, processors) { + + this._logger = logger; + this._eventBus = eventBus; + + const extensions = processors.flatMap(processor => processor.extensions); + + /** + * @type { string[] } + */ + this._roots = []; + + /** + * @type {Set} + */ + this._files = new Set(); + + this._logger.info('watcher:start'); + + /** + * @type { import('chokidar').FSWatcher } + */ + this._chokidar = new FSWatcher({ + ignored: /\/(node_modules|\.git)\//i, + atomic: 300 + }); + + this._chokidar.on('add', path => { + if (!extensions.includes(getFileExtension(path))) { + this._logger.info('watcher:ignore', path); + + return; + } + + this._logger.info('watcher:add', path); + + this._files.add(path); + + this._emit('add', toFileUrl(path)); + + this._changed(); + }); + + this._chokidar.on('unlink', path => { + this._emit('remove', toFileUrl(path)); + + this._files.delete(path); + + this._changed(); + }); + + /\*|events/.test(process.env.DEBUG) && this._chokidar.on('all', (event, arg0) => { + this._logger.info(event, arg0); + }); + + this._chokidar.on('ready', () => { + this._logger.info('watcher:ready'); + + this._emit('ready'); + }); + + eventBus.on('indexer:roots:add', (uri) => { + this.addRoot(uri); + }); + + eventBus.on('indexer:roots:remove', (uri) => { + this.removeRoot(uri); + }); + } + + /** + * @param { string } event + * + * @param { ...any[] } args + */ + _emit(event, ...args) { + this._eventBus.emit('watcher:' + event, ...args); + } + + /** + * @internal + */ + _changed() { + clearTimeout(this._changedTimer); + + this._changedTimer = setTimeout(() => { + this._emit('changed'); + }, 300); + } + + /** + * @returns { string[] } + */ + getFiles() { + return Array.from(this._files); + } + + /** + * Add watched root directory. + * + * @param { string } uri + */ + addRoot(uri) { + this._logger.info('watcher:addRoot', uri); + + const path = toFilePath(uri); + + if (this._roots.includes(path)) { + return; + } + + this._roots.push(path); + + this._chokidar.add(path); + } + + /** + * Remove watched root directory. + * + * @param { string } uri + */ + removeRoot(uri) { + this._logger.info('watcher:removeFolder', uri); + + const path = toFilePath(uri); + + if (!this._roots.includes(path)) { + return; + } + + this._chokidar.unwatch(path); + + this._roots = this._roots.filter(p => p !== path); + } + + close() { + this._logger.info('watcher:close'); + + return this._chokidar.close(); + } +}; \ No newline at end of file diff --git a/app/lib/file-context/workqueue.js b/app/lib/file-context/workqueue.js new file mode 100644 index 0000000000..d4676aa794 --- /dev/null +++ b/app/lib/file-context/workqueue.js @@ -0,0 +1,47 @@ +/** + * Copyright Camunda Services GmbH and/or licensed to Camunda Services GmbH + * under one or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information regarding copyright + * ownership. + * + * Camunda licenses this file to you under the MIT; you may not use this file + * except in compliance with the MIT License. + */ + +module.exports = class Workqueue { + + /** + * @type { Set> } + */ + queue = new Set(); + + /** + * @param { import('node:events').EventEmitter } eventBus + */ + constructor(eventBus) { + this.eventBus = eventBus; + } + + /** + * Add work queue item. + * + * @template T + * + * @param {Promise} value + * + * @return { Promise } + */ + add(value) { + + this.queue.add(value); + + return value.finally(() => { + this.queue.delete(value); + + if (this.queue.size === 0) { + this.eventBus.emit('workqueue:empty'); + } + }); + } + +}; \ No newline at end of file diff --git a/app/lib/file-system.js b/app/lib/file-system.js index fbb94e8531..5cc3f93d21 100644 --- a/app/lib/file-system.js +++ b/app/lib/file-system.js @@ -13,6 +13,9 @@ const fs = require('fs'), path = require('path'); + +const { pathToFileURL } = require('node:url'); + const { assign, pick @@ -163,11 +166,15 @@ function createFile(oldFile, newFile) { if (newFile.path) { newFile.name = path.basename(newFile.path); + newFile.dirname = path.dirname(newFile.path); + newFile.extname = path.extname(newFile.path); + newFile.uri = pathToFileURL(newFile.path).toString(); } return assign({}, oldFile, newFile); } +module.exports.createFile = createFile; /** * Ensure that the file path has an extension, diff --git a/app/lib/index.js b/app/lib/index.js index f3a9e15e0c..a2dd7cecb9 100644 --- a/app/lib/index.js +++ b/app/lib/index.js @@ -41,6 +41,13 @@ const { registerConnectorTemplateUpdater } = require('./connector-templates'); +const FileContext = require('./file-context/file-context'); +const { toFileUrl, toFilePath } = require('./file-context/util'); +const { + findProcessApplicationFile, + isProcessApplicationFile +} = require('./file-context/processors/util'); + const { readFile, readFileStats, @@ -80,6 +87,7 @@ const { const { config, dialog, + fileContext, files, flags, menu, @@ -215,6 +223,46 @@ renderer.on('system-clipboard:write-text', function(options, done) { done(null, undefined); }); +// file context ////////// +renderer.on('file-context:add-root', function(filePath, done) { + fileContext.addRoot(filePath); + + done(null); +}); + +renderer.on('file-context:remove-root', function(filePath, done) { + fileContext.removeRoot(filePath); + + done(null); +}); + +renderer.on('file-context:file-opened', function(filePath, value, done) { + fileContext.fileOpened({ uri: toFileUrl(filePath), value }); + + done(null); +}); + +renderer.on('file-context:file-content-changed', function(filePath, value, done) { + fileContext.fileContentChanged({ uri: toFileUrl(filePath), value }); + + done(null); +}); + +renderer.on('file-context:file-closed', function(filePath, done) { + fileContext.fileClosed(toFileUrl(filePath)); + + done(null); +}); + +renderer.on('file:content-changed', function(filePath, contents, done) { + fileContext.fileContentChanged({ + uri: toFileUrl(filePath), + value: contents + }); + + done(null); +}); + // filesystem ////////// renderer.on('file:read', function(filePath, options = {}, done) { @@ -697,9 +745,53 @@ function bootstrap() { registerConnectorTemplateUpdater(renderer, userPath); } + // (11) file context + const fileContextLog = Log('app:file-context'); + + const fileContext = new FileContext(fileContextLog); + + let lastItems = []; + + function onIndexerUpdated() { + const items = fileContext._indexer.getItems(); + + const added = items.filter(({ uri }) => !lastItems.find(({ uri: lastUri }) => uri === lastUri)); + const removed = lastItems.filter(({ uri }) => !items.find(({ uri: newUri }) => uri === newUri)); + + lastItems = items; + + fileContextLog.info('added', added.map(({ file }) => file.path)); + fileContextLog.info('removed', removed.map(({ file }) => file.path)); + fileContextLog.info('items', items.map(({ uri, metadata }) => ({ uri, metadata: JSON.stringify(metadata, null, 2) }))); + + renderer.send('file-context:changed', items.map(({ file, metadata }) => ({ file, metadata }))); + } + + fileContext.on('indexer:updated', onIndexerUpdated); + fileContext.on('indexer:removed', onIndexerUpdated); + + function onFileOpened(fileUri) { + const filePath = toFilePath(fileUri); + + if (isProcessApplicationFile(filePath)) { + return; + } + + const processApplicationFile = findProcessApplicationFile(filePath); + + if (processApplicationFile) { + fileContext.addRoot(path.dirname(processApplicationFile)); + } + }; + + fileContext.on('indexer:file-opened', onFileOpened); + + app.on('quit', () => fileContext.close()); + return { config, dialog, + fileContext, files, flags, menu, diff --git a/app/lib/menu/menu-builder.js b/app/lib/menu/menu-builder.js index c63f2120d8..3571db3dc1 100644 --- a/app/lib/menu/menu-builder.js +++ b/app/lib/menu/menu-builder.js @@ -65,6 +65,7 @@ class MenuBuilder { this.appendFileMenu( new MenuBuilder(this.options) .appendNewFile() + .appendNewProcessApplication() .appendOpen() .appendSeparator() .appendSwitchTab() @@ -268,6 +269,17 @@ class MenuBuilder { return this; } + appendNewProcessApplication() { + this.menu.append(new MenuItem({ + label: 'New Process Application', + click: function() { + app.emit('menu:action', 'create-process-application'); + } + })); + + return this; + } + appendExportAs() { const exportState = this.options.state.exportAs; const enabled = exportState && exportState.length > 0; diff --git a/app/lib/preload.js b/app/lib/preload.js index 6c18ad8dd5..7626de3387 100644 --- a/app/lib/preload.js +++ b/app/lib/preload.js @@ -22,6 +22,7 @@ const handledInPreload = [ const allowedEvents = [ ...handledInPreload, + 'activeTab:change', 'app:reload', 'app:quit-aborted', 'app:quit-allowed', @@ -41,7 +42,12 @@ const allowedEvents = [ 'file:read', 'file:read-stats', 'file:write', - 'activeTab:change', + 'file-context:add-root', + 'file-context:changed', + 'file-context:file-closed', + 'file-context:file-content-changed', + 'file-context:file-opened', + 'file-context:remove-root', 'menu:register', 'menu:update', 'system-clipboard:write-text', diff --git a/app/package.json b/app/package.json index de74fd12d0..74c6d0cb74 100644 --- a/app/package.json +++ b/app/package.json @@ -13,6 +13,7 @@ "dependencies": { "@sentry/integrations": "^7.113.0", "@sentry/node": "^8.0.0", + "chokidar": "^4.0.1", "epipebomb": "^1.0.0", "fast-glob": "^3.3.1", "ids": "^1.0.0", diff --git a/client/resources/icons/file-types/ProcessApplication.svg b/client/resources/icons/file-types/ProcessApplication.svg new file mode 100644 index 0000000000..dfcdf23d81 --- /dev/null +++ b/client/resources/icons/file-types/ProcessApplication.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/client/src/app/App.js b/client/src/app/App.js index b0b3e61327..5589b963d4 100644 --- a/client/src/app/App.js +++ b/client/src/app/App.js @@ -65,6 +65,9 @@ import pSeries from 'p-series'; import History from './History'; +import ProcessApplications from './process-applications'; +import { ProcessApplicationsStatusBar } from './process-applications/components'; + import { PluginsRoot } from './plugins'; import * as css from './App.less'; @@ -72,6 +75,8 @@ import * as css from './App.less'; import Notifications, { NOTIFICATION_TYPES } from './notifications'; import { RecentTabs } from './RecentTabs'; +import ProcessApplicationIcon from '../../resources/icons/file-types/ProcessApplication.svg'; + const log = debug('App'); export const EMPTY_TAB = { @@ -91,6 +96,7 @@ const INITIAL_STATE = { dirtyTabs: {}, unsavedTabs: {}, recentTabs: [], + processApplication: null, layout: {}, tabs: [], tabState: {}, @@ -125,6 +131,33 @@ export class App extends PureComponent { config: this.getGlobal('config') }); + const processApplications = this.processApplications = new ProcessApplications(this); + + this.getGlobal('backend').on('file-context:changed', (_, items) => { + processApplications.emit('items-changed', items); + }); + + this.on('app.activeTabChanged', ({ activeTab }) => { + processApplications.emit('activeTab-changed', activeTab); + }); + + processApplications.on('changed', () => { + const hasOpenProcessApplication = processApplications.hasOpen(); + + if (!hasOpenProcessApplication) { + this.setState({ + processApplication: null + }); + } else { + this.setState({ + processApplication: { + ...processApplications.getOpen(), + items: processApplications.getItems() + } + }); + } + }); + this.tabRef = React.createRef(); const userPlugins = this.getPlugins('client'); @@ -192,6 +225,8 @@ export class App extends PureComponent { unsavedState = this.setUnsaved(tab, properties.unsaved); } + this._onTabOpened(tab); + return { ...unsavedState, tabs: [ @@ -205,7 +240,6 @@ export class App extends PureComponent { return tab; } - /** * Navigate shown tabs in given direction. */ @@ -427,6 +461,8 @@ export class App extends PureComponent { await this._removeTab(tab); + this._onTabClosed(tab); + return true; }; @@ -1042,6 +1078,8 @@ export class App extends PureComponent { this.emit('tab.saved', { tab }); this.triggerAction('lint-tab', { tab }); + this._onTabSaved(tab); + return tab; } @@ -1741,6 +1779,40 @@ export class App extends PureComponent { return this.getGlobal('dialog').show(options); } + async showCreateProcessApplicationDialog() { + const dialog = this.getGlobal('dialog'); + + const [ directoryPath ] = await dialog.showOpenFilesDialog({ + properties: [ 'openDirectory' ], + title: 'Create Process Application' + }); + + return directoryPath; + } + + /** + * Create and save new process application. + */ + async createProcessApplication() { + const directoryPath = await this.showCreateProcessApplicationDialog(); + + if (!directoryPath) { + return; + } + + const file = { + name: '.process-application', + contents: JSON.stringify({}, null, 2), + path: null + }; + + const fileSystem = this.getGlobal('fileSystem'); + + await fileSystem.writeFile(`${directoryPath}/${file.name}`, file); + + this.getGlobal('backend').send('file-context:file-opened', `${directoryPath}/${file.name}`, undefined); + } + triggerAction = failSafe((action, options) => { const { @@ -1750,6 +1822,10 @@ export class App extends PureComponent { log('App#triggerAction %s %o', action, options); + if (action === 'create-process-application') { + return this.createProcessApplication(); + } + if (action === 'lint-tab') { const { tab, @@ -2063,6 +2139,22 @@ export class App extends PureComponent { return fn; }; + _onTabOpened(tab) { + if (!this.isUnsaved(tab)) { + this.getGlobal('backend').send('file-context:file-opened', tab.file.path, undefined); + } + } + + _onTabClosed(tab) { + if (!this.isUnsaved(tab)) { + this.getGlobal('backend').send('file-context:file-closed', tab.file.path); + } + } + + _onTabSaved(tab) { + this.getGlobal('backend').send('file-context:file-content-changed', tab.file.path, undefined); + } + render() { const { @@ -2105,6 +2197,7 @@ export class App extends PureComponent { title: 'Welcome Screen' } } draggable + processApplication={ this.state.processApplication } /> @@ -2154,6 +2247,17 @@ export class App extends PureComponent { + { + const files = await this.readFileList([ path ]); + + await this.openFiles(files); + } } + tabsProvider={ this.props.tabsProvider } + /> + { - let items = []; + let items = [ + { + text: 'Process application', + group: 'Camunda 8', + icon: ProcessApplicationIcon, + onClick: () => this.triggerAction('create-process-application') + } + ]; + const providers = this.props.tabsProvider.getProviders(); forEach(providers, provider => { diff --git a/client/src/app/primitives/TabLinks.js b/client/src/app/primitives/TabLinks.js index 202f62ae3b..23f0104ff6 100644 --- a/client/src/app/primitives/TabLinks.js +++ b/client/src/app/primitives/TabLinks.js @@ -95,6 +95,7 @@ export default class TabLinks extends PureComponent { onContextMenu, onClose, placeholder, + processApplication, className, isDirty = () => false } = this.props; @@ -109,11 +110,14 @@ export default class TabLinks extends PureComponent { const dirty = isDirty(tab); const active = tab === activeTab; + const isProcessApplication = processApplication && processApplication.items.some(item => item.file.path === tab.file.path); + return ( onSelect(tab, event) } onAuxClick={ (event) => { diff --git a/client/src/app/primitives/Tabbed.less b/client/src/app/primitives/Tabbed.less index d109897230..659c2c6a0a 100644 --- a/client/src/app/primitives/Tabbed.less +++ b/client/src/app/primitives/Tabbed.less @@ -194,7 +194,8 @@ } /* tab bottom lines (active + hover) */ - &.tab--active::after, + &.tab--active:after, + &.tab--process-application:after, &:not(.tab--active):hover::after { z-index: 3; position: absolute; @@ -205,13 +206,17 @@ height: 2px; } - &:not(.tab--active):hover::after { + &:not(.tab--active):hover:after { background-color: var(--tab-line-hover-background-color); } - &.tab--active::after { + &.tab--active:after { background-color: var(--tab-line-active-background-color); } + + &.tab--process-application:not(.tab--active):after { + background-color: var(--color-grey-225-10-75); + } } } diff --git a/client/src/app/process-applications/ProcessApplications.js b/client/src/app/process-applications/ProcessApplications.js new file mode 100644 index 0000000000..41132dd23a --- /dev/null +++ b/client/src/app/process-applications/ProcessApplications.js @@ -0,0 +1,169 @@ +/** + * Copyright Camunda Services GmbH and/or licensed to Camunda Services GmbH + * under one or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information regarding copyright + * ownership. + * + * Camunda licenses this file to you under the MIT; you may not use this file + * except in compliance with the MIT License. + */ + +import EventEmitter from 'events'; + +export default class ProcessApplications { + constructor() { + const events = this._events = new EventEmitter(); + + this._items = []; + this._processApplication = null; + this._processApplicationItems = []; + this._activeTab = null; + + events.on('items-changed', (items) => { + this._items = items; + + if (this.hasOpen()) { + const processApplicationItem = this._findItem(this._processApplication.file.path); + + if (processApplicationItem) { + this._processApplicationItems = this._items.filter(item => this._isProcessApplicationItem(item)); + + events.emit('changed'); + } else { + this.close(); + } + } else if (this._activeTab) { + const { file } = this._activeTab; + + if (!file) { + return; + } + + const item = this._items.find(item => item.file.path === file.path); + + const processApplicationItem = this._findProcessApplicationItemForItem(item); + + if (processApplicationItem) { + this.open(processApplicationItem); + } + } + }); + + events.on('activeTab-changed', (activeTab) => { + this._activeTab = activeTab; + + const { file } = activeTab; + + if (!file || !file.path) { + this.close(); + + return; + } + + const item = this._items.find(item => item.file.path === file.path); + + const processApplicationItem = this._findProcessApplicationItemForItem(item); + + if (processApplicationItem) { + this.open(processApplicationItem); + } else { + this.close(); + } + }); + } + + /** + * @param {Item} + */ + async open(processApplicationItem) { + try { + const { file } = processApplicationItem; + + const { contents } = file; + + this._processApplication = { + file, + ...JSON.parse(contents) + }; + + this._processApplicationItems = this._items.filter(item => this._isProcessApplicationItem(item)); + + this._events.emit('changed'); + } catch (err) { + console.error(err); + + this._events.emit('error', err); + + this._processApplication = null; + this._processApplicationItems = []; + } + } + + close() { + if (!this.hasOpen()) { + return; + } + + this._processApplication = null; + this._processApplicationItems = []; + + this._events.emit('changed'); + } + + getOpen() { + return this._processApplication; + } + + hasOpen() { + return !!this._processApplication; + } + + getItems() { + return this._processApplicationItems; + } + + emit(...args) { + this._events.emit(...args); + } + + on(...args) { + this._events.on(...args); + } + + /** + * Check if item is process application item. + * + * @param {Item} item + * + * @returns {boolean} + */ + _isProcessApplicationItem(item) { + const processApplicationItem = this._findProcessApplicationItemForItem(item); + + return processApplicationItem && processApplicationItem.file.path === this._processApplication.file.path; + } + + /** + * Find process application item for item. + * + * @param {Item} item + * + * @returns {Item|undefined} + */ + _findProcessApplicationItemForItem(item) { + return this._items.find(otherItem => { + return otherItem.metadata.type === 'processApplication' && item.file.path.startsWith(otherItem.file.dirname); + }); + } + + /** + * Find item by path. + * + * @param {string} path + * + * @returns {Item|undefined} + */ + _findItem(path) { + return this._items.find(item => item.file.path === path); + } +} \ No newline at end of file diff --git a/client/src/app/process-applications/__tests__/ProcessApplicationsSpec.js b/client/src/app/process-applications/__tests__/ProcessApplicationsSpec.js new file mode 100644 index 0000000000..15b45a92dd --- /dev/null +++ b/client/src/app/process-applications/__tests__/ProcessApplicationsSpec.js @@ -0,0 +1,272 @@ +/** + * Copyright Camunda Services GmbH and/or licensed to Camunda Services GmbH + * under one or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information regarding copyright + * ownership. + * + * Camunda licenses this file to you under the MIT; you may not use this file + * except in compliance with the MIT License. + */ + +/* global sinon */ + +import ProcessApplications from '../ProcessApplications'; + +const { spy } = sinon; + + +describe('ProcessApplications', function() { + + let processApplications; + + beforeEach(function() { + processApplications = new ProcessApplications(); + }); + + + describe('', function() { + + it('should open process application on ', function() { + + // given + const changedSpy = spy(); + + processApplications.on('changed', changedSpy); + + processApplications.emit('activeTab-changed', DEFAULT_ACTIVE_TAB); + + expect(processApplications.hasOpen()).to.be.false; + + // when + processApplications.emit('items-changed', DEFAULT_ITEMS_PROCESS_APPLICATION); + + // then + expect(processApplications.hasOpen()).to.be.true; + expect(processApplications.getItems()).to.have.length(2); + + expect(changedSpy).to.have.been.calledOnce; + }); + + + it('should not open process application on ', function() { + + // given + const changedSpy = spy(); + + processApplications.on('changed', changedSpy); + + processApplications.emit('activeTab-changed', DEFAULT_ACTIVE_TAB); + + expect(processApplications.hasOpen()).to.be.false; + + // when + processApplications.emit('items-changed', DEFAULT_ITEMS); + + // then + expect(processApplications.hasOpen()).to.be.false; + expect(processApplications.getItems()).to.have.length(0); + + expect(changedSpy).to.not.have.been.called; + }); + + + it('should close process application on ', function() { + + // given + processApplications.emit('activeTab-changed', DEFAULT_ACTIVE_TAB); + processApplications.emit('items-changed', DEFAULT_ITEMS_PROCESS_APPLICATION); + + expect(processApplications.hasOpen()).to.be.true; + + const changedSpy = spy(); + + processApplications.on('changed', changedSpy); + + // when + processApplications.emit('items-changed', DEFAULT_ITEMS); + + // then + expect(processApplications.hasOpen()).to.be.false; + expect(processApplications.getItems()).to.have.length(0); + + expect(changedSpy).to.have.been.calledOnce; + }); + + }); + + + describe('', function() { + + it('should open process application on ', function() { + + // given + const changedSpy = spy(); + + processApplications.on('changed', changedSpy); + + processApplications.emit('items-changed', DEFAULT_ITEMS_PROCESS_APPLICATION); + + expect(processApplications.hasOpen()).to.be.false; + + // when + processApplications.emit('activeTab-changed', DEFAULT_ACTIVE_TAB); + + // then + expect(processApplications.hasOpen()).to.be.true; + expect(processApplications.getItems()).to.have.length(2); + + expect(changedSpy).to.have.been.calledOnce; + }); + + + it('should close process application on (unsaved tab)', function() { + + // given + processApplications.emit('items-changed', DEFAULT_ITEMS_PROCESS_APPLICATION); + processApplications.emit('activeTab-changed', DEFAULT_ACTIVE_TAB); + + expect(processApplications.hasOpen()).to.be.true; + + const changedSpy = spy(); + + processApplications.on('changed', changedSpy); + + // when + processApplications.emit('activeTab-changed', UNSAVED_TAB); + + // then + expect(processApplications.hasOpen()).to.be.false; + expect(processApplications.getItems()).to.have.length(0); + + expect(changedSpy).to.have.been.calledOnce; + }); + + + it('should close process application on (empty tab)', function() { + + // given + processApplications.emit('items-changed', DEFAULT_ITEMS_PROCESS_APPLICATION); + processApplications.emit('activeTab-changed', DEFAULT_ACTIVE_TAB); + + expect(processApplications.hasOpen()).to.be.true; + + const changedSpy = spy(); + + processApplications.on('changed', changedSpy); + + // when + processApplications.emit('activeTab-changed', EMPTY_TAB); + + // then + expect(processApplications.hasOpen()).to.be.false; + expect(processApplications.getItems()).to.have.length(0); + + expect(changedSpy).to.have.been.calledOnce; + }); + + }); + + + describe('error handling', function() { + + it('should open process application on (parse error)', function() { + + // given + const changedSpy = spy(); + + processApplications.on('changed', changedSpy); + + const errorSpy = spy(); + + processApplications.on('error', errorSpy); + + processApplications.emit('activeTab-changed', DEFAULT_ACTIVE_TAB); + + expect(processApplications.hasOpen()).to.be.false; + + // when + processApplications.emit('items-changed', DEFAULT_ITEMS_PROCESS_APPLICATION_PARSE_ERROR); + + // then + expect(processApplications.hasOpen()).to.be.false; + expect(processApplications.getItems()).to.have.length(0); + + expect(changedSpy).not.to.have.been.called; + expect(errorSpy).to.have.been.calledOnce; + }); + + }); + +}); + +const DEFAULT_ITEMS = [ + { + file: { + uri: 'file:///C:/process-application/foo.bpmn', + path: 'C://process-application/foo.bpmn', + dirname: 'C://process-application', + contents: '' + }, + metadata: { + type: 'bpmn' + } + }, + { + file: { + uri: 'file:///C:/bar.bpmn', + path: 'C://bar.bpmn', + dirname: 'C://', + contents: '' + }, + metadata: { + type: 'bpmn' + } + } +]; + +const DEFAULT_ITEMS_PROCESS_APPLICATION = [ + { + file: { + uri: 'file:///C:/process-application/.process-application', + path: 'C://process-application/.process-application', + dirname: 'C://process-application', + contents: '{}' + }, + metadata: { + type: 'processApplication' + } + }, + ...DEFAULT_ITEMS +]; + +const DEFAULT_ITEMS_PROCESS_APPLICATION_PARSE_ERROR = [ + { + file: { + uri: 'file:///C:/process-application/.process-application', + path: 'C://process-application/.process-application', + dirname: 'C://process-application', + contents: '{' + }, + metadata: { + type: 'processApplication' + } + }, + ...DEFAULT_ITEMS +]; + +const DEFAULT_ACTIVE_TAB = { + file: { + ...DEFAULT_ITEMS[0].file + } +}; + +const UNSAVED_TAB = { + file: { + path: null, + contents: '' + } +}; + +const EMPTY_TAB = { + file: null +}; \ No newline at end of file diff --git a/client/src/app/process-applications/components/ProcessApplicationsStatusBar.js b/client/src/app/process-applications/components/ProcessApplicationsStatusBar.js new file mode 100644 index 0000000000..7aa9d0d03c --- /dev/null +++ b/client/src/app/process-applications/components/ProcessApplicationsStatusBar.js @@ -0,0 +1,106 @@ +/** + * Copyright Camunda Services GmbH and/or licensed to Camunda Services GmbH + * under one or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information regarding copyright + * ownership. + * + * Camunda licenses this file to you under the MIT; you may not use this file + * except in compliance with the MIT License. + */ + +import React, { useEffect, useRef, useState } from 'react'; + +import classnames from 'classnames'; + +import { Fill } from '../../slot-fill'; + +import { Overlay, Section } from '../../../shared/ui'; + +import ProcessApplicationIcon from '../../../../resources/icons/file-types/ProcessApplication.svg'; + +import * as css from './ProcessApplicationsStatusBar.less'; + +export default function ProcessApplicationsStatusBar(props) { + const ref = useRef(); + + const [ isOpen, setIsOpen ] = useState(false); + + const { + activeTab, + onOpen, + processApplication, + tabsProvider + } = props; + + useEffect(() => { + if (!processApplication) { + setIsOpen(false); + } + }, [ processApplication ]); + + if (!processApplication) { + return null; + } + + const { items } = processApplication; + + return <> + + + + { + isOpen && setIsOpen(false) }> +
+ + Process application + + +
    + { + sortByType(items.filter(item => item.metadata.type !== 'processApplication')).map(item => { + const provider = tabsProvider.getProvider(item.metadata.type); + + const Icon = provider.getIcon(item.file); + + const { file } = item; + + if (file.path === activeTab.file.path) { + return
  • + { file.name } +
  • ; + } + + return
  • onOpen(file.path) } key={ file.path } title={ file.path }> + { file.name } +
  • ; + }) + } +
+
+
+
+ } + ; +} + +function sortByType(items) { + const groupedByType = items.reduce((acc, item) => { + const { type } = item.metadata; + + if (!acc[type]) { + acc[type] = []; + } + + acc[type].push(item); + + return acc; + }, {}); + + for (const type in groupedByType) { + groupedByType[type].sort((a, b) => a.file.name.localeCompare(b.file.name)); + } + + return Object.values(groupedByType).flat(); +} \ No newline at end of file diff --git a/client/src/app/process-applications/components/ProcessApplicationsStatusBar.less b/client/src/app/process-applications/components/ProcessApplicationsStatusBar.less new file mode 100644 index 0000000000..37a38c07e9 --- /dev/null +++ b/client/src/app/process-applications/components/ProcessApplicationsStatusBar.less @@ -0,0 +1,40 @@ +:local(.ProcessApplicationsButton) { + background: hsl(205, 100%, 45%) !important; + + svg { + fill: white; + } +} + +:local(.ProcessApplicationsOverlay) { + width: min-content; + + .section__header { + white-space: nowrap; + } + + .link { + cursor: pointer; + text-decoration: underline; + white-space: nowrap; + + &.active { + cursor: default; + text-decoration: none; + color: hsl(205, 100%, 45%); + } + } + + ul { + padding: 0; + } + + li { + display: flex; + align-items: center; + + svg { + margin-right: 6px; + } + } +} diff --git a/client/src/app/process-applications/components/index.js b/client/src/app/process-applications/components/index.js new file mode 100644 index 0000000000..12e5141438 --- /dev/null +++ b/client/src/app/process-applications/components/index.js @@ -0,0 +1,11 @@ +/** + * Copyright Camunda Services GmbH and/or licensed to Camunda Services GmbH + * under one or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information regarding copyright + * ownership. + * + * Camunda licenses this file to you under the MIT; you may not use this file + * except in compliance with the MIT License. + */ + +export { default as ProcessApplicationsStatusBar } from './ProcessApplicationsStatusBar'; \ No newline at end of file diff --git a/client/src/app/process-applications/index.js b/client/src/app/process-applications/index.js new file mode 100644 index 0000000000..518796dc56 --- /dev/null +++ b/client/src/app/process-applications/index.js @@ -0,0 +1,11 @@ +/** + * Copyright Camunda Services GmbH and/or licensed to Camunda Services GmbH + * under one or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information regarding copyright + * ownership. + * + * Camunda licenses this file to you under the MIT; you may not use this file + * except in compliance with the MIT License. + */ + +export { default } from './ProcessApplications'; \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index bbfd23d5b6..788d1fad96 100644 --- a/package-lock.json +++ b/package-lock.json @@ -55,6 +55,7 @@ "dependencies": { "@sentry/integrations": "^7.113.0", "@sentry/node": "^8.0.0", + "chokidar": "^4.0.1", "epipebomb": "^1.0.0", "fast-glob": "^3.3.1", "ids": "^1.0.0", @@ -67,6 +68,21 @@ "vscode-windows-ca-certs": "^0.3.0" } }, + "app/node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "app/node_modules/min-dash": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/min-dash/-/min-dash-4.2.1.tgz", @@ -86,6 +102,19 @@ "node": ">= 0.8.0" } }, + "app/node_modules/readdirp": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.0.2.tgz", + "integrity": "sha512-yDMz9g+VaZkqBYS/ozoBJwaBhTbZo3UNYQHNRw1D3UFQB8oHB4uS/tAODO+ZLjGWmUbKnIlOWO+aaIiAxrUWHA==", + "license": "MIT", + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, "client": { "name": "camunda-modeler-client", "version": "5.31.0", @@ -41223,6 +41252,7 @@ "requires": { "@sentry/integrations": "^7.113.0", "@sentry/node": "^8.0.0", + "chokidar": "^4.0.1", "epipebomb": "^1.0.0", "fast-glob": "^3.3.1", "ids": "^1.0.0", @@ -41233,6 +41263,14 @@ "zeebe-node": "^8.3.2" }, "dependencies": { + "chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "requires": { + "readdirp": "^4.0.1" + } + }, "min-dash": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/min-dash/-/min-dash-4.2.1.tgz", @@ -41246,6 +41284,11 @@ }, "path-platform": { "version": "0.11.15" + }, + "readdirp": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.0.2.tgz", + "integrity": "sha512-yDMz9g+VaZkqBYS/ozoBJwaBhTbZo3UNYQHNRw1D3UFQB8oHB4uS/tAODO+ZLjGWmUbKnIlOWO+aaIiAxrUWHA==" } } },