/ src / theme-manager.js
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  };