/* global snapshotAuxiliaryData */ const path = require('path'); const _ = require('underscore-plus'); const { Emitter, CompositeDisposable } = require('event-kit'); const { File } = require('pathwatcher'); const fs = require('fs-plus'); const LessCompileCache = require('./less-compile-cache'); // Extended: Handles loading and activating available themes. // // An instance of this class is always available as the `atom.themes` global. module.exports = class ThemeManager { constructor({ packageManager, config, styleManager, notificationManager, viewRegistry }) { this.packageManager = packageManager; this.config = config; this.styleManager = styleManager; this.notificationManager = notificationManager; this.viewRegistry = viewRegistry; this.emitter = new Emitter(); this.styleSheetDisposablesBySourcePath = {}; this.lessCache = null; this.initialLoadComplete = false; this.packageManager.registerPackageActivator(this, ['theme']); this.packageManager.onDidActivateInitialPackages(() => { this.onDidChangeActiveThemes(() => this.packageManager.reloadActivePackageStyleSheets() ); }); } initialize({ resourcePath, configDirPath, safeMode, devMode }) { this.resourcePath = resourcePath; this.configDirPath = configDirPath; this.safeMode = safeMode; this.lessSourcesByRelativeFilePath = null; if (devMode || typeof snapshotAuxiliaryData === 'undefined') { this.lessSourcesByRelativeFilePath = {}; this.importedFilePathsByRelativeImportPath = {}; } else { this.lessSourcesByRelativeFilePath = snapshotAuxiliaryData.lessSourcesByRelativeFilePath; this.importedFilePathsByRelativeImportPath = snapshotAuxiliaryData.importedFilePathsByRelativeImportPath; } } /* Section: Event Subscription */ // Essential: Invoke `callback` when style sheet changes associated with // updating the list of active themes have completed. // // * `callback` {Function} // // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. onDidChangeActiveThemes(callback) { return this.emitter.on('did-change-active-themes', callback); } /* Section: Accessing Available Themes */ getAvailableNames() { // TODO: Maybe should change to list all the available themes out there? return this.getLoadedNames(); } /* Section: Accessing Loaded Themes */ // Public: Returns an {Array} of {String}s of all the loaded theme names. getLoadedThemeNames() { return this.getLoadedThemes().map(theme => theme.name); } // Public: Returns an {Array} of all the loaded themes. getLoadedThemes() { return this.packageManager .getLoadedPackages() .filter(pack => pack.isTheme()); } /* Section: Accessing Active Themes */ // Public: Returns an {Array} of {String}s of all the active theme names. getActiveThemeNames() { return this.getActiveThemes().map(theme => theme.name); } // Public: Returns an {Array} of all the active themes. getActiveThemes() { return this.packageManager .getActivePackages() .filter(pack => pack.isTheme()); } activatePackages() { return this.activateThemes(); } /* Section: Managing Enabled Themes */ warnForNonExistentThemes() { let themeNames = this.config.get('core.themes') || []; if (!Array.isArray(themeNames)) { themeNames = [themeNames]; } for (let themeName of themeNames) { if ( !themeName || typeof themeName !== 'string' || !this.packageManager.resolvePackagePath(themeName) ) { console.warn(`Enabled theme '${themeName}' is not installed.`); } } } // Public: Get the enabled theme names from the config. // // Returns an array of theme names in the order that they should be activated. getEnabledThemeNames() { let themeNames = this.config.get('core.themes') || []; if (!Array.isArray(themeNames)) { themeNames = [themeNames]; } themeNames = themeNames.filter( themeName => typeof themeName === 'string' && this.packageManager.resolvePackagePath(themeName) ); // Use a built-in syntax and UI theme any time the configured themes are not // available. if (themeNames.length < 2) { const builtInThemeNames = [ 'atom-dark-syntax', 'atom-dark-ui', 'atom-light-syntax', 'atom-light-ui', 'base16-tomorrow-dark-theme', 'base16-tomorrow-light-theme', 'solarized-dark-syntax', 'solarized-light-syntax' ]; themeNames = _.intersection(themeNames, builtInThemeNames); if (themeNames.length === 0) { themeNames = ['one-dark-syntax', 'one-dark-ui']; } else if (themeNames.length === 1) { if (themeNames[0].endsWith('-ui')) { themeNames.unshift('one-dark-syntax'); } else { themeNames.push('one-dark-ui'); } } } // Reverse so the first (top) theme is loaded after the others. We want // the first/top theme to override later themes in the stack. return themeNames.reverse(); } /* Section: Private */ // Resolve and apply the stylesheet specified by the path. // // This supports both CSS and Less stylesheets. // // * `stylesheetPath` A {String} path to the stylesheet that can be an absolute // path or a relative path that will be resolved against the load path. // // Returns a {Disposable} on which `.dispose()` can be called to remove the // required stylesheet. requireStylesheet( stylesheetPath, priority, skipDeprecatedSelectorsTransformation ) { let fullPath = this.resolveStylesheet(stylesheetPath); if (fullPath) { const content = this.loadStylesheet(fullPath); return this.applyStylesheet( fullPath, content, priority, skipDeprecatedSelectorsTransformation ); } else { throw new Error(`Could not find a file at path '${stylesheetPath}'`); } } unwatchUserStylesheet() { if (this.userStylesheetSubscriptions != null) this.userStylesheetSubscriptions.dispose(); this.userStylesheetSubscriptions = null; this.userStylesheetFile = null; if (this.userStyleSheetDisposable != null) this.userStyleSheetDisposable.dispose(); this.userStyleSheetDisposable = null; } loadUserStylesheet() { this.unwatchUserStylesheet(); const userStylesheetPath = this.styleManager.getUserStyleSheetPath(); if (!fs.isFileSync(userStylesheetPath)) { return; } try { this.userStylesheetFile = new File(userStylesheetPath); this.userStylesheetSubscriptions = new CompositeDisposable(); const reloadStylesheet = () => this.loadUserStylesheet(); this.userStylesheetSubscriptions.add( this.userStylesheetFile.onDidChange(reloadStylesheet) ); this.userStylesheetSubscriptions.add( this.userStylesheetFile.onDidRename(reloadStylesheet) ); this.userStylesheetSubscriptions.add( this.userStylesheetFile.onDidDelete(reloadStylesheet) ); } catch (error) { const message = `\ Unable to watch path: \`${path.basename(userStylesheetPath)}\`. Make sure you have permissions to \`${userStylesheetPath}\`. On linux there are currently problems with watch sizes. See [this document][watches] for more info. [watches]:https://github.com/atom/atom/blob/master/docs/build-instructions/linux.md#typeerror-unable-to-watch-path\ `; this.notificationManager.addError(message, { dismissable: true }); } let userStylesheetContents; try { userStylesheetContents = this.loadStylesheet(userStylesheetPath, true); } catch (error) { return; } this.userStyleSheetDisposable = this.styleManager.addStyleSheet( userStylesheetContents, { sourcePath: userStylesheetPath, priority: 2 } ); } loadBaseStylesheets() { this.reloadBaseStylesheets(); } reloadBaseStylesheets() { this.requireStylesheet('../static/atom', -2, true); } stylesheetElementForId(id) { const escapedId = id.replace(/\\/g, '\\\\'); return document.head.querySelector( `atom-styles style[source-path="${escapedId}"]` ); } resolveStylesheet(stylesheetPath) { if (path.extname(stylesheetPath).length > 0) { return fs.resolveOnLoadPath(stylesheetPath); } else { return fs.resolveOnLoadPath(stylesheetPath, ['css', 'less']); } } loadStylesheet(stylesheetPath, importFallbackVariables) { if (path.extname(stylesheetPath) === '.less') { return this.loadLessStylesheet(stylesheetPath, importFallbackVariables); } else { return fs.readFileSync(stylesheetPath, 'utf8'); } } loadLessStylesheet(lessStylesheetPath, importFallbackVariables = false) { if (this.lessCache == null) { this.lessCache = new LessCompileCache({ resourcePath: this.resourcePath, lessSourcesByRelativeFilePath: this.lessSourcesByRelativeFilePath, importedFilePathsByRelativeImportPath: this .importedFilePathsByRelativeImportPath, importPaths: this.getImportPaths() }); } try { if (importFallbackVariables) { const baseVarImports = `\ @import "variables/ui-variables"; @import "variables/syntax-variables";\ `; const relativeFilePath = path.relative( this.resourcePath, lessStylesheetPath ); const lessSource = this.lessSourcesByRelativeFilePath[relativeFilePath]; let content, digest; if (lessSource != null) { ({ content } = lessSource); ({ digest } = lessSource); } else { content = baseVarImports + '\n' + fs.readFileSync(lessStylesheetPath, 'utf8'); digest = null; } return this.lessCache.cssForFile(lessStylesheetPath, content, digest); } else { return this.lessCache.read(lessStylesheetPath); } } catch (error) { let detail, message; error.less = true; if (error.line != null) { // Adjust line numbers for import fallbacks if (importFallbackVariables) { error.line -= 2; } message = `Error compiling Less stylesheet: \`${lessStylesheetPath}\``; detail = `Line number: ${error.line}\n${error.message}`; } else { message = `Error loading Less stylesheet: \`${lessStylesheetPath}\``; detail = error.message; } this.notificationManager.addError(message, { detail, dismissable: true }); throw error; } } removeStylesheet(stylesheetPath) { if (this.styleSheetDisposablesBySourcePath[stylesheetPath] != null) { this.styleSheetDisposablesBySourcePath[stylesheetPath].dispose(); } } applyStylesheet(path, text, priority, skipDeprecatedSelectorsTransformation) { this.styleSheetDisposablesBySourcePath[ path ] = this.styleManager.addStyleSheet(text, { priority, skipDeprecatedSelectorsTransformation, sourcePath: path }); return this.styleSheetDisposablesBySourcePath[path]; } activateThemes() { return new Promise(resolve => { // @config.observe runs the callback once, then on subsequent changes. this.config.observe('core.themes', () => { this.deactivateThemes().then(() => { this.warnForNonExistentThemes(); this.refreshLessCache(); // Update cache for packages in core.themes config const promises = []; for (const themeName of this.getEnabledThemeNames()) { if (this.packageManager.resolvePackagePath(themeName)) { promises.push(this.packageManager.activatePackage(themeName)); } else { console.warn( `Failed to activate theme '${themeName}' because it isn't installed.` ); } } return Promise.all(promises).then(() => { this.addActiveThemeClasses(); this.refreshLessCache(); // Update cache again now that @getActiveThemes() is populated this.loadUserStylesheet(); this.reloadBaseStylesheets(); this.initialLoadComplete = true; this.emitter.emit('did-change-active-themes'); resolve(); }); }); }); }); } deactivateThemes() { this.removeActiveThemeClasses(); this.unwatchUserStylesheet(); const results = this.getActiveThemes().map(pack => this.packageManager.deactivatePackage(pack.name) ); return Promise.all( results.filter(r => r != null && typeof r.then === 'function') ); } isInitialLoadComplete() { return this.initialLoadComplete; } addActiveThemeClasses() { const workspaceElement = this.viewRegistry.getView(this.workspace); if (workspaceElement) { for (const pack of this.getActiveThemes()) { workspaceElement.classList.add(`theme-${pack.name}`); } } } removeActiveThemeClasses() { const workspaceElement = this.viewRegistry.getView(this.workspace); for (const pack of this.getActiveThemes()) { workspaceElement.classList.remove(`theme-${pack.name}`); } } refreshLessCache() { if (this.lessCache) this.lessCache.setImportPaths(this.getImportPaths()); } getImportPaths() { let themePaths; const activeThemes = this.getActiveThemes(); if (activeThemes.length > 0) { themePaths = activeThemes .filter(theme => theme) .map(theme => theme.getStylesheetsPath()); } else { themePaths = []; for (const themeName of this.getEnabledThemeNames()) { const themePath = this.packageManager.resolvePackagePath(themeName); if (themePath) { const deprecatedPath = path.join(themePath, 'stylesheets'); if (fs.isDirectorySync(deprecatedPath)) { themePaths.push(deprecatedPath); } else { themePaths.push(path.join(themePath, 'styles')); } } } } return themePaths.filter(themePath => fs.isDirectorySync(themePath)); } };