theme-manager.js
1 /* global snapshotAuxiliaryData */ 2 3 const path = require('path'); 4 const _ = require('underscore-plus'); 5 const { Emitter, CompositeDisposable } = require('event-kit'); 6 const { File } = require('pathwatcher'); 7 const fs = require('fs-plus'); 8 const LessCompileCache = require('./less-compile-cache'); 9 10 // Extended: Handles loading and activating available themes. 11 // 12 // An instance of this class is always available as the `atom.themes` global. 13 module.exports = class ThemeManager { 14 constructor({ 15 packageManager, 16 config, 17 styleManager, 18 notificationManager, 19 viewRegistry 20 }) { 21 this.packageManager = packageManager; 22 this.config = config; 23 this.styleManager = styleManager; 24 this.notificationManager = notificationManager; 25 this.viewRegistry = viewRegistry; 26 this.emitter = new Emitter(); 27 this.styleSheetDisposablesBySourcePath = {}; 28 this.lessCache = null; 29 this.initialLoadComplete = false; 30 this.packageManager.registerPackageActivator(this, ['theme']); 31 this.packageManager.onDidActivateInitialPackages(() => { 32 this.onDidChangeActiveThemes(() => 33 this.packageManager.reloadActivePackageStyleSheets() 34 ); 35 }); 36 } 37 38 initialize({ resourcePath, configDirPath, safeMode, devMode }) { 39 this.resourcePath = resourcePath; 40 this.configDirPath = configDirPath; 41 this.safeMode = safeMode; 42 this.lessSourcesByRelativeFilePath = null; 43 if (devMode || typeof snapshotAuxiliaryData === 'undefined') { 44 this.lessSourcesByRelativeFilePath = {}; 45 this.importedFilePathsByRelativeImportPath = {}; 46 } else { 47 this.lessSourcesByRelativeFilePath = 48 snapshotAuxiliaryData.lessSourcesByRelativeFilePath; 49 this.importedFilePathsByRelativeImportPath = 50 snapshotAuxiliaryData.importedFilePathsByRelativeImportPath; 51 } 52 } 53 54 /* 55 Section: Event Subscription 56 */ 57 58 // Essential: Invoke `callback` when style sheet changes associated with 59 // updating the list of active themes have completed. 60 // 61 // * `callback` {Function} 62 // 63 // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. 64 onDidChangeActiveThemes(callback) { 65 return this.emitter.on('did-change-active-themes', callback); 66 } 67 68 /* 69 Section: Accessing Available Themes 70 */ 71 72 getAvailableNames() { 73 // TODO: Maybe should change to list all the available themes out there? 74 return this.getLoadedNames(); 75 } 76 77 /* 78 Section: Accessing Loaded Themes 79 */ 80 81 // Public: Returns an {Array} of {String}s of all the loaded theme names. 82 getLoadedThemeNames() { 83 return this.getLoadedThemes().map(theme => theme.name); 84 } 85 86 // Public: Returns an {Array} of all the loaded themes. 87 getLoadedThemes() { 88 return this.packageManager 89 .getLoadedPackages() 90 .filter(pack => pack.isTheme()); 91 } 92 93 /* 94 Section: Accessing Active Themes 95 */ 96 97 // Public: Returns an {Array} of {String}s of all the active theme names. 98 getActiveThemeNames() { 99 return this.getActiveThemes().map(theme => theme.name); 100 } 101 102 // Public: Returns an {Array} of all the active themes. 103 getActiveThemes() { 104 return this.packageManager 105 .getActivePackages() 106 .filter(pack => pack.isTheme()); 107 } 108 109 activatePackages() { 110 return this.activateThemes(); 111 } 112 113 /* 114 Section: Managing Enabled Themes 115 */ 116 117 warnForNonExistentThemes() { 118 let themeNames = this.config.get('core.themes') || []; 119 if (!Array.isArray(themeNames)) { 120 themeNames = [themeNames]; 121 } 122 for (let themeName of themeNames) { 123 if ( 124 !themeName || 125 typeof themeName !== 'string' || 126 !this.packageManager.resolvePackagePath(themeName) 127 ) { 128 console.warn(`Enabled theme '${themeName}' is not installed.`); 129 } 130 } 131 } 132 133 // Public: Get the enabled theme names from the config. 134 // 135 // Returns an array of theme names in the order that they should be activated. 136 getEnabledThemeNames() { 137 let themeNames = this.config.get('core.themes') || []; 138 if (!Array.isArray(themeNames)) { 139 themeNames = [themeNames]; 140 } 141 themeNames = themeNames.filter( 142 themeName => 143 typeof themeName === 'string' && 144 this.packageManager.resolvePackagePath(themeName) 145 ); 146 147 // Use a built-in syntax and UI theme any time the configured themes are not 148 // available. 149 if (themeNames.length < 2) { 150 const builtInThemeNames = [ 151 'atom-dark-syntax', 152 'atom-dark-ui', 153 'atom-light-syntax', 154 'atom-light-ui', 155 'base16-tomorrow-dark-theme', 156 'base16-tomorrow-light-theme', 157 'solarized-dark-syntax', 158 'solarized-light-syntax' 159 ]; 160 themeNames = _.intersection(themeNames, builtInThemeNames); 161 if (themeNames.length === 0) { 162 themeNames = ['one-dark-syntax', 'one-dark-ui']; 163 } else if (themeNames.length === 1) { 164 if (themeNames[0].endsWith('-ui')) { 165 themeNames.unshift('one-dark-syntax'); 166 } else { 167 themeNames.push('one-dark-ui'); 168 } 169 } 170 } 171 172 // Reverse so the first (top) theme is loaded after the others. We want 173 // the first/top theme to override later themes in the stack. 174 return themeNames.reverse(); 175 } 176 177 /* 178 Section: Private 179 */ 180 181 // Resolve and apply the stylesheet specified by the path. 182 // 183 // This supports both CSS and Less stylesheets. 184 // 185 // * `stylesheetPath` A {String} path to the stylesheet that can be an absolute 186 // path or a relative path that will be resolved against the load path. 187 // 188 // Returns a {Disposable} on which `.dispose()` can be called to remove the 189 // required stylesheet. 190 requireStylesheet( 191 stylesheetPath, 192 priority, 193 skipDeprecatedSelectorsTransformation 194 ) { 195 let fullPath = this.resolveStylesheet(stylesheetPath); 196 if (fullPath) { 197 const content = this.loadStylesheet(fullPath); 198 return this.applyStylesheet( 199 fullPath, 200 content, 201 priority, 202 skipDeprecatedSelectorsTransformation 203 ); 204 } else { 205 throw new Error(`Could not find a file at path '${stylesheetPath}'`); 206 } 207 } 208 209 unwatchUserStylesheet() { 210 if (this.userStylesheetSubscriptions != null) 211 this.userStylesheetSubscriptions.dispose(); 212 this.userStylesheetSubscriptions = null; 213 this.userStylesheetFile = null; 214 if (this.userStyleSheetDisposable != null) 215 this.userStyleSheetDisposable.dispose(); 216 this.userStyleSheetDisposable = null; 217 } 218 219 loadUserStylesheet() { 220 this.unwatchUserStylesheet(); 221 222 const userStylesheetPath = this.styleManager.getUserStyleSheetPath(); 223 if (!fs.isFileSync(userStylesheetPath)) { 224 return; 225 } 226 227 try { 228 this.userStylesheetFile = new File(userStylesheetPath); 229 this.userStylesheetSubscriptions = new CompositeDisposable(); 230 const reloadStylesheet = () => this.loadUserStylesheet(); 231 this.userStylesheetSubscriptions.add( 232 this.userStylesheetFile.onDidChange(reloadStylesheet) 233 ); 234 this.userStylesheetSubscriptions.add( 235 this.userStylesheetFile.onDidRename(reloadStylesheet) 236 ); 237 this.userStylesheetSubscriptions.add( 238 this.userStylesheetFile.onDidDelete(reloadStylesheet) 239 ); 240 } catch (error) { 241 const message = `\ 242 Unable to watch path: \`${path.basename(userStylesheetPath)}\`. Make sure 243 you have permissions to \`${userStylesheetPath}\`. 244 245 On linux there are currently problems with watch sizes. See 246 [this document][watches] for more info. 247 [watches]:https://github.com/atom/atom/blob/master/docs/build-instructions/linux.md#typeerror-unable-to-watch-path\ 248 `; 249 this.notificationManager.addError(message, { dismissable: true }); 250 } 251 252 let userStylesheetContents; 253 try { 254 userStylesheetContents = this.loadStylesheet(userStylesheetPath, true); 255 } catch (error) { 256 return; 257 } 258 259 this.userStyleSheetDisposable = this.styleManager.addStyleSheet( 260 userStylesheetContents, 261 { sourcePath: userStylesheetPath, priority: 2 } 262 ); 263 } 264 265 loadBaseStylesheets() { 266 this.reloadBaseStylesheets(); 267 } 268 269 reloadBaseStylesheets() { 270 this.requireStylesheet('../static/atom', -2, true); 271 } 272 273 stylesheetElementForId(id) { 274 const escapedId = id.replace(/\\/g, '\\\\'); 275 return document.head.querySelector( 276 `atom-styles style[source-path="${escapedId}"]` 277 ); 278 } 279 280 resolveStylesheet(stylesheetPath) { 281 if (path.extname(stylesheetPath).length > 0) { 282 return fs.resolveOnLoadPath(stylesheetPath); 283 } else { 284 return fs.resolveOnLoadPath(stylesheetPath, ['css', 'less']); 285 } 286 } 287 288 loadStylesheet(stylesheetPath, importFallbackVariables) { 289 if (path.extname(stylesheetPath) === '.less') { 290 return this.loadLessStylesheet(stylesheetPath, importFallbackVariables); 291 } else { 292 return fs.readFileSync(stylesheetPath, 'utf8'); 293 } 294 } 295 296 loadLessStylesheet(lessStylesheetPath, importFallbackVariables = false) { 297 if (this.lessCache == null) { 298 this.lessCache = new LessCompileCache({ 299 resourcePath: this.resourcePath, 300 lessSourcesByRelativeFilePath: this.lessSourcesByRelativeFilePath, 301 importedFilePathsByRelativeImportPath: this 302 .importedFilePathsByRelativeImportPath, 303 importPaths: this.getImportPaths() 304 }); 305 } 306 307 try { 308 if (importFallbackVariables) { 309 const baseVarImports = `\ 310 @import "variables/ui-variables"; 311 @import "variables/syntax-variables";\ 312 `; 313 const relativeFilePath = path.relative( 314 this.resourcePath, 315 lessStylesheetPath 316 ); 317 const lessSource = this.lessSourcesByRelativeFilePath[relativeFilePath]; 318 319 let content, digest; 320 if (lessSource != null) { 321 ({ content } = lessSource); 322 ({ digest } = lessSource); 323 } else { 324 content = 325 baseVarImports + '\n' + fs.readFileSync(lessStylesheetPath, 'utf8'); 326 digest = null; 327 } 328 329 return this.lessCache.cssForFile(lessStylesheetPath, content, digest); 330 } else { 331 return this.lessCache.read(lessStylesheetPath); 332 } 333 } catch (error) { 334 let detail, message; 335 error.less = true; 336 if (error.line != null) { 337 // Adjust line numbers for import fallbacks 338 if (importFallbackVariables) { 339 error.line -= 2; 340 } 341 342 message = `Error compiling Less stylesheet: \`${lessStylesheetPath}\``; 343 detail = `Line number: ${error.line}\n${error.message}`; 344 } else { 345 message = `Error loading Less stylesheet: \`${lessStylesheetPath}\``; 346 detail = error.message; 347 } 348 349 this.notificationManager.addError(message, { detail, dismissable: true }); 350 throw error; 351 } 352 } 353 354 removeStylesheet(stylesheetPath) { 355 if (this.styleSheetDisposablesBySourcePath[stylesheetPath] != null) { 356 this.styleSheetDisposablesBySourcePath[stylesheetPath].dispose(); 357 } 358 } 359 360 applyStylesheet(path, text, priority, skipDeprecatedSelectorsTransformation) { 361 this.styleSheetDisposablesBySourcePath[ 362 path 363 ] = this.styleManager.addStyleSheet(text, { 364 priority, 365 skipDeprecatedSelectorsTransformation, 366 sourcePath: path 367 }); 368 369 return this.styleSheetDisposablesBySourcePath[path]; 370 } 371 372 activateThemes() { 373 return new Promise(resolve => { 374 // @config.observe runs the callback once, then on subsequent changes. 375 this.config.observe('core.themes', () => { 376 this.deactivateThemes().then(() => { 377 this.warnForNonExistentThemes(); 378 this.refreshLessCache(); // Update cache for packages in core.themes config 379 380 const promises = []; 381 for (const themeName of this.getEnabledThemeNames()) { 382 if (this.packageManager.resolvePackagePath(themeName)) { 383 promises.push(this.packageManager.activatePackage(themeName)); 384 } else { 385 console.warn( 386 `Failed to activate theme '${themeName}' because it isn't installed.` 387 ); 388 } 389 } 390 391 return Promise.all(promises).then(() => { 392 this.addActiveThemeClasses(); 393 this.refreshLessCache(); // Update cache again now that @getActiveThemes() is populated 394 this.loadUserStylesheet(); 395 this.reloadBaseStylesheets(); 396 this.initialLoadComplete = true; 397 this.emitter.emit('did-change-active-themes'); 398 resolve(); 399 }); 400 }); 401 }); 402 }); 403 } 404 405 deactivateThemes() { 406 this.removeActiveThemeClasses(); 407 this.unwatchUserStylesheet(); 408 const results = this.getActiveThemes().map(pack => 409 this.packageManager.deactivatePackage(pack.name) 410 ); 411 return Promise.all( 412 results.filter(r => r != null && typeof r.then === 'function') 413 ); 414 } 415 416 isInitialLoadComplete() { 417 return this.initialLoadComplete; 418 } 419 420 addActiveThemeClasses() { 421 const workspaceElement = this.viewRegistry.getView(this.workspace); 422 if (workspaceElement) { 423 for (const pack of this.getActiveThemes()) { 424 workspaceElement.classList.add(`theme-${pack.name}`); 425 } 426 } 427 } 428 429 removeActiveThemeClasses() { 430 const workspaceElement = this.viewRegistry.getView(this.workspace); 431 for (const pack of this.getActiveThemes()) { 432 workspaceElement.classList.remove(`theme-${pack.name}`); 433 } 434 } 435 436 refreshLessCache() { 437 if (this.lessCache) this.lessCache.setImportPaths(this.getImportPaths()); 438 } 439 440 getImportPaths() { 441 let themePaths; 442 const activeThemes = this.getActiveThemes(); 443 if (activeThemes.length > 0) { 444 themePaths = activeThemes 445 .filter(theme => theme) 446 .map(theme => theme.getStylesheetsPath()); 447 } else { 448 themePaths = []; 449 for (const themeName of this.getEnabledThemeNames()) { 450 const themePath = this.packageManager.resolvePackagePath(themeName); 451 if (themePath) { 452 const deprecatedPath = path.join(themePath, 'stylesheets'); 453 if (fs.isDirectorySync(deprecatedPath)) { 454 themePaths.push(deprecatedPath); 455 } else { 456 themePaths.push(path.join(themePath, 'styles')); 457 } 458 } 459 } 460 } 461 462 return themePaths.filter(themePath => fs.isDirectorySync(themePath)); 463 } 464 };