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=="
}
}
},