diff --git a/src/theme-manager.coffee b/src/theme-manager.coffee deleted file mode 100644 index d5a2cb0d1..000000000 --- a/src/theme-manager.coffee +++ /dev/null @@ -1,322 +0,0 @@ -path = require 'path' -_ = require 'underscore-plus' -{Emitter, CompositeDisposable} = require 'event-kit' -{File} = require 'pathwatcher' -fs = require 'fs-plus' -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}) -> - @emitter = new Emitter - @styleSheetDisposablesBySourcePath = {} - @lessCache = null - @initialLoadComplete = false - @packageManager.registerPackageActivator(this, ['theme']) - @packageManager.onDidActivateInitialPackages => - @onDidChangeActiveThemes => @packageManager.reloadActivePackageStyleSheets() - - initialize: ({@resourcePath, @configDirPath, @safeMode, devMode}) -> - @lessSourcesByRelativeFilePath = null - if devMode or typeof snapshotAuxiliaryData is 'undefined' - @lessSourcesByRelativeFilePath = {} - @importedFilePathsByRelativeImportPath = {} - else - @lessSourcesByRelativeFilePath = snapshotAuxiliaryData.lessSourcesByRelativeFilePath - @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} - onDidChangeActiveThemes: (callback) -> - @emitter.on 'did-change-active-themes', callback - - ### - Section: Accessing Available Themes - ### - - getAvailableNames: -> - # TODO: Maybe should change to list all the available themes out there? - @getLoadedNames() - - ### - Section: Accessing Loaded Themes - ### - - # Public: Returns an {Array} of {String}s of all the loaded theme names. - getLoadedThemeNames: -> - theme.name for theme in @getLoadedThemes() - - # Public: Returns an {Array} of all the loaded themes. - getLoadedThemes: -> - pack for pack in @packageManager.getLoadedPackages() when pack.isTheme() - - ### - Section: Accessing Active Themes - ### - - # Public: Returns an {Array} of {String}s all the active theme names. - getActiveThemeNames: -> - theme.name for theme in @getActiveThemes() - - # Public: Returns an {Array} of all the active themes. - getActiveThemes: -> - pack for pack in @packageManager.getActivePackages() when pack.isTheme() - - activatePackages: -> @activateThemes() - - ### - Section: Managing Enabled Themes - ### - - warnForNonExistentThemes: -> - themeNames = @config.get('core.themes') ? [] - themeNames = [themeNames] unless _.isArray(themeNames) - for themeName in themeNames - unless themeName and typeof themeName is 'string' and @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: -> - themeNames = @config.get('core.themes') ? [] - themeNames = [themeNames] unless _.isArray(themeNames) - themeNames = themeNames.filter (themeName) => - if themeName and typeof themeName is 'string' - return true if @packageManager.resolvePackagePath(themeName) - false - - # Use a built-in syntax and UI theme any time the configured themes are not - # available. - if themeNames.length < 2 - 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 is 0 - themeNames = ['atom-dark-syntax', 'atom-dark-ui'] - else if themeNames.length is 1 - if _.endsWith(themeNames[0], '-ui') - themeNames.unshift('atom-dark-syntax') - else - themeNames.push('atom-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. - 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) -> - if fullPath = @resolveStylesheet(stylesheetPath) - content = @loadStylesheet(fullPath) - @applyStylesheet(fullPath, content, priority, skipDeprecatedSelectorsTransformation) - else - throw new Error("Could not find a file at path '#{stylesheetPath}'") - - unwatchUserStylesheet: -> - @userStylesheetSubscriptions?.dispose() - @userStylesheetSubscriptions = null - @userStylesheetFile = null - @userStyleSheetDisposable?.dispose() - @userStyleSheetDisposable = null - - loadUserStylesheet: -> - @unwatchUserStylesheet() - - userStylesheetPath = @styleManager.getUserStyleSheetPath() - return unless fs.isFileSync(userStylesheetPath) - - try - @userStylesheetFile = new File(userStylesheetPath) - @userStylesheetSubscriptions = new CompositeDisposable() - reloadStylesheet = => @loadUserStylesheet() - @userStylesheetSubscriptions.add(@userStylesheetFile.onDidChange(reloadStylesheet)) - @userStylesheetSubscriptions.add(@userStylesheetFile.onDidRename(reloadStylesheet)) - @userStylesheetSubscriptions.add(@userStylesheetFile.onDidDelete(reloadStylesheet)) - catch error - 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 - """ - @notificationManager.addError(message, dismissable: true) - - try - userStylesheetContents = @loadStylesheet(userStylesheetPath, true) - catch - return - - @userStyleSheetDisposable = @styleManager.addStyleSheet(userStylesheetContents, sourcePath: userStylesheetPath, priority: 2) - - loadBaseStylesheets: -> - @reloadBaseStylesheets() - - reloadBaseStylesheets: -> - @requireStylesheet('../static/atom', -2, true) - - stylesheetElementForId: (id) -> - escapedId = id.replace(/\\/g, '\\\\') - document.head.querySelector("atom-styles style[source-path=\"#{escapedId}\"]") - - resolveStylesheet: (stylesheetPath) -> - if path.extname(stylesheetPath).length > 0 - fs.resolveOnLoadPath(stylesheetPath) - else - fs.resolveOnLoadPath(stylesheetPath, ['css', 'less']) - - loadStylesheet: (stylesheetPath, importFallbackVariables) -> - if path.extname(stylesheetPath) is '.less' - @loadLessStylesheet(stylesheetPath, importFallbackVariables) - else - fs.readFileSync(stylesheetPath, 'utf8') - - loadLessStylesheet: (lessStylesheetPath, importFallbackVariables=false) -> - @lessCache ?= new LessCompileCache({ - @resourcePath, - @lessSourcesByRelativeFilePath, - @importedFilePathsByRelativeImportPath, - importPaths: @getImportPaths() - }) - - try - if importFallbackVariables - baseVarImports = """ - @import "variables/ui-variables"; - @import "variables/syntax-variables"; - """ - relativeFilePath = path.relative(@resourcePath, lessStylesheetPath) - lessSource = @lessSourcesByRelativeFilePath[relativeFilePath] - if lessSource? - content = lessSource.content - digest = lessSource.digest - else - content = baseVarImports + '\n' + fs.readFileSync(lessStylesheetPath, 'utf8') - digest = null - - @lessCache.cssForFile(lessStylesheetPath, content, digest) - else - @lessCache.read(lessStylesheetPath) - catch error - error.less = true - if error.line? - # Adjust line numbers for import fallbacks - error.line -= 2 if importFallbackVariables - - message = "Error compiling Less stylesheet: `#{lessStylesheetPath}`" - detail = """ - Line number: #{error.line} - #{error.message} - """ - else - message = "Error loading Less stylesheet: `#{lessStylesheetPath}`" - detail = error.message - - @notificationManager.addError(message, {detail, dismissable: true}) - throw error - - removeStylesheet: (stylesheetPath) -> - @styleSheetDisposablesBySourcePath[stylesheetPath]?.dispose() - - applyStylesheet: (path, text, priority, skipDeprecatedSelectorsTransformation) -> - @styleSheetDisposablesBySourcePath[path] = @styleManager.addStyleSheet( - text, - { - priority, - skipDeprecatedSelectorsTransformation, - sourcePath: path - } - ) - - activateThemes: -> - new Promise (resolve) => - # @config.observe runs the callback once, then on subsequent changes. - @config.observe 'core.themes', => - @deactivateThemes().then => - @warnForNonExistentThemes() - @refreshLessCache() # Update cache for packages in core.themes config - - promises = [] - for themeName in @getEnabledThemeNames() - if @packageManager.resolvePackagePath(themeName) - promises.push(@packageManager.activatePackage(themeName)) - else - console.warn("Failed to activate theme '#{themeName}' because it isn't installed.") - - Promise.all(promises).then => - @addActiveThemeClasses() - @refreshLessCache() # Update cache again now that @getActiveThemes() is populated - @loadUserStylesheet() - @reloadBaseStylesheets() - @initialLoadComplete = true - @emitter.emit 'did-change-active-themes' - resolve() - - deactivateThemes: -> - @removeActiveThemeClasses() - @unwatchUserStylesheet() - results = @getActiveThemes().map((pack) => @packageManager.deactivatePackage(pack.name)) - Promise.all(results.filter((r) -> typeof r?.then is 'function')) - - isInitialLoadComplete: -> @initialLoadComplete - - addActiveThemeClasses: -> - if workspaceElement = @viewRegistry.getView(@workspace) - for pack in @getActiveThemes() - workspaceElement.classList.add("theme-#{pack.name}") - return - - removeActiveThemeClasses: -> - workspaceElement = @viewRegistry.getView(@workspace) - for pack in @getActiveThemes() - workspaceElement.classList.remove("theme-#{pack.name}") - return - - refreshLessCache: -> - @lessCache?.setImportPaths(@getImportPaths()) - - getImportPaths: -> - activeThemes = @getActiveThemes() - if activeThemes.length > 0 - themePaths = (theme.getStylesheetsPath() for theme in activeThemes when theme) - else - themePaths = [] - for themeName in @getEnabledThemeNames() - if themePath = @packageManager.resolvePackagePath(themeName) - deprecatedPath = path.join(themePath, 'stylesheets') - if fs.isDirectorySync(deprecatedPath) - themePaths.push(deprecatedPath) - else - themePaths.push(path.join(themePath, 'styles')) - - themePaths.filter (themePath) -> fs.isDirectorySync(themePath) diff --git a/src/theme-manager.js b/src/theme-manager.js new file mode 100644 index 000000000..6abf0fc74 --- /dev/null +++ b/src/theme-manager.js @@ -0,0 +1,401 @@ +/* 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} + 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 (!_.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 (!_.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 = ['atom-dark-syntax', 'atom-dark-ui'] + } else if (themeNames.length === 1) { + if (_.endsWith(themeNames[0], '-ui')) { + themeNames.unshift('atom-dark-syntax') + } else { + themeNames.push('atom-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)) + } +}