UNPKG

camunda-modeler

Version:

Camunda Modeler for BPMN, DMN and CMMN, based on bpmn.io

1,466 lines (1,134 loc) 31.4 kB
'use strict'; var merge = require('lodash/object/merge'), bind = require('lodash/function/bind'), assign = require('lodash/object/assign'), find = require('lodash/collection/find'), filter = require('lodash/collection/filter'), map = require('lodash/collection/map'), debounce = require('lodash/function/debounce'); var inherits = require('inherits'); var BaseComponent = require('base/component'), MenuBar = require('base/components/menu-bar'), Tabbed = require('base/components/tabbed'); var MultiButton = require('base/components/buttons/multi-button'), Button = require('base/components/buttons/button'), Separator = require('base/components/buttons/separator'); var BpmnProvider = require('./tabs/bpmn/provider'), DmnProvider = require('./tabs/dmn/provider'), CmmnProvider = require('./tabs/cmmn/provider'); var EmptyTab = require('./tabs/empty-tab'); var Footer = require('./footer'); var ensureOpts = require('util/ensure-opts'), series = require('util/async/series'), isUnsaved = require('util/file/is-unsaved'), parseFileType = require('./util/parse-file-type'), namespace = require('./util/namespace'), fileDrop = require('./util/dom/file-drop'); var debug = require('debug')('app'); /** * The main application entry point */ function App(options) { ensureOpts([ 'logger', 'events', 'dialog', 'fileSystem', 'config', 'metaData' ], options); BaseComponent.call(this, options); this.layout = { propertiesPanel: { open: false, width: 250 }, log: { open: false, height: 150 } }; var EXPORT_BUTTONS = { png: { id: 'png', action: this.compose('triggerAction', 'export-tab', { type: 'png' }), label: 'Export as PNG', icon: 'icon-picture', primary: true }, jpeg: { id: 'jpeg', action: this.compose('triggerAction', 'export-tab', { type: 'jpeg' }), label: 'Export as JPEG' }, svg: { id: 'svg', action: this.compose('triggerAction', 'export-tab', { type: 'svg' }), label: 'Export as SVG' } }; this.menuEntries = { modeler: { visible: true, name: 'modeler', buttons: [ MultiButton({ id: 'create', choices: [ { id: 'create-bpmn-diagram', action: this.compose('triggerAction', 'create-bpmn-diagram'), label: 'Create new BPMN Diagram', icon: 'icon-new', primary: true }, { id: 'create-dmn-diagram', action: this.compose('triggerAction', 'create-dmn-diagram'), label: 'Create new DMN Table' },{ id: 'create-cmmn-diagram', action: this.compose('triggerAction', 'create-cmmn-diagram'), label: 'Create new CMMN Diagram' } ] }), Button({ id: 'open', group: 'modeler', icon: 'icon-open', label: 'Open a Diagram', action: this.compose('triggerAction', 'open-diagram') }), Separator(), Button({ id: 'save', group: 'modeler', icon: 'icon-save-normal', label: 'Save Diagram', action: this.compose('triggerAction', 'save') }), Button({ id: 'save-as', group: 'modeler', icon: 'icon-save-as', label: 'Save Diagram as...', action: this.compose('triggerAction', 'save-as') }), Separator(), Button({ id: 'undo', group: 'modeler', icon: 'icon-undo', label: 'Undo', action: this.compose('triggerAction', 'undo'), disabled: true }), Button({ id: 'redo', group: 'modeler', icon: 'icon-redo', label: 'Redo', action: this.compose('triggerAction', 'redo'), disabled: true }), Separator(), MultiButton({ id: 'export-as', group: 'modeler', disabled: true, choices: map(EXPORT_BUTTONS, function(btn) { return btn; }) }) ] }, bpmn: { visible: false, name: 'bpmn', buttons: [ Separator(), Button({ id: 'align-left', icon: 'icon-align-left-tool', label: 'Align Elements to the Left', action: this.compose('triggerAction', 'alignElements', { type: 'left' }) }), Button({ id: 'align-center', icon: 'icon-align-horizontal-center-tool', label: 'Align Elements to the Center', action: this.compose('triggerAction', 'alignElements', { type: 'center' }) }), Button({ id: 'align-right', icon: 'icon-align-right-tool', label: 'Align Elements to the Right', action: this.compose('triggerAction', 'alignElements', { type: 'right' }) }), Button({ id: 'align-top', icon: 'icon-align-top-tool', label: 'Align Elements to the Top', action: this.compose('triggerAction', 'alignElements', { type: 'top' }) }), Button({ id: 'align-middle', icon: 'icon-align-vertical-center-tool', label: 'Align Elements to the Middle', action: this.compose('triggerAction', 'alignElements', { type: 'middle' }) }), Button({ id: 'align-bottom', icon: 'icon-align-bottom-tool', label: 'Align Elements to the Middle', action: this.compose('triggerAction', 'alignElements', { type: 'bottom' }) }), Separator(), Button({ id: 'distribute-horizontally', icon: 'icon-distribute-horizontally-tool', label: 'Distribute Elements Horizontally', action: this.compose('triggerAction', 'distributeHorizontally') }), Button({ id: 'distribute-bottom', icon: 'icon-distribute-vertically-tool', label: 'Distribute Elements Vertically', action: this.compose('triggerAction', 'distributeVertically') }) ] } }; this.tabs = [ EmptyTab({ id: 'empty-tab', label: '+', title: 'Create new Diagram', action: this.compose('triggerAction', 'create-bpmn-diagram'), closable: false, app: this, events: this.events }) ]; this.activeTab = this.tabs[0]; this.fileHistory = []; this.events.on('workspace:changed', debounce((done) => { this.persistWorkspace((err) => { debug('workspace persisted?', err); // this is something we want to prevent a race condition when quitting the app if (done) { done(err); } }); }, 100)); this.events.on('tools:state-changed', (tab, newState) => { var button; if (this.activeTab !== tab) { return debug('Warning: state updated on incative tab! This should never happen!'); } // update undo/redo/export based on state [ 'undo', 'redo' ].forEach((key) => { this.updateMenuEntry('modeler', key, !newState[key]); }); debug('tools:state-changed', newState); [ 'bpmn', 'cmmn', 'dmn' ].forEach((key) => { if (newState[key] && this.menuEntries[key]) { this.menuEntries[key].visible = true; } else if (this.menuEntries[key]) { this.menuEntries[key].visible = false; } }); // update export button state button = find(this.menuEntries.modeler.buttons, { id: 'export-as' }); button.choices = (newState['exportAs'] || []).map((type) => { return EXPORT_BUTTONS[type]; }); if (button.choices.length) { button.disabled = false; button.choices[0] = assign({}, button.choices[0], { icon: 'icon-picture', primary: true }); } else { button.disabled = true; button.choices[0] = { icon: 'icon-picture', primary: true, label: 'Export as Image' }; } // save and saveAs buttons // should work all the time as long as the // tab provides a save action [ 'save', 'save-as' ].forEach((key) => { var enabled = 'save' in newState; this.updateMenuEntry('modeler', key, !enabled); }); this.events.emit('changed'); }); this.events.on('log:toggle', (options) => { var open = options && options.open; if (typeof open === 'undefined') { open = !(this.layout.log && this.layout.log.open); } this.events.emit('layout:update', { log: { open: open } }); }); this.logger.on('changed', this.events.composeEmitter('changed')); this.events.on('layout:update', newLayout => { this.layout = merge(this.layout, newLayout); this.events.emit('changed'); }); this.events.on('dialog-overlay:toggle', this.compose('toggleDialogOverlay')); ///////// public API yea! ////////////////////////////////////// /** * Listen to an app event * * @param {String} event * @param {Function} callbackFn */ this.on = bind(this.events.on, this.events); /** * Emit an event via the app * * @param {String} event * @param {Object...} additionalArgs */ this.emit = bind(this.events.emit, this.events); // bootstrap support for diagram files this.tabProviders = [ this.createComponent(BpmnProvider, { app: this }), this.createComponent(DmnProvider, { app: this }), this.createComponent(CmmnProvider, { app: this }) ]; // let other components know that the window has been resized window.addEventListener('resize', this.events.composeEmitter('window:resized')); } inherits(App, BaseComponent); module.exports = App; App.prototype.render = function() { var dialogOverlayClasses = 'dialog-overlay'; if (this._activeDialogOverlay) { dialogOverlayClasses += ' active'; } var html = <div className="app" onDragover={ fileDrop(this.compose('openFiles')) }> <div className={ dialogOverlayClasses }></div> <MenuBar entries={ this.menuEntries } /> <Tabbed className="main" tabs={ this.tabs } active={ this.activeTab } onDragTab={ this.compose('shiftTab') } onSelect={ this.compose('selectTab') } onClose={ this.compose('closeTab') } /> <Footer layout={ this.layout } log={ this.logger } events={ this.events } /> </div>; return html; }; App.prototype.toggleDialogOverlay = function(isOpened) { this._activeDialogOverlay = isOpened; this.events.emit('changed'); }; /** * Create new application component with wired globals. * * @param {Function} Component constructor * @param {Object} [options] * * @return {Object} component instance */ App.prototype.createComponent = function(Component, options) { var actualOptions = assign(options || {}, { events: this.events, layout: this.layout, logger: this.logger, dialog: this.dialog, config: this.config }); return new Component(actualOptions); }; /** * Opens bare files descriptors, that have not been yet validated or processed. * * @param {Array<FileDescriptor>} files */ App.prototype.openFiles = function(files) { var dialog = this.dialog; series(files, (file, done) => { var type = parseFileType(file); if (!type) { dialog.unrecognizedFileError(file, function(err) { debug('open-diagram canceled: unrecognized file type', file); return done(err); }); } else { if (namespace.hasActivitiURL(file.contents)) { dialog.convertNamespace((err, answer) => { if (err) { debug('open-diagram error: %s', err); return done(err); } if (isCancel(answer)) { return done(null); } if (answer === 'yes') { file.contents = namespace.replace(file.contents); } done(null, assign({}, file, { fileType: type })); }); } else { done(null, assign({}, file, { fileType: type })); } } }, (err, diagramFiles) => { if (err) { return debug('open-diagram canceled: %s', err); } diagramFiles = filter(diagramFiles, (file) => { return !!file; }); this.openTabs(diagramFiles); }); }; /** * Open a new tab based on a file chosen by the user. */ App.prototype.openDiagram = function() { var dialog = this.dialog; dialog.open((err, files) => { if (err) { return dialog.openError(err, function() { debug('open-diagram canceled: %s', err); }); } if (!files) { return debug('open-diagram canceled: no file'); } this.openFiles(files); }); }; App.prototype.triggerAction = function(action, options) { debug('trigger-action', action, options); var activeTab = this.activeTab; if (action === 'select-tab') { if (options === 'next') { this.selectNext(); } if (options === 'previous') { this.selectPrevious(); } return; } if (action === 'create-bpmn-diagram') { return this.createDiagram('bpmn'); } if (action === 'create-dmn-diagram') { return this.createDiagram('dmn'); } if (action === 'create-cmmn-diagram') { return this.createDiagram('cmmn'); } if (action === 'open-diagram') { return this.openDiagram(); } if (action === 'save-all') { return this.saveAllTabs(); } if (action === 'quit') { return this.quit(); } if (action === 'close-all-tabs') { return this.closeAllTabs(); } if (action === 'reopen-last-tab') { return this.reopenLastTab(); } // Actions below require active tab if (!activeTab) { return; } if (action === 'close-active-tab') { if (activeTab.closable) { return this.closeTab(this.activeTab); } } // handle special actions if (action === 'save' && activeTab.save) { return this.saveTab(activeTab); } if (action === 'save-as' && activeTab.save) { return this.saveTab(activeTab, { saveAs: true }); } if (action === 'export-tab' && activeTab.exportAs) { return this.exportTab(activeTab, options.type); } // forward other actions to active tab activeTab.triggerAction(action, options); }; /** * Create diagram of the specific type. * * @param {String} type * @return {Tab} created diagram tab */ App.prototype.createDiagram = function(type) { var tabProvider = this._findTabProvider(type); var file = tabProvider.createNewFile(); return this.openTab(file); }; /** * Open tabs for the given files and make sure an appropriate * tab is selected and tabs are not opened twice. * * This method does not do any validation on the file internals * and assumes the creation of tabs for given files does not fail * (tabs should be robust and handle opening errors internally). * * @param {Array<FileDescriptor>} files * @return {Array<Tab>} return the opened tabs */ App.prototype.openTabs = function(files) { if (!Array.isArray(files)) { throw new Error('expected Array<FileDescriptor> argument'); } if (!files.length) { return; } var openedTabs = files.map((file) => { // make sure we do not double open tabs // for the same file return this.findTab(file) || this._createTab(file); }); // select the last opened tab this.selectTab(openedTabs[openedTabs.length - 1]); return openedTabs; }; /** * Open a single tab. * * @param {FileDescriptor} file * @return {Tab} the opened tab */ App.prototype.openTab = function(file) { return this.openTabs([ file ])[0]; }; /** * Create a new tab from the given file and add it * to the application. * * @param {FileDescriptor} file */ App.prototype._createTab = function(file) { var tabProvider = this._findTabProvider(file.fileType); return this._addTab(tabProvider.createTab(file)); }; /** * Save all open tabs */ App.prototype.saveAllTabs = function() { debug('saving all open tabs'); var activeTab = this.activeTab; series(this.tabs, (tab, done) => { if (!tab.save || !tab.dirty) { // skipping tabs that cannot save or are dirty return done(null); } this.saveTab(tab, function(err, savedFile) { if (err || !savedFile) { return done(err || userCanceled()); } return done(null, savedFile); }); }, (err) => { if (err) { return debug('save all canceled', err); } debug('save all finished'); // restore active tab this.selectTab(activeTab); }); }; /** * Export the given tab with an image type. * * @param {Tab} tab * @param {String} [type] * @param {Function} [done] */ App.prototype.exportTab = function(tab, type, done) { if (!tab) { throw new Error('need tab to save'); } if (!tab.save) { throw new Error('tab cannot #save'); } done = done || function(err, savedFile) { if (err) { debug('export error: %s', err); } else if (!savedFile) { debug('export user canceled'); } else { debug('exported %s \n%s', tab.id, savedFile.contents); } }; tab.exportAs(type, (err, file) => { if (err) { return done(err); } this.saveFile(file, true, done); }); }; /** * Find the open tab for the given file, if any. * * @param {FileDescriptor} file * @return {Tab} */ App.prototype.findTab = function(file) { if (isUnsaved(file)) { return null; } return find(this.tabs, function(t) { var tabPath = (t.file ? t.file.path : null); return file.path === tabPath; }); }; /** * Find a tab provider for the given file type. * * @param {String} fileType * * @return {TabProvider} */ App.prototype._findTabProvider = function(fileType) { var tabProvider = find(this.tabProviders, function(provider) { return provider.canCreate(fileType); }); if (!tabProvider) { throw noTabProvider(fileType); } return tabProvider; }; /** * Save the given tab with optional new name and * path (passed via options). * * The saved file is passed as the second argument to the * provided callback, unless the user canceled the save operation. * * @param {Tab} tab * @param {Object} [options] * @param {Function} [done] invoked with (err, savedFile) */ App.prototype.saveTab = function(tab, options, done) { var dialog = this.dialog; if (!tab) { throw new Error('need tab to save'); } if (typeof options === 'function') { done = options; options = undefined; } done = done || function(err) { if (err) { dialog.saveError(err, function() { debug('error: %s', err); }); } }; var updateTab = (err, savedFile) => { if (err) { debug('not gonna update tab: %s', err); return done(err); } if (!savedFile) { debug('save file canceled'); return done(); } debug('saved %s', tab.id); // finally saved... tab.setFile(savedFile); this.events.emit('workspace:changed'); return done(null, savedFile); }; debug('saving %s', tab.id); // keep track of current active tab var activeTab = this.activeTab; // making sure tab is selected before save this.selectTab(tab); tab.save((err, file) => { // restore last active tab this.selectTab(activeTab); if (err) { return done(err); } debug('exported %s \n%s', tab.id, file.contents); var saveAs = isUnsaved(file) || options && options.saveAs; this.saveFile(file, saveAs, updateTab); }); }; /** * Save the given file and invoke callback with (err, savedFile). * * @param {FileDescriptor} file * @param {Boolean} saveAs whether to ask the user for a file name * @param {Function} done */ App.prototype.saveFile = function(file, saveAs, done) { var self = this; var dialog = this.dialog, fileSystem = this.fileSystem; function handleFileError(err, savedFile) { if (err) { return dialog.savingDenied(function(err, choice) { if (err) { debug('save file canceled: %s', err); return done(err); } if (isCancel(choice)) { return; } self.saveFile(file, { saveAs: true }, done); }); } done(null, savedFile); } if (!saveAs) { return fileSystem.writeFile(assign({}, file), handleFileError); } dialog.saveAs(file, (err, suggestedFile) => { if (err) { debug('save file error', err); return done(err); } if (!suggestedFile) { debug('save file canceled'); return done(); } debug('save file %s as %s', file.name, suggestedFile.path); fileSystem.writeFile(assign({}, file, suggestedFile), handleFileError); }); }; /** * Select the given tab. May also be used to deselect all tabs * (empty selection) when passing null. * * @param {Tab} tab */ App.prototype.selectTab = function(tab) { debug('selecting tab'); var exists = contains(this.tabs, tab); if (tab && !exists) { throw new Error('non existing tab'); } this.activeTab = tab; if (tab) { tab.emit('focus'); this.recheckTabContent(tab); } this.events.emit('workspace:changed'); this.events.emit('changed'); }; /** * Select next or previous non-empty tab. * Defaults to previous tab. * * @param {Boolean} isNext */ App.prototype._selectWithDirection = function(isNext) { var nonEmptyTabs = filter(this.tabs, function(t) { return !t.empty; }); if (nonEmptyTabs.length < 2) { return; } var i = nonEmptyTabs.indexOf(this.activeTab); if (isNext) { i = (i + 1) % nonEmptyTabs.length; } else { i = (i - 1 + nonEmptyTabs.length) % nonEmptyTabs.length; } this.selectTab(nonEmptyTabs[i]); }; /** * Select next non-empty tab */ App.prototype.selectNext = function() { this._selectWithDirection(true); }; /** * Select previus non-empty tab */ App.prototype.selectPrevious = function() { this._selectWithDirection(false); }; /** * Close the given tab. If the user aborts the operation * (i.e. cancels it via dialog choice) the callback will * be evaluated with (null, 'canceled'). * * @param {Tab} tab * @param {Function} [done] passed with (err, status=(canceled, ...)) */ App.prototype.closeTab = function(tab, done) { debug('close tab', tab); var tabs = this.tabs, dialog = this.dialog, file; var exists = contains(tabs, tab); if (!exists) { throw new Error('non existing tab'); } if (typeof done !== 'function') { done = function(err) { if (err) { debug('error: %s', err); } }; } // close normally when file is already saved if (!tab.dirty) { return this._closeTab(tab, done); } file = tab.file; dialog.close(file, (err, result) => { debug('---->', err, result); if (isCancel(result)) { debug('close-tab canceled: %s', err); return done(userCanceled()); } if (err) { debug('close-tab error: %s', err); return done(err); } // close without saving if (isDiscard(result)) { return this._closeTab(tab, done); } // save and then close the tab this.saveTab(tab, (err, savedFile) => { if (err) { debug('save-tab error: %s', err); return done(err); } return this._closeTab(tab, done); }); }); }; /** * Close given tab and select other tab, if current one is active. * * @param {Tab} tab * @param {Function} done */ App.prototype._closeTab = function(tab, done) { var tabs = this.tabs, events = this.events; tab.emit('destroy'); events.emit('tab:close', tab); var idx = tabs.indexOf(tab); // remove tab from selection tabs.splice(idx, 1); // if tab was active, select previous (if exists) or next tab if (tab === this.activeTab) { this.selectTab(tabs[idx - 1] || tabs[idx]); } if (!isUnsaved(tab.file)) { this.fileHistory.push(tab.file); } events.emit('workspace:changed'); events.emit('changed'); return done(); }; /** * Add a tab to the app at an appropriate position. * * @param {Tab} tab * @return {Tab} the added tab */ App.prototype._addTab = function(tab) { var tabs = this.tabs, events = this.events; // always add tab right before the EMPTY_TAB // TODO(vlad): make adding before empty tab more explicit tabs.splice(tabs.length - 1, 0, tab); events.emit('workspace:changed'); events.emit('changed'); return tab; }; /** * Persist the current workspace state * * @param {Function} done */ App.prototype.persistWorkspace = function(done) { var config = { tabs: [], activeTab: -1 }; // store tabs this.tabs.forEach((tab, idx) => { var file = tab.file; // do not persist unsaved files if (isUnsaved(file)) { return; } config.tabs.push(assign({}, file)); // store saved active tab index if (tab === this.activeTab) { config.activeTab = config.tabs.length - 1; } }); // store layout config.layout = this.layout; // let others store stuff, too this.events.emit('workspace:persist', config); // actually save this.workspace.save(config, (err, config) => { this.events.emit('workspace:persisted', err, config); done(err, config); }); }; /** * Restore previously saved workspace, if any exists. * * @param {Function} done */ App.prototype.restoreWorkspace = function(done) { var defaultWorkspace = { tabs: [], layout: { propertiesPanel: { open: false, width: 250 }, log: { open: false, height: 150 } } }; this.workspace.load(defaultWorkspace, (err, workspaceConfig) => { if (err) { debug('workspace load error', err); return done(err); } // restore tabs if (workspaceConfig.tabs && workspaceConfig.tabs.length) { this.openTabs(workspaceConfig.tabs); } if (workspaceConfig.activeTab && workspaceConfig.activeTab !== -1) { this.activeTab = this.tabs[workspaceConfig.activeTab]; } this.events.emit('layout:update', workspaceConfig.layout); this.events.emit('changed'); this.events.emit('workspace:restored'); // we are done done(null, workspaceConfig); }); }; /** * Load the application configuration. * * @param {Function} done */ App.prototype.loadConfig = function(done) { this.config.load((err) => { if (err) { debug('configuration load error', err); return done(err); } this.events.emit('changed'); this.events.emit('configuration:loaded'); // we are done done(null); }); }; /** * Enables/disables any (button) menu entries * * @param {String} id * @param {Boolean} isDisabled */ App.prototype.updateMenuEntry = function(group, id, isDisabled) { var button = find(this.menuEntries[group].buttons, { id: id }); button.disabled = isDisabled; this.events.emit('changed'); }; /** * Start application. */ App.prototype.run = function() { // initialization sequence // // (0) select empty tab // (1) load configuration // (2) restore workspace // (3) indicate ready this.selectTab(this.tabs[0]); this.loadConfig((err) => { if (err) { this.logger.warn('Failed to load config', err); } this.restoreWorkspace((err) => { if (err) { debug('workspace restore error', err); } else { debug('workspace restored'); } this.events.emit('ready'); }); }); this.events.emit('changed'); }; /** * Shifts a dragged tab to a new position (index based) * * @param {Tab} tab * @param {Number} newIdx */ App.prototype.shiftTab = function(tab, newIdx) { var tabs = this.tabs, tabIdx; if (!tab) { return; } tabIdx = tabs.indexOf(tab); tabs.splice(tabIdx, 1); tabs.splice(newIdx, 0, tab); this.events.emit('workspace:changed'); this.events.emit('changed'); }; /** * Close all given tabs in a sequence. * Aborts if user cancels any of the dialogs. * * @param {Array<Tab>} tabs * @param {Function} cb */ App.prototype._closeTabs = function(tabs, cb) { cb = cb || function(err) { if (err) { debug('error: %s', err); } }; series(tabs, (tab, done) => { this.selectTab(tab); // TODO: make sure newly selected tab is rendered this.closeTab(tab, done); }, cb); }; /** * Closes all tabs that have external files associated with them. */ App.prototype.closeAllTabs = function() { var tabs = this.tabs.filter(function(tab) { return !!tab.file; }); this._closeTabs(tabs); }; App.prototype.reopenLastTab = function() { var file = this.fileHistory.pop(); if (file) { this.openFiles([ file ]); } }; /** * Initiates application quit. */ App.prototype.quit = function() { debug('initiating application quit'); var dirtyTabs = this.tabs.filter(function(tab) { return tab.dirty; }); this._closeTabs(dirtyTabs, (err) => { if (err) { debug('quit aborted'); return this.events.emit('quit-aborted'); } debug('shutting down application'); // we have to use the event based workspace persisting // or there will be race conditions on quit this.events.emit('workspace:changed', () => { this.events.emit('quitting'); }); }); }; var rdebug = require('debug')('app - external change'); /** * Checks tab content for external changes * @param {Tab} tab */ App.prototype.recheckTabContent = function(tab) { if (isUnsaved(tab.file)) { return rdebug('skipping (unsaved)'); } rdebug('checking'); if (typeof tab.file.lastModified === 'undefined') { return rdebug('skipping (missing tab.file.lastChanged)'); } var setNewFile = (file) => { tab.setFile(assign({}, tab.file, file)); this.events.emit('workspace:changed'); }; this.fileSystem.readFileStats(tab.file, (err, statsFile) => { if (err) { return rdebug('file check error', err); } rdebug('last modified { tab: %s, stats: %s }', tab.file.lastModified || 0, statsFile.lastModified); if (!(statsFile.lastModified > tab.file.lastModified)) { return rdebug('unchanged'); } rdebug('external change'); // notifying user about external changes this.dialog.contentChanged((answer) => { if (isOk(answer)) { rdebug('reloading'); this.fileSystem.readFile(tab.file, function(err, updatedFile) { if (err) { return rdebug('reloading failed', err); } setNewFile(updatedFile); }); } else if (isCancel(answer)) { rdebug('NOT reloading'); setNewFile(statsFile); } }); }); }; function contains(collection, element) { return collection.some(function(e) { return e === element; }); } function isDiscard(userChoice) { return userChoice === 'discard'; } function isCancel(userChoice) { return userChoice === 'cancel'; } function isOk(userChoice) { return userChoice === 'ok'; } function userCanceled() { return new Error('user canceled'); } function noTabProvider(fileType) { throw new Error('missing provider for file <' + fileType + '>'); }