From bc8f21212c7277d52338114eccb796603d481e1e Mon Sep 17 00:00:00 2001 From: "Robert E. Griffith" <36312888+bobjunga@users.noreply.github.com> Date: Fri, 1 May 2020 09:17:43 -0400 Subject: [PATCH] cleaning up for 2.x release * improved the dispose pattern for BGAtomPlugin * BGAtomPlugin: added watchConfig, watchPackage, watchPreCommand, watchPostCommand * moved BGStylesheet from here to bg-atom-redom-ui * organized pkg helpers into bg-packageManager accessing via atom2.packages * added bg-promise * --- BGAtomPlugin.js | 113 +++++++++++++++--- BGStylesheet.js | 273 ------------------------------------------- README.md | 2 +- bg-atom-utils.js | 4 +- bg-packageManager.js | 255 ++++++++++++++++++++++++++++++++++++++++ bg-promise.js | 109 +++++++++++++++++ miscellaneous.js | 192 +----------------------------- procCntr.js | 104 ----------------- 8 files changed, 466 insertions(+), 586 deletions(-) delete mode 100644 BGStylesheet.js create mode 100644 bg-packageManager.js create mode 100644 bg-promise.js delete mode 100644 procCntr.js diff --git a/BGAtomPlugin.js b/BGAtomPlugin.js index a8d98a6..3b06847 100644 --- a/BGAtomPlugin.js +++ b/BGAtomPlugin.js @@ -1,8 +1,6 @@ - -import { Disposable, CompositeDisposable } from 'atom'; +import { Disposables } from 'bg-atom-redom-ui'; import { FirstParamOf } from './miscellaneous'; - // BGAtomPlugin makes writing Atom plugin packages easier. // Atom Entry Points: // initialize: maps to static PluginClass.Initialize() @@ -14,8 +12,10 @@ import { FirstParamOf } from './miscellaneous'; // when the package is activated via settings-view, this will still be called after the tick that the constructor is in settles // Members: // lastSessionsState : contains the deserialization state passed from the atom activate call -// disposables : an instance of CompositeDisposable to add things that need to be undone in destroy -// addCommand() : wrapper to atom.commands.add() +// disposables : an instance of Disposables to add things that need to be undone in destroy +// addCommand() : wrapper to atom.commands.add(). associates the commands with this plugin and unregisters them in deactivate +// watchConfig() : get notified when a configKey's value changes +// watchPackages() : get notified when a package's activation state changes // Plugin Registry: // window.bgPlgins['my-plugin'] : other packages and user init.js can find your package's services dynamically // Example: @@ -36,16 +36,19 @@ export class BGAtomPlugin { // usage: pkgName, lastSessionsState constructor(...p) { this.pkgName = FirstParamOf('string', ...p); - this.lastSessionsState = FirstParamOf('object', ...p); + this.lastSessionsState = FirstParamOf('object', ...p) || {}; this.PluginClass = new.target; - // subscriptions is a place to put things that need to be cleaned up on deativation - this.disposables = new CompositeDisposable(); + this.disposables = new Disposables(); // make an alias to support transitioning to this.disposables name - this.subscriptions = this.disposables; - this.registeredCommands = []; + // When the derived class uses our methods to create resources, they are tracked in these Maps and automatically disposed. + this.registeredCommands = new DisposableMap(); + this.watchedConfig = new DisposableMap(); + this.watchedPacakges = new DisposableMap(); + this.watchedPreCmd = new DisposableMap(); + this.watchedPostCmd = new DisposableMap(); console.assert(!this.PluginClass.instance, 'Package plugin being constructed twice. Should be a singleton. pacakgeName='+this.pkgName); this.PluginClass.instance = this; @@ -66,20 +69,85 @@ export class BGAtomPlugin { //lateActivate(state) {} destroy() { - this.disposables.dispose(); + // this.registeredCommands.forEach(v=>v.dispose()); this.registeredCommands.clear(); + // this.watchedConfig.forEach(v=>v.dispose()); this.watchedConfig.clear(); + // this.watchedPacakges.forEach(v=>v.dispose()); this.watchedPacakges.clear(); + + for (const name of Object.getOwnPropertyNames(this)) { + const prop = this[name]; + if (typeof prop == 'object' && typeof prop.dispose == 'function') { + //console.log(`!!!found ${name} to dispose`); + prop.dispose(); + } + } + this.PluginClass.instance = null delete BGAtomPlugin.plugins[this.pkgName]; } - serialize() { - return ; - } + serialize() {} + // add to atom's command pallette + // call this with a null callback to remove the command addCommand(name, callback) { - this.registeredCommands.push(name); - var obj = {} - obj[name] = callback; - this.disposables.add(atom.commands.add('atom-workspace', obj)); + const prevValue = this.registeredCommands.get(name); + if (prevValue) { + prevValue.dispose; + this.registeredCommands.delete(name); + } + callback && this.registeredCommands.set(name, atom.commands.add('atom-workspace', {[name]:callback})); + } + + // callback gets invoked whenever thespecified configKey changes value + // call this with a null callback to stop watching + watchConfig(configKey, callback) { + const prevValue = this.watchedConfig.get(configKey); + if (prevValue) { + prevValue.dispose; + this.watchedConfig.delete(configKey) + } + callback && this.watchedConfig.set(configKey, atom.config.onDidChange(configKey, {}, callback)); + } + + // callback gets invoked whenever a specified pkgName changes activation state. Callback is passed the name of the package and + // a boolean indicating the current activation state. + // call this with a null callback to stop watching + watchPackage(pkgNames, callback) { + typeof pkgNames == 'string' && (pkgNames = pkgNames.split(',')); + for (const pkgName of pkgNames) { + const prevValue = this.watchedPacakges.get(pkgName); + if (prevValue) { + prevValue.dispose; + this.watchedPacakges.delete(configKey) + } + callback && this.watchedPacakges.set(pkgName, { + onAct: atom.packages.onDidActivatePackage( (pkg)=>{if (pkgName==pkg.name) callback(pkg.name, true);}), + onDea: atom.packages.onDidDeactivatePackage((pkg)=>{if (pkgName==pkg.name) callback(pkg.name, false);}), + dispose: function () {this.onAct.dispose(); this.onDea.dispose()} + }); + } + } + + watchPreCommand(cmdSpec, callback) { + typeof cmdSpec == 'string' && (cmdSpec = new RegExp(cmdSpec)); + const key = cmdSpec.toString(); + const prevValue = this.watchedPreCmd.get(key); + if (prevValue) { + prevValue.dispose; + this.watchedPreCmd.delete(key) + } + callback && this.watchedPreCmd.delete(key, atom.commands.onWillDispatch((e)=>{if (cmdSpec.test(e.type)) callback(e.type,e);})); + } + + watchPostCommand(cmdSpec, callback) { + typeof cmdSpec == 'string' && (cmdSpec = new RegExp(cmdSpec)); + const key = cmdSpec.toString(); + const prevValue = this.watchedPostCmd.get(key); + if (prevValue) { + prevValue.dispose; + this.watchedPostCmd.delete(key) + } + callback && this.watchedPostCmd.delete(key, atom.commands.onDidDispatch((e)=>{if (cmdSpec.test(e.type)) callback(e.type,e);})); } // Use this static method to export your MyPluginClass that extends BGAtomPlugin @@ -95,5 +163,14 @@ export class BGAtomPlugin { } } +// helper class to manage containers of resorces that we need to dispose of when deactivated +class DisposableMap extends Map { + dispose() { + this.forEach(v=>v.dispose()); + this.clear(); + } +} + + BGAtomPlugin.plugins = {}; global.bgPlugins = BGAtomPlugin.plugins; diff --git a/BGStylesheet.js b/BGStylesheet.js deleted file mode 100644 index 7abc6ef..0000000 --- a/BGStylesheet.js +++ /dev/null @@ -1,273 +0,0 @@ - -export class BGStylesheet { - constructor() { - var styleEl = document.createElement("style"); - document.head.appendChild(styleEl); - this.dynStyles = styleEl.sheet; - this.freeIDs = []; - // consume the '0' index b/c 0 is not a valid ruleID - this.dynStyles.insertRule('#NOTHING {border: none}', 0); - r=this; // for console inspection - } - - isEmpty() { return this.dynStyles.cssRules.length == 0} - - addRule(cssText) { - if (this.freeIDs.length > 0) { - var ruleID = this.freeIDs.pop(); - return this.updateRule(ruleID, cssText) - } else { - var ruleID = this.dynStyles.cssRules.length; - this.dynStyles.insertRule(cssText, ruleID); - return ruleID; - } - } - updateRule(ruleID, cssText) { - if (!ruleID) - return this.addRule(cssText) - this.dynStyles.deleteRule(ruleID); - this.dynStyles.insertRule(cssText, ruleID); - return ruleID; - } - deleteRule(ruleID) { - this.updateRule(ruleID, '#NOTHING {}') - this.freeIDs.push(ruleID); - return 0; - } - deleteAllRules() { - while (this.dynStyles.cssRules.length > 0) { - this.dynStyles.deleteRule(this.dynStyles.cssRules.length -1); - } - this.freeIDs = []; - } - addAllRules(ruleArray) { - this.deleteAllRules(); - for (var i=0; i< ruleArray.length; i++) { - this.dynStyles.insertRule(ruleArray[i], i); - } - } -} - - - -// This class facilitates adjusting the size of List Items in Atom (namely the tree-view and tabs) -// It allows changing the fontSize and line-height separately -// atom has a weird style rule that sets the line-height of list items and their hightlight bars to a fixed 25px with very specific -// selectors that can not be overrided at the container level. This class overrides that by -// 1) creating a dynamic style sheet with more specific rules to override the Atom selectors -// 2) setting the fontSize at container so that it will apply to all the items within. -export class BGAtomTreeItemFontSizer { - constructor(treeViewEl,state) { - this.treeViewEl = treeViewEl; - this.dynStyles = new BGStylesheet(); - this.fontSizeToLineHeightPercent = 230; // this is about the equivalent % to the atom hardcoded 11px/25px font/lineHeight - - // handle incoming serialized state from the last run - if (state && state.active) { - this.fontSizeToLineHeightPercent = state.lineHeight; - this.setFontSize(state.fontSize, true); - } - } - - // set the new font size by adjusting the existing fontSize by a number of pixels. Nagative numbers make the font smaller - adjustFontSize(delta) { - this.setFontSize(this.getFontSize() + delta); - } - - // resetting returns to the default Atom styling - resetFontSize() { - if (this.dynStyles && this.cssListItemRule) { - this.cssListItemRule = this.dynStyles.deleteRule(this.cssListItemRule); - this.cssListHighlightRule = this.dynStyles.deleteRule(this.cssListHighlightRule); - this.cssListHighlightRuleRoot = this.dynStyles.deleteRule(this.cssListHighlightRuleRoot); - } - if (this.treeViewEl) - this.treeViewEl.style.fontSize = ''; - } - - // set the new size in pixels - async setFontSize(fontSize, fromCtor) { - // if not yet set or if the fontSizeToLineHeightPercent has changed, set a dynamic rule to override the line-height - if (!this.cssListItemRule || this.fontSizeToLineHeightPercent != this.lastFontSizeToLineHeightPercent) { - this.cssListItemRule = this.dynStyles.updateRule(this.cssListItemRule, ` - .tool-panel.tree-view .list-item { - line-height: ${this.fontSizeToLineHeightPercent}% !important; - } - `); - this.lastFontSizeToLineHeightPercent = this.fontSizeToLineHeightPercent; - } - - // set the font size at the top of the tree-view and it will affect all the item text - this.treeViewEl.style.fontSize = fontSize+'px'; - - // the highlight bar is also hardcoded to 25px so create a dynamic rule to set it - // to determine the height, we query the height of a list-item. The root is not a good choice because themes can style it - // differently. If the root node is collapsed, expand it so that we have a regular list-item to query - if (this.treeViewEl.getElementsByClassName('list-item').length <= 1) { - atom.commands.dispatch(this.treeViewEl, 'core:move-to-top'); - atom.commands.dispatch(this.treeViewEl, 'tree-view:expand-item'); - for (var i=0; i<10 && this.treeViewEl.getElementsByClassName('list-item').length <= 1; i++) - await bgAsyncSleep(100); - } - var lineBoxHeight = 0; - if (this.treeViewEl.getElementsByClassName('list-item').length > 1) - lineBoxHeight = this.treeViewEl.getElementsByClassName('list-item')[1].getBoundingClientRect().height; - else - lineBoxHeight = Math.trunc(fontSize * this.fontSizeToLineHeightPercent /100); - this.cssListHighlightRule = this.dynStyles.updateRule(this.cssListHighlightRule, ` - .tool-panel.tree-view .list-item.selected::before, .tool-panel.tree-view .list-nested-item.selected::before { - height:${lineBoxHeight}px !important; - } - `); - - // only for the intial call from th constructor to restore the previous state, do we yeild. This is only because the root - // tree item does not have its actual size until later. Appearently some Atom code changes something that affects its height - (fromCtor) ? await bgAsyncSleep(1) : null; - var rootLineBoxHeight = 0; - if (this.treeViewEl.getElementsByClassName('list-item').length > 0) { - rootLineBoxHeight = this.treeViewEl.getElementsByClassName('list-item')[0].getBoundingClientRect().height; - } else { - rootLineBoxHeight = lineBoxHeight; - } - this.cssListHighlightRuleRoot = this.dynStyles.updateRule(this.cssListHighlightRuleRoot, ` - .tool-panel.tree-view .project-root.selected::before { - height:${rootLineBoxHeight}px !important;} - `); - - // this.cssListItemRule = this.dynStyles.updateRule(this.cssListItemRule, [ - // ` - // .tool-panel.tree-view .list-item { - // line-height: ${this.fontSizeToLineHeightPercent}% !important; - // }`, - // ` - // .tool-panel.tree-view .list-item.selected::before, .tool-panel.tree-view .list-nested-item.selected::before { - // height:"+lineBoxHeight+"px !important; - // }`, - // ` - // .tool-panel.tree-view .project-root.selected::before { - // height:${rootLineBoxHeight}px !important;} - // `]); - } - - // return the existing size in pixels - getFontSize() { - if (!this.treeViewEl) - return 11; - var currentFontSize = parseInt(this.treeViewEl.style.fontSize); - if (!currentFontSize) { - currentFontSize = parseInt(window.getComputedStyle(this.treeViewEl, null).fontSize); - } - return currentFontSize; - } - - setItemLineHightPercentage(lineHeightPercent) { - this.fontSizeToLineHeightPercent = lineHeightPercent; - this.setFontSize(this.getFontSize()); - } - - adjustItemLineHightPercentage(delta) { - this.fontSizeToLineHeightPercent += delta; - this.setFontSize(this.getFontSize()); - } - - serialize() { - return { - active: (this.cssListItemRule ? true:false), - fontSize: this.getFontSize(), - lineHeight: this.fontSizeToLineHeightPercent - } - } - - dispose() { - this.resetFontSize() - } -} - - -export class BGAtomTabFontSizer { - constructor(dockSelector, state) { - this.dockSelector = dockSelector; - this.dynStyles = new BGStylesheet(); - - // temp hard code - this.currentFontSize = 11; - this.currentTabBarHeight = 36; - - // handle incoming serialized state from the last run - if (state && state.active) { - this.setFontSize(state.fontSize, state.tabBarHeight); - } - } - - // set the new font size by adjusting the existing fontSize by a number of pixels. Nagative numbers make the font smaller - adjustFontSize(delta) { - var {fontSize, tabBarHeight} = this.getTabBarSizes(); - this.setFontSize(fontSize + delta, tabBarHeight + 1*delta); - } - - // set the new font size by adjusting the existing fontSize by a number of pixels. Nagative numbers make the font smaller - adjustBarHeight(delta) { - var {fontSize, tabBarHeight} = this.getTabBarSizes(); - this.setFontSize(fontSize, tabBarHeight + delta); - } - - // resetting returns to the default Atom styling - resetFontSize() { - this.dynStyles.deleteAllRules(); - var {fontSize, tabBarHeight} = this.getTabBarSizes(); - } - - // set the new size in pixels - async setFontSize(fontSize, tabBarHeight) { - // .tab-bar {changed height 36 to 72} - // .tab-bar .tab, .tab-bar .tab::before {changed height 26 to inherit, but 62 was better} - // .tab-bar .tab {-> height 26 to inherit (top justified -- needs font-size)} - // .tab-bar .tab .close-icon {line-height 26 to inherit (not right yet)} - // .tab-bar .tab:before, .tab-bar .tab:after {-> height 26 to inherit } - // .tab-bar .tab {font-size 11px to 35px} - - // atom-workspace-axis.vertical > atom-panel-container.pane - this.cssListItemRule = this.dynStyles.addAllRules([` - ${this.dockSelector} .tab-bar {height: ${tabBarHeight}px !important;} - `, ` - ${this.dockSelector} .tab-bar .tab, .tab-bar .tab::before {height: inherit !important;} - `, ` - ${this.dockSelector} .tab-bar .tab {height: inherit !important;} - `, ` - ${this.dockSelector} .tab-bar .tab .close-icon {line-height: inherit !important;} - `, ` - ${this.dockSelector} .tab-bar .tab:before, .tab-bar .tab:after {height: inherit !important;} - `, ` - ${this.dockSelector} .tab-bar .tab {font-size: ${fontSize}px !important;} - ` - ]); - this.currentFontSize = fontSize; - this.currentTabBarHeight = tabBarHeight; - } - - // return the existing size in pixels - getTabBarSizes() { - var fontSize=11; - var tabBarHeight=26; - var bar = document.querySelector(this.dockSelector+' .tab-bar'); - tabBarHeight = (bar) ? bar.getBoundingClientRect().height : tabBarHeight; - var tab = document.querySelector(this.dockSelector+' .tab-bar .tab'); - if (tab) { - fontSize = parseInt(window.getComputedStyle(tab).fontSize); - tabBarHeight = Math.max(tabBarHeight, tab.getBoundingClientRect().height); - } - return {fontSize: fontSize, tabBarHeight: tabBarHeight}; - } - - serialize() { - return { - active: (!this.dynStyles.isEmpty() ? true:false), - fontSize: this.currentFontSize, - tabBarHeight: this.currentTabBarHeight - } - } - - dispose() { - this.resetFontSize() - } -} diff --git a/README.md b/README.md index 5f3e059..450aeb2 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ See the atom package bg-atom-packageDev which demonstrate how to use this packag * class BGAtomTabFontSizer : change the size of tab controls in tree-view dynamically ### Functions -* function OnDependentPackageStateChange(packageNames, callback) : makes depending on other pkgs more convenient +* function WatchPackageStateChange(packageNames, callback) : makes depending on other pkgs more convenient * function bgAsyncSleep(ms) : delay function to be used in sync functions * function FirstParamOf : helper for overloaded function parameters * function ArrangeParamsByType : helper for overloaded function parameters diff --git a/bg-atom-utils.js b/bg-atom-utils.js index 6ffe4f1..aa4493c 100644 --- a/bg-atom-utils.js +++ b/bg-atom-utils.js @@ -1,6 +1,6 @@ export * from './BGAtomPlugin' export * from './BGAtomView' -export * from './BGStylesheet' export * from './BGTimer' export * from './miscellaneous' -export * from './procCntr' +export * from './bg-packageManager' +export * from './bg-promise' diff --git a/bg-packageManager.js b/bg-packageManager.js new file mode 100644 index 0000000..12923b6 --- /dev/null +++ b/bg-packageManager.js @@ -0,0 +1,255 @@ +import assert from 'assert'; +import { BGPromise } from './bg-promise'; +import { BGFeedbackDialog } from './miscellaneous' +import { Disposables } from 'bg-atom-redom-ui' +import { exec, spawn, execSync } from 'child_process'; + +// this is a hack to subclass the atom.packages instance with proposed features. +// atom.packages is untouched +// atom2.packages is same as atom.packages but also has these extra methods +export class BGPackageManager { + installPackage(packageNames, {onAllFinishedCB, onPkgFinishedCB, confirmPromt=true, extraButtons=[], ...p}) { + return PackageInstall(packageNames, {onAllFinishedCB, onPkgFinishedCB, confirmPromt, extraButtons, ...p}); + } + + onDidPackageStateChange(packageNames, callback) { + WatchPackageStateChange(packageNames, callback) + } +} +Object.setPrototypeOf(BGPackageManager.prototype, atom.packages) +if (!global.atom2) global.atom2 = {} +if (!atom2.packages) atom2.packages = new BGPackageManager(); + + +// Install one or more Atom pacakges so that their features are available inside Atom. It uses apmInstall to do the work. +// Features: +// * can accept a single pkgName, a comma separated list of pkgNames, or an array of pkgNames +// * prompts the user for confirmation by default. +// * shows progress and informs the user of success or failure. +// It is not an error to call this function when the package is already installed or activated. +// Note that this initial implementation will launch one apm for each packageName all at once. If this is used to install many +// packages, we would want to use a worker queue pattern to limit the simultaneous apm runs. Also, the confirmation dialog will need +// display the long list better. +// +// Async Pattern: +// This function returns a promise so that it can be used with await in async functions and/or the caller can pass in onAllFinishedCB +// and/or onPkgFinishedCB. onAllFinishedCB is equivalent to waiting for the promise but onPkgFinishedCB gives the caller access to additional +// information. The author might want to use await to tell when all packages are ready (or catch the exception if any can not be installed) +// and also provide onPkgFinishedCB to provide feedback as pkgs finish installing. +// +// Return Value: +// : On success, there is no information returned but the promise or onAllFinishedCB callback lets you know when its done. +// +// Params: +// :string|Array : one or more package names to install. A string input can have comma separated names. +// (err) : optional callback that is called after all has been attempted. On success, err is falsey +// This function also returns a promise so the caller can use that (.then.. or await) instead of onAllFinishedCB. +// (pkgName, err, stdout, stderr) : optional callback that is called after each is installed or +// failed to install. This function already gives the user feedback on each package success/failure so this +// is not typically needed. +// :true|false : controls whether the user is prompted to confirm installation. If false, the installation will +// start right away. +// :object : extra buttons to display in the confirmation prompt. These can offer alternatives to installing the packages +// such as configuring the system so that the packages are not needed. +// See https://flight-manual.atom.io/api/v1.45.0/NotificationManager/#instance-addInfo +export function PackageInstall(packageNames, {onAllFinishedCB, onPkgFinishedCB, confirmPromt=true, extraButtons=[], ...p}) { + var pkgInstaller = new PkgInstaller(packageNames, {onAllFinishedCB, onPkgFinishedCB, confirmPromt, extraButtons, ...p}) + return pkgInstaller.prom +} + + +// PkgInstaller provides a dialog workflow for the user to interact with the installation process. The user can be given the option +// to approve the installation, choose an alternative to installation, or refuse the installation. Once the installation is started +// the user sees the progress and results when finsished. +class PkgInstaller { + constructor(packageNames, {onAllFinishedCB, onPkgFinishedCB, confirmPromt=true, extraButtons=[]}) { + this.packageNames = (typeof packageNames == 'string') ? packageNames.split(',') : packageNames; + this.prom = new BGPromise(); + this.onAllFinishedCB = onAllFinishedCB; + this.onPkgFinishedCB = onPkgFinishedCB; + this.extraButtons = extraButtons; + + if (confirmPromt) + this.confirmInstallation() + else + this.installPkgs() + } + + // This opens a dialog with the user giving them several choices represented in the buttons parameter. Each choice must lead to + // this.prom being resolved or rejected. Install -> this.installPkgs(), do nothing -> this.endWithoutInstalling(), extraButtons -> + this.endWithoutInstalling + confirmInstallation() { + // fixup the provided extra buttons to call this.endWithoutInstalling after they do their work + for (const button of this.extraButtons) { + let upstreamCB = button.onDidClick; + button.onDidClick = (...p)=>{upstreamCB(...p); this.endWithoutInstalling()} + } + this.confirmDlg = atom.notifications.addWarning( + `The feature you are accessing requires that the the following package(s) be installed.`, + { dismissable: true, + icon: 'cloud-download', + detail: this.packageNames.join(', '), + description: "How do you want to proceed?", + buttons: [ + {text:'Install package(s)', onDidClick:()=>{this.installPkgs(); }}, + ...this.extraButtons, + {text:'Do nothing for now', onDidClick:()=>{this.endWithoutInstalling();}} + ] + } + ); + } + + installPkgs() { + try { + this.confirmDlg && this.confirmDlg.dismiss(); + + this.feedbackDlg = new BGFeedbackDialog("Installing Packages", { + dismissable: true, + icon: 'cloud-download', + status: this.packageNames.join(', '), + }); + + // launch apm install for each pkgName, keeping track of + this.pkgsPendingCount = this.packageNames.length + this.pkgsSuccessList = []; + this.pkgsFailList = []; + + for (const pkgName of this.packageNames) { + apmInstall(pkgName, + // success callback of one apmInstall invocation + (stdout, stderr)=> { + this.pkgsSuccessList.push(pkgName); + this.onOnePkgFinish(pkgName, 0, stdout, stderr) + }, + // fail callback of one apmInstall invocation + (err, stdout, stderr)=> { + this.pkgsFailList.push(pkgName); + this.onOnePkgFinish(pkgName, err, stdout, stderr) + } + ) + } + } catch(e) { + this.feedbackDlg && this.feedbackDlg.dismiss(); + this.prom.reject(e); + } + } + + onOnePkgFinish(pkgName, err, stdout, stderr) { + this.pkgsPendingCount--; + this.onPkgFinishedCB && this.onPkgFinishedCB(pkgName, err, stdout, stderr); + + this.feedbackDlg.update({ + status: ` +
+ ${this.pkgsPendingCount} + package installations in progress.

+
+
+ ${this.pkgsSuccessList.length} + packages were installed successfully. +
${this.pkgsSuccessList.join(', ')}
+
+
+ ${this.pkgsFailList.length} + packages failed to install. +
${this.pkgsFailList.join(', ')}
+
+ `, + current: this.pkgsSuccessList.length + this.pkgsFailList.length, + goal: this.packageNames.length + }) + + if (this.pkgsPendingCount <= 0) { + this.feedbackDlg.update({title:'Finished Installing Packages', buttons:[ + {text:'Dismiss', onDidClick:()=>{this.feedbackDlg.dismiss(); }} + ]}); + this.feedbackDlg.hideProgress() + + this.onAllFinishedCB && this.onAllFinishedCB(this.pkgsSuccessList, this.pkgsFailList) + if (this.pkgsFailList.length > 0) { + this.prom.reject() + } else { + setTimeout(()=>this.feedbackDlg.dismiss(), 2000) + this.prom.resolve() + } + } + } + + endWithoutInstalling() { + this.confirmDlg && this.confirmDlg.dismiss(); + this.prom.reject('user opted not to install'); + } +} + + + +// This makes it easy to follow the active/deactive state of one or more packages that you depend on. +// Params: +// packageNames : a comma separated string or array of package names. +// callback(pkgName, isActive) : callback function to handle the envent. isActive will be true if it was just activated and +// false if it was just deactivated. +export function WatchPackageStateChange(packageNames, callback) { + if (typeof packageNames == 'string') packageNames = packageNames.split(','); + const disposables = new Disposables(); + disposables.add(atom.packages.onDidActivatePackage((pkg)=>{ + if (packageNames.includes(pkg.name)) + callback(pkg.name, true); + })); + disposables.add(atom.packages.onDidDeactivatePackage((pkg)=>{ + if (packageNames.includes(pkg.name)) + callback(pkg.name, false); + })); + return disposables; +} + + + +// Install an Atom pacakge so that it is active inside Atom. It uses apm cli tool to install the package locally if needed and then +// activates it within Atom so that the packages features should become available as soon as the onInstalledCB callback is invoked. +// This function returns a promise so that it can be used with await in async function and/or the caller can pass in onInstalledCB +// and/or onFailedCB. +// It is not an error to call this function when the package is already installed or activated. +// Async Pattern: +// This is a dual mode async function. You can use the promise it returns or pass it callbacks. +// Return Value: +// : an object with information about the pkg that was installed. This is the --json output of apm +// note that old versions of apm do not support --json in which case the string output of apm is returned +export function apmInstall(pkgName, onInstalledCB, onFailedCB) { + var prom = new BGPromise() + + if (global.atom && atom.packages.isPackageActive(pkgName)) + setTimeout(()=>{ + onInstalledCB && onInstalledCB({allreadyActivated: true}) + prom.resolve({allreadyActivated:true}); + },1) + + + var cmd = `${GetApmPath()} install ${pkgName} --json`; + let apmProc = exec(cmd, (err, stdout, stderr) => { + if (err) { + onFailedCB && onFailedCB(err, stdout, stderr) + prom.reject(err, stdout, stderr); + } else { + // old apm's do not support the --json flag so ignore json parse failures + try {var packageInfo = JSON.parse(stdout)[0];} catch (err) {var packageInfo = stdout} + + if (global.atom) + atom.packages.activatePackage(pkgName).then(()=>{ + onInstalledCB && onInstalledCB(packageInfo, stderr) + prom.resolve(packageInfo, stderr); + }); + else { + onInstalledCB && onInstalledCB(packageInfo, stderr) + prom.resolve(packageInfo, stderr); + } + } + }); + return prom +} + +// If this is ran from a script, outside the atom enironment, it will return 'apm' and rely on it being in the path. +function GetApmPath() { + if (global.atom) + return atom.packages.getApmPath() + else + return 'apm' +} diff --git a/bg-promise.js b/bg-promise.js new file mode 100644 index 0000000..d4a08e2 --- /dev/null +++ b/bg-promise.js @@ -0,0 +1,109 @@ + +// This is a Promise compatible class that allows a function to support both Promise and callback patterns. I find functions written +// to the real Promise class to be hard to follow because the algorithms needs to be written inside the Promise constructor. This +// allows a bit more declarative coding style. +// The difference between this and Promise is that it has a default constructor and explict resolve() and reject() methods that can +// be called explicitly +// This opens the BGPromise up to using it like an IPC semaphore-like semantics with the await statement. +export class BGPromise { + constructor() { + this.state = BGPromise.pending; + this.onResolvedCBList = []; + this.onRejectedCBList = []; + this.firedResolveCBList = []; + this.firedRejectedCBList =[]; + this.seenResolveCBs = new Set(); + this.seenRejectCBs = new Set(); + this.p = null; + } + + resolve(...p) { + this.state = BGPromise.resolved; + this.p = p; + return this._checkForFire(); + } + + reject(...p) { + this.state = BGPromise.rejected; + this.p = p; + return this._checkForFire(); + } + + then(onResolvedCB, onRejectedCB) { + if (typeof onResolvedCB == 'BGPromise') { + var prom = onResolvedCB; + prom.onResolvedCBList.map((cb)=>{this._addCallbacks(cb,null)}) + prom.onRejectedCBList.map((cb)=>{this._addCallbacks(null,cb)}) + } else { + this._addCallbacks(onResolvedCB, onRejectedCB) + } + return this._checkForFire(); + } + + finally(onFinishedOneWayOrAnother) { + this._addCallbacks(onFinishedOneWayOrAnother, onFinishedOneWayOrAnother) + return this._checkForFire(); + } + + // The concept of resetting is new to this type of promise. This base class can be reset manually to reuse it but the reall + // use-case is the BGRepeatablePromise derived class that automatically resets after each resolve. This allows using it in a + // loop with await that 'wakes' up each time the promise is resolved. + reset() { + // if we have been resolved or rejected but noone has received those results, we do not reset because we dont want to loose + // those results. + if (this.state != BGPromise.pending && this.firedResolveCBList.length+this.firedRejectedCBList.length>0) { + this._checkForFire(); // make sure any new cb are drained into the fired* arrays + switch (this.state) { + case BGPromise.resolved: this.onResolvedCBList = this.firedResolveCBList; this.firedResolveCBList = []; break; + case BGPromise.rejected: this.onRejectedCB = this.firedRejectedCBList; this.firedRejectedCBList = []; break; + } + this.p = null; + this.state = BGPromise.pending + } + } + + _addCallbacks(onResolvedCB, onRejectedCB) { + if (onResolvedCB && ! (onResolvedCB.toString() in this.seenResolveCBs)) { + this.onResolvedCBList.push(onResolvedCB); + this.seenResolveCBs.add(onResolvedCB.toString()) + } + if (onRejectedCB && ! (onRejectedCB.toString() in this.seenRejectCBs)) { + this.onRejectedCBList.push(onRejectedCB); + this.seenRejectCBs.add(onRejectedCB.toString()) + } + } + + _checkForFire() { + switch (this.state) { + case BGPromise.resolved: if (this.onResolvedCBList.length > 0) this._doResolve(); break; + case BGPromise.rejected: if (this.onRejectedCBList.length > 0) this._doReject(); break; + } + return this + } + + _doResolve() { + for (const cb of this.onResolvedCBList) { + cb(...this.p) + }; + this.firedResolveCBList.concat(this.onResolvedCBList); this.onResolvedCBList=[]; + } + + _doReject() { + for (const cb of this.onRejectedCBList) { + cb(...this.p) + }; + this.firedRejectedCBList.concat(this.onRejectedCBList); this.onRejectedCBList=[]; + } +} + +BGPromise.pending = Symbol('pending') +BGPromise.resolved = Symbol('resolved') +BGPromise.rejected = Symbol('rejected') + +export class BGRepeatablePromise extends BGPromise { + resolve(...p) { + super.resolve(...p) + this.p = null; + this.state = BGPromise.pending; + } +} diff --git a/miscellaneous.js b/miscellaneous.js index 37a0987..a28b1d7 100644 --- a/miscellaneous.js +++ b/miscellaneous.js @@ -1,169 +1,9 @@ import assert from 'assert'; -import { CompositeDisposable } from 'atom'; -import { el, list, mount, setAttr, text } from 'redom'; -import { Component } from 'bg-atom-redom-ui'; -import { apmInstall, BGPromise } from './procCntr'; - - -// Install one or more Atom pacakges so that their features are available inside Atom. It uses apmInstall to do the work. -// Features: -// * can accept a single pkgName, a comma separated list of pkgNames, or an array of pkgNames -// * prompts the user for confirmation by default. -// * shows progress and informs the user of success or failure. -// It is not an error to call this function when the package is already installed or activated. -// Note that this initial implementation will launch one apm for each packageName all at once. If this is used to install many -// packages, we would want to use a worker queue pattern to limit the simultaneous apm runs. Also, the confirmation dialog will need -// display the long list better. -// -// Async Pattern: -// This function returns a promise so that it can be used with await in async functions and/or the caller can pass in onAllFinishedCB -// and/or onPkgFinishedCB. onAllFinishedCB is equivalent to waiting for the promise but onPkgFinishedCB gives the caller access to additional -// information. The author might want to use await to tell when all packages are ready (or catch the exception if any can not be installed) -// and also provide onPkgFinishedCB to provide feedback as pkgs finish installing. -// -// Return Value: -// : On success, there is no information returned but the promise or onAllFinishedCB callback lets you know when its done. -// -// Params: -// :string|Array : one or more package names to install. A string input can have comma separated names. -// (err) : optional callback that is called after all has been attempted. On success, err is falsey -// (pkgName, err, stdout, stderr) : optional callback that is called after each is installed or -// failed to install. -// :true|false : controls whether the user is prompted to confirm installation. -// :object : extra buttons to display in the confirmation prompt. These can offer alternatives to installing the packages -// such as configuring the system so that the packages are not needed. -// See https://flight-manual.atom.io/api/v1.45.0/NotificationManager/#instance-addInfo -export async function PackageInstall(packageNames, {onAllFinishedCB, onPkgFinishedCB, confirmPromt=true, extraButtons=[]}) { - var pkgInstaller = new PkgInstaller(packageNames, {onAllFinishedCB, onPkgFinishedCB, confirmPromt, extraButtons}) - - return pkgInstaller.prom -} - - - -class PkgInstaller { - constructor(packageNames, {onAllFinishedCB, onPkgFinishedCB, confirmPromt=true, extraButtons=[]}) { - this.packageNames = (typeof packageNames == 'string') ? packageNames.split(',') : packageNames; - this.prom = new BGPromise(); - this.onAllFinishedCB = onAllFinishedCB; - this.onPkgFinishedCB = onPkgFinishedCB; - this.extraButtons = extraButtons; - - if (confirmPromt) - this.confirmInstallation() - else - this.installPkgs() - } - - // This opens a dialog with the user giving them several choices represented in the buttons parameter. Each choice must lead to - // this.prom being resolved or rejected. Install -> this.installPkgs(), do nothing -> this.endWithoutInstalling(), extraButtons -> + this.endWithoutInstalling - confirmInstallation() { - // fixup the provided extra buttons to call this.endWithoutInstalling after they do their work - for (const button of this.extraButtons) { - let upstreamCB = button.onDidClick; - button.onDidClick = (...p)=>{upstreamCB(...p); this.endWithoutInstalling()} - } - this.confirmDlg = atom.notifications.addWarning( - `The feature you are accessing requires that the the following package(s) be installed.`, - { dismissable: true, - icon: 'cloud-download', - detail: this.packageNames.join(', '), - description: "How do you want to proceed?", - buttons: [ - {text:'Install package(s)', onDidClick:()=>{this.installPkgs(); }}, - ...this.extraButtons, - {text:'Do nothing for now', onDidClick:()=>{this.endWithoutInstalling();}} - ] - } - ); - } - - installPkgs() { - try { - this.confirmDlg && this.confirmDlg.dismiss(); - - this.feedbackDlg = new BGFeedbackDialog("Installing Packages", { - dismissable: true, - icon: 'cloud-download', - status: this.packageNames.join(', '), - }); - - // launch apm install for each pkgName, keeping track of - this.pkgsPendingCount = this.packageNames.length - this.pkgsSuccessList = []; - this.pkgsFailList = []; - - for (const pkgName of this.packageNames) { - apmInstall(pkgName, - // success callback of one apmInstall invocation - (stdout, stderr)=> { - this.pkgsSuccessList.push(pkgName); - this.onOnePkgFinish(pkgName, 0, stdout, stderr) - }, - // fail callback of one apmInstall invocation - (err, stdout, stderr)=> { - this.pkgsFailList.push(pkgName); - this.onOnePkgFinish(pkgName, err, stdout, stderr) - } - ) - } - } catch(e) { - this.feedbackDlg && this.feedbackDlg.dismiss(); - this.prom.reject(e); - } - } - - onOnePkgFinish(pkgName, err, stdout, stderr) { - this.pkgsPendingCount--; - this.onPkgFinishedCB && this.onPkgFinishedCB(pkgName, err, stdout, stderr); - - this.feedbackDlg.update({ - status: ` -
- ${this.pkgsPendingCount} - package installations in progress.

-
-
- ${this.pkgsSuccessList.length} - packages were installed successfully. -
${this.pkgsSuccessList.join(', ')}
-
-
- ${this.pkgsFailList.length} - packages failed to install. -
${this.pkgsFailList.join(', ')}
-
- `, - current: this.pkgsSuccessList.length + this.pkgsFailList.length, - goal: this.packageNames.length - }) - - if (this.pkgsPendingCount <= 0) { - this.feedbackDlg.update({title:'Finished Installing Packages', buttons:[ - {text:'Dismiss', onDidClick:()=>{this.feedbackDlg.dismiss(); }} - ]}); - this.feedbackDlg.hideProgress() - - this.onAllFinishedCB && this.onAllFinishedCB(this.pkgsSuccessList, this.pkgsFailList) - if (this.pkgsFailList.length > 0) { - this.prom.reject() - } else { - setTimeout(()=>this.feedbackDlg.dismiss(), 2000) - this.prom.resolve() - } - } - } - - endWithoutInstalling() { - this.confirmDlg && this.confirmDlg.dismiss(); - this.prom.reject('user opted not to install'); - } -} - +import { Component, Disposables } from 'bg-atom-redom-ui'; -class BGFeedbackDialog { +export class BGFeedbackDialog { constructor(title, params) { this.type = params.type || 'info'; if (!params.detail) params.detail = ' '; @@ -186,21 +26,15 @@ class BGFeedbackDialog { this.el = atom.views.getView(this.dialogBox).element; this.el.classList.add('BGFeedbackDialog'); this.title = this.el.querySelector('.message'); -console.log('this.title=',this.title); this.buttons = this.el.querySelector('.meta .btn-toolbar'); -console.log('this.buttons=',this.buttons); if (!this.buttons) { const meta = this.el.querySelector('.meta'); this.buttons = new Component('$div.btn-toolbar').el; meta.appendChild(this.buttons); this.el.classList.add('has-buttons'); } -console.log('this.buttons=',this.buttons); this.dialogDetailEl = this.el.querySelector('.detail-content'); -window.dlg = this -window.dlgEl = atom.views.getView(this.dialogBox).element -console.log({dlgEl:window.dlgEl, dialogDetailEl:this.dialogDetailEl}); Component.mount(this.dialogDetailEl, [ this.statusArea, @@ -271,24 +105,6 @@ console.log({dlgEl:window.dlgEl, dialogDetailEl:this.dialogDetailEl}); } } -// This makes it easy to follow the active/deactive state of one or more packages that you depend on. -// Params: -// packageNames : a comma separated string or array of package names. -// callback(pkgName, isActive) : callback function to handle the envent. isActive will be true if it was just activated and -// false if it was just deactivated. -export function OnDependentPackageStateChange(packageNames, callback) { - if (typeof packageNames == 'string') packageNames = packageNames.split(','); - atom.packages.onDidActivatePackage((pkg)=>{ - if (packageNames.includes(pkg.name)) - callback(pkg.name, true); - }); - atom.packages.onDidDeactivatePackage((pkg)=>{ - if (packageNames.includes(pkg.name)) - callback(pkg.name, false); - }); -} - - export function bgAsyncSleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } @@ -379,7 +195,7 @@ export function GetConfigKeys($keyContainer, $configKeyRegex) { // form2: OnDidChangeAnyConfig( [, ], ) export function OnDidChangeAnyConfig(keyContainer, configKeyRegex, callback) { var [keyContainer, configKeyRegex, callback] = ArrangeParamsByType(arguments, 'string', RegExp, 'function'); - var disposables = new CompositeDisposable(); + var disposables = new Disposables(); var keys = GetConfigKeys(keyContainer, configKeyRegex); assert(keys.length < 100, 'Registering callbacks on large sets of configuration keys (>100) is not supported'); for (const name of keys) @@ -391,7 +207,7 @@ export function OnDidChangeAnyConfig(keyContainer, configKeyRegex, callback) { // The active WorkspaceItem is used if it exists, other wise atom.workspace is used. export function DispatchCommand(cmd) { var target = atom.workspace.getActivePaneItem(); - var targetEl = target ? target.getElement() : atom.workspace.getElement(); + var targetEl = (target && target.getElement) ? target.getElement() : atom.workspace.getElement(); atom.commands.dispatch(targetEl, cmd); } diff --git a/procCntr.js b/procCntr.js deleted file mode 100644 index 62e7905..0000000 --- a/procCntr.js +++ /dev/null @@ -1,104 +0,0 @@ - -import { exec, spawn, execSync } from 'child_process'; - -// This is a Promise compatible class that allows a function to support both Promise and callback patterns. I find functions written -// to the real Promise class to be hard to follow because the algorithms needs to be written inside the Promise constructor. This -// allows a bit more declarative coding style. -// The difference between this and Promise is that it has a default constructor and explict resolve() and reject() methods. -export class BGPromise { - constructor() { - this.state = 'pending'; - this.onFulfilled = []; - this.onRejected = []; - } - resolve(...p) { - this.state = 'resolved'; - this.p = p; - return this.fire(); - } - reject(...p) { - this.state = 'rejected'; - this.p = p; - return this.fire(); - } - then(onFulfilled, onRejected) { - if (typeof onFulfilled == 'BGPromise') { - var prom = onFulfilled; - this.onFulfilled.push(prom.onFulfilled); - this.onRejected.push( prom.onRejected); - } else { - this.onFulfilled.push(onFulfilled); - this.onRejected.push( onRejected); - } - return this.fire(); - } - finally(onFinishedOneWayOrAnother) { - if (typeof onFulfilled == 'BGPromise') { - var prom = onFulfilled; - this.onFulfilled.push(prom.onFulfilled); - this.onRejected.push( prom.onRejected); - } else { - this.onFulfilled.push(onFinishedOneWayOrAnother); - this.onRejected.push( onFinishedOneWayOrAnother); - } - return this.fire(); - } - fire() { - switch (this.state) { - case 'resolved': for (const cb of this.onFulfilled) {cb(...this.p)}; break; - case 'rejected': for (const cb of this.onRejected) {cb(...this.p)}; break; - } - return this - } -} - -// Install an Atom pacakge so that it is active inside Atom. It uses apm cli tool to install the package locally if needed and then -// activates it within Atom so that the packages features should become available as soon as the onInstalledCB callback is invoked. -// This function returns a promise so that it can be used with await in async function and/or the caller can pass in onInstalledCB -// and/or onFailedCB. -// It is not an error to call this function when the package is already installed or activated. -// Async Pattern: -// This is a dual mode async function. You can use the promise it returns or pass it callbacks. -// Return Value: -// : an object with information about the pkg that was installed. This is the --json output of apm -// note that old versions of apm do not support --json in which case the string output of apm is returned -export function apmInstall(pkgName, onInstalledCB, onFailedCB) { - var prom = new BGPromise() - - if (global.atom && atom.packages.isPackageActive(pkgName)) - setTimeout(()=>{ - onInstalledCB && onInstalledCB({allreadyActivated: true}) - prom.resolve({allreadyActivated:true}); - },1) - - - var cmd = `${GetApmPath()} install ${pkgName} --json`; - let apmProc = exec(cmd, (err, stdout, stderr) => { - if (err) { - onFailedCB && onFailedCB(err, stdout, stderr) - prom.reject(err, stdout, stderr); - } else { - // old apm's do not support the --json flag so ignore json parse failures - try {var packageInfo = JSON.parse(stdout)[0];} catch (err) {var packageInfo = stdout} - - if (global.atom) - atom.packages.activatePackage(pkgName).then(()=>{ - onInstalledCB && onInstalledCB(packageInfo, stderr) - prom.resolve(packageInfo, stderr); - }); - else { - onInstalledCB && onInstalledCB(packageInfo, stderr) - prom.resolve(packageInfo, stderr); - } - } - }); - return prom -} - -// If this is ran from a script, outside the atom enironment, it will return 'apm' and rely on it being in the path. -function GetApmPath() { - if (global.atom) - return atom.packages.getApmPath() - else - return 'apm' -}