diff --git a/menus/darwin.cson b/menus/darwin.cson index b967220c0..f16bfa981 100644 --- a/menus/darwin.cson +++ b/menus/darwin.cson @@ -36,6 +36,13 @@ { label: 'New File', command: 'application:new-file' } { label: 'Open…', command: 'application:open' } { label: 'Add Project Folder…', command: 'application:add-project-folder' } + { + label: 'Reopen Project', + submenu: [ + { label: 'Clear Project History', command: 'application:clear-project-history' } + { type: 'separator' } + ] + } { label: 'Reopen Last Item', command: 'pane:reopen-closed-item' } { type: 'separator' } { label: 'Save', command: 'core:save' } diff --git a/menus/linux.cson b/menus/linux.cson index 3ec2780e1..c900d3d29 100644 --- a/menus/linux.cson +++ b/menus/linux.cson @@ -7,6 +7,13 @@ { label: '&Open File…', command: 'application:open-file' } { label: 'Open Folder…', command: 'application:open-folder' } { label: 'Add Project Folder…', command: 'application:add-project-folder' } + { + label: 'Reopen Project', + submenu: [ + { label: 'Clear Project History', command: 'application:clear-project-history' } + { type: 'separator' } + ] + } { label: 'Reopen Last &Item', command: 'pane:reopen-closed-item' } { type: 'separator' } { label: '&Save', command: 'core:save' } diff --git a/menus/win32.cson b/menus/win32.cson index d6b707009..7897709b7 100644 --- a/menus/win32.cson +++ b/menus/win32.cson @@ -7,6 +7,13 @@ { label: '&Open File…', command: 'application:open-file' } { label: 'Open Folder…', command: 'application:open-folder' } { label: 'Add Project Folder…', command: 'application:add-project-folder' } + { + label: 'Reopen Project', + submenu: [ + { label: 'Clear Project History', command: 'application:clear-project-history' } + { type: 'separator' } + ] + } { label: 'Reopen Last &Item', command: 'pane:reopen-closed-item' } { type: 'separator' } { label: 'Se&ttings', command: 'application:show-settings' } diff --git a/spec/history-manager-spec.js b/spec/history-manager-spec.js new file mode 100644 index 000000000..425f1efe0 --- /dev/null +++ b/spec/history-manager-spec.js @@ -0,0 +1,199 @@ +/** @babel */ + +import {it, fit, ffit, fffit, beforeEach, afterEach} from './async-spec-helpers' +import {Emitter, Disposable, CompositeDisposable} from 'event-kit' + +import {HistoryManager, HistoryProject} from '../src/history-manager' + +describe("HistoryManager", () => { + let historyManager, commandRegistry, project, localStorage, stateStore + let commandDisposable, projectDisposable + + beforeEach(() => { + commandDisposable = jasmine.createSpyObj('Disposable', ['dispose']) + commandRegistry = jasmine.createSpyObj('CommandRegistry', ['add']) + commandRegistry.add.andReturn(commandDisposable) + + localStorage = jasmine.createSpyObj('LocalStorage', ['getItem', 'setItem']) + localStorage.items = { + history: JSON.stringify({ + projects: [ + { paths: ['/1', 'c:\\2'], lastOpened: new Date(2016, 9, 17, 17, 16, 23) }, + { paths: ['/test'], lastOpened: new Date(2016, 9, 17, 11, 12, 13) } + ] + }) + } + localStorage.getItem.andCallFake((key) => localStorage.items[key]) + localStorage.setItem.andCallFake((key, value) => localStorage.items[key] = value) + + projectDisposable = jasmine.createSpyObj('Disposable', ['dispose']) + project = jasmine.createSpyObj('Project', ['onDidChangePaths']) + project.onDidChangePaths.andCallFake((f) => { + project.didChangePathsListener = f + return projectDisposable + }) + + historyManager = new HistoryManager({project, commands:commandRegistry, localStorage}) + }) + + describe("constructor", () => { + it("registers the 'clear-project-history' command function", () => { + expect(commandRegistry.add).toHaveBeenCalled() + const cmdCall = commandRegistry.add.calls[0] + expect(cmdCall.args.length).toBe(2) + expect(cmdCall.args[0]).toBe('atom-workspace') + expect(typeof cmdCall.args[1]['application:clear-project-history']).toBe('function') + }) + + describe("getProjects", () => { + it("returns an array of HistoryProjects", () => { + expect(historyManager.getProjects()).toEqual([ + new HistoryProject(['/1', 'c:\\2'], new Date(2016, 9, 17, 17, 16, 23)), + new HistoryProject(['/test'], new Date(2016, 9, 17, 11, 12, 13)) + ]) + }) + + it("returns an array of HistoryProjects that is not mutable state", () => { + const firstProjects = historyManager.getProjects() + firstProjects.pop() + firstProjects[0].path = 'modified' + + const secondProjects = historyManager.getProjects() + expect(secondProjects.length).toBe(2) + expect(secondProjects[0].path).not.toBe('modified') + }) + }) + + describe("clearProjects", () => { + it("clears the list of projects", () => { + expect(historyManager.getProjects().length).not.toBe(0) + historyManager.clearProjects() + expect(historyManager.getProjects().length).toBe(0) + }) + + it("saves the state", () => { + expect(localStorage.setItem).not.toHaveBeenCalled() + historyManager.clearProjects() + expect(localStorage.setItem).toHaveBeenCalled() + expect(localStorage.setItem.calls[0].args[0]).toBe('history') + expect(historyManager.getProjects().length).toBe(0) + }) + + it("fires the onDidChangeProjects event", () => { + expect(localStorage.setItem).not.toHaveBeenCalled() + historyManager.clearProjects() + expect(localStorage.setItem).toHaveBeenCalled() + expect(localStorage.setItem.calls[0].args[0]).toBe('history') + expect(historyManager.getProjects().length).toBe(0) + }) + }) + + it("loads state", () => { + expect(localStorage.getItem).toHaveBeenCalledWith('history') + }) + + it("listens to project.onDidChangePaths adding a new project", () => { + const start = new Date() + project.didChangePathsListener(['/a/new', '/path/or/two']) + const projects = historyManager.getProjects() + expect(projects.length).toBe(3) + expect(projects[0].paths).toEqual(['/a/new', '/path/or/two']) + expect(projects[0].lastOpened).not.toBeLessThan(start) + }) + + it("listens to project.onDidChangePaths updating an existing project", () => { + const start = new Date() + project.didChangePathsListener(['/test']) + const projects = historyManager.getProjects() + expect(projects.length).toBe(2) + expect(projects[0].paths).toEqual(['/test']) + expect(projects[0].lastOpened).not.toBeLessThan(start) + }) + }) + + describe("loadState", () => { + it("defaults to an empty array if no state", () => { + localStorage.items.history = null + historyManager.loadState() + expect(historyManager.getProjects()).toEqual([]) + }) + + it("defaults to an empty array if no projects", () => { + localStorage.items.history = JSON.stringify('') + historyManager.loadState() + expect(historyManager.getProjects()).toEqual([]) + }) + }) + + describe("addProject", () => { + it("adds a new project to the end", () => { + const date = new Date(2010, 10, 9, 8, 7, 6) + historyManager.addProject(['/a/b'], date) + const projects = historyManager.getProjects() + expect(projects.length).toBe(3) + expect(projects[2].paths).toEqual(['/a/b']) + expect(projects[2].lastOpened).toBe(date) + }) + + it("adds a new project to the start", () => { + const date = new Date() + historyManager.addProject(['/so/new'], date) + const projects = historyManager.getProjects() + expect(projects.length).toBe(3) + expect(projects[0].paths).toEqual(['/so/new']) + expect(projects[0].lastOpened).toBe(date) + }) + + it("updates an existing project and moves it to the start", () => { + const date = new Date() + historyManager.addProject(['/test'], date) + const projects = historyManager.getProjects() + expect(projects.length).toBe(2) + expect(projects[0].paths).toEqual(['/test']) + expect(projects[0].lastOpened).toBe(date) + }) + + it("fires the onDidChangeProjects event when adding a project", () => { + const didChangeSpy = jasmine.createSpy() + const beforeCount = historyManager.getProjects().length + historyManager.onDidChangeProjects(didChangeSpy) + historyManager.addProject(['/test-new'], new Date()) + expect(didChangeSpy).toHaveBeenCalled() + expect(historyManager.getProjects().length).toBe(beforeCount + 1) + }) + + it("fires the onDidChangeProjects event when updating a project", () => { + const didChangeSpy = jasmine.createSpy() + const beforeCount = historyManager.getProjects().length + historyManager.onDidChangeProjects(didChangeSpy) + historyManager.addProject(['/test'], new Date()) + expect(didChangeSpy).toHaveBeenCalled() + expect(historyManager.getProjects().length).toBe(beforeCount) + }) + }) + + describe("getProject", () => { + it("returns a project that matches the paths", () => { + const project = historyManager.getProject(['/1', 'c:\\2']) + expect(project).not.toBeNull() + expect(project.paths).toEqual(['/1', 'c:\\2']) + }) + + it("returns null when it can't find the project", () => { + const project = historyManager.getProject(['/1']) + expect(project).toBeNull() + }) + }) + + describe("saveState" ,() => { + it("saves the state", () => { + historyManager.addProject(["/save/state"]) + historyManager.saveState() + expect(localStorage.setItem).toHaveBeenCalled() + expect(localStorage.setItem.calls[0].args[0]).toBe('history') + expect(localStorage.items['history']).toContain('/save/state') + historyManager.loadState() + expect(historyManager.getProjects()[0].paths).toEqual(['/save/state']) + }) + }) +}) diff --git a/spec/reopen-project-menu-manager-spec.js b/spec/reopen-project-menu-manager-spec.js new file mode 100644 index 000000000..b06b4975d --- /dev/null +++ b/spec/reopen-project-menu-manager-spec.js @@ -0,0 +1,267 @@ +/** @babel */ + +import {it, fit, ffit, fffit, beforeEach, afterEach} from './async-spec-helpers' +import {Emitter, Disposable, CompositeDisposable} from 'event-kit' + +const ReopenProjectMenuManager = require('../src/reopen-project-menu-manager') + +numberRange = (low, high) => { + const size = high - low + const result = new Array(size) + for (var i = 0; i < size; i++) + result[i] = low + i + return result +} + +describe("ReopenProjectMenuManager", () => { + let menuManager, commandRegistry, config, historyManager, reopenProjects + let commandDisposable, configDisposable, historyDisposable + + beforeEach(() => { + menuManager = jasmine.createSpyObj('MenuManager', ['add']) + menuManager.add.andReturn(new Disposable()) + + commandRegistry = jasmine.createSpyObj('CommandRegistry', ['add']) + commandDisposable = jasmine.createSpyObj('Disposable', ['dispose']) + commandRegistry.add.andReturn(commandDisposable) + + config = jasmine.createSpyObj('Config', ['onDidChange', 'get']) + config.get.andReturn(10) + configDisposable = jasmine.createSpyObj('Disposable', ['dispose']) + config.didChangeListener = { } + config.onDidChange.andCallFake((key, fn) => { + config.didChangeListener[key] = fn + return configDisposable + }) + + historyManager = jasmine.createSpyObj('historyManager', ['getProjects','onDidChangeProjects']) + historyManager.getProjects.andReturn([]) + historyDisposable = jasmine.createSpyObj('Disposable', ['dispose']) + historyManager.onDidChangeProjects.andCallFake((fn) => { + historyManager.changeProjectsListener = fn + return historyDisposable + }) + + openFunction = jasmine.createSpy() + reopenProjects = new ReopenProjectMenuManager({menu:menuManager, commands: commandRegistry, history: historyManager, config, open:openFunction}) + }) + + describe("constructor", () => { + it("registers the 'reopen-project' command function", () => { + expect(commandRegistry.add).toHaveBeenCalled() + const cmdCall = commandRegistry.add.calls[0] + expect(cmdCall.args.length).toBe(2) + expect(cmdCall.args[0]).toBe('atom-workspace') + expect(typeof cmdCall.args[1]['application:reopen-project']).toBe('function') + }) + }) + + describe("dispose", () => { + it("disposes of the history, command and config disposables", () => { + reopenProjects.dispose() + expect(historyDisposable.dispose).toHaveBeenCalled() + expect(configDisposable.dispose).toHaveBeenCalled() + expect(commandDisposable.dispose).toHaveBeenCalled() + }) + + it("disposes of the menu disposable once used", () => { + const menuDisposable = jasmine.createSpyObj('Disposable', ['dispose']) + menuManager.add.andReturn(menuDisposable) + reopenProjects.update() + expect(menuDisposable.dispose).not.toHaveBeenCalled() + reopenProjects.dispose() + expect(menuDisposable.dispose).toHaveBeenCalled() + }) + }) + + describe("the command", () => { + it("calls open with the paths of the project specified by the detail index", () => { + historyManager.getProjects.andReturn([ { paths: ['/a'] }, { paths: ['/b', 'c:\\'] }]) + reopenProjects.update() + + reopenProjectCommand = commandRegistry.add.calls[0].args[1]['application:reopen-project'] + reopenProjectCommand({ detail: { index: 1 } }) + + expect(openFunction).toHaveBeenCalled() + expect(openFunction.calls[0].args[0]).toEqual(['/b', 'c:\\']) + }) + + it("does not call open when no command detail is supplied", () => { + reopenProjectCommand = commandRegistry.add.calls[0].args[1]['application:reopen-project'] + reopenProjectCommand({}) + + expect(openFunction).not.toHaveBeenCalled() + }) + + it("does not call open when no command detail index is supplied", () => { + reopenProjectCommand = commandRegistry.add.calls[0].args[1]['application:reopen-project'] + reopenProjectCommand({ detail: { anything: 'here' } }) + + expect(openFunction).not.toHaveBeenCalled() + }) + }) + + describe("update", () => { + it("adds menu items to MenuManager based on projects from HistoryManager", () => { + historyManager.getProjects.andReturn([ { paths: ['/a'] }, { paths: ['/b', 'c:\\'] }]) + reopenProjects.update() + expect(historyManager.getProjects).toHaveBeenCalled() + expect(menuManager.add).toHaveBeenCalled() + const menuArg = menuManager.add.calls[0].args[0] + expect(menuArg.length).toBe(1) + expect(menuArg[0].label).toBe('File') + expect(menuArg[0].submenu.length).toBe(1) + const projectsMenu = menuArg[0].submenu[0] + expect(projectsMenu.label).toBe('Reopen Project') + expect(projectsMenu.submenu.length).toBe(2) + + const first = projectsMenu.submenu[0] + expect(first.label).toBe('/a') + expect(first.command).toBe('application:reopen-project') + expect(first.commandDetail).toEqual({ index: 0 }) + + const second = projectsMenu.submenu[1] + expect(second.label).toBe('b, c:\\') + expect(second.command).toBe('application:reopen-project') + expect(second.commandDetail).toEqual({ index: 1 }) + }) + + it("adds only the number of menu items specified in the 'core.reopenProjectMenuCount' config", () => { + historyManager.getProjects.andReturn(numberRange(1, 100).map(i => ({ paths: [ '/test/' + i ] }))) + reopenProjects.update() + expect(menuManager.add).toHaveBeenCalled() + const menu = menuManager.add.calls[0].args[0][0] + expect(menu.label).toBe('File') + expect(menu.submenu.length).toBe(1) + expect(menu.submenu[0].label).toBe('Reopen Project') + expect(menu.submenu[0].submenu.length).toBe(10) + }) + + it("disposes the previously menu built", () => { + const menuDisposable = jasmine.createSpyObj('Disposable', ['dispose']) + menuManager.add.andReturn(menuDisposable) + reopenProjects.update() + expect(menuDisposable.dispose).not.toHaveBeenCalled() + reopenProjects.update() + expect(menuDisposable.dispose).toHaveBeenCalled() + }) + + it("is called when the Config changes for 'core.reopenProjectMenuCount'", () => { + historyManager.getProjects.andReturn(numberRange(1, 100).map(i => ({ paths: [ '/test/' + i ] }))) + reopenProjects.update() + config.get.andReturn(25) + config.didChangeListener['core.reopenProjectMenuCount']({oldValue:10, newValue: 25}) + + const finalArgs = menuManager.add.calls[1].args[0] + const projectsMenu = finalArgs[0].submenu[0].submenu + + expect(projectsMenu.length).toBe(25) + }) + + it("is called when the HistoryManager's projects change", () => { + reopenProjects.update() + historyManager.getProjects.andReturn([ { paths: ['/a'] }, { paths: ['/b', 'c:\\'] } ]) + historyManager.changeProjectsListener() + expect(menuManager.add.calls.length).toBe(2) + + const finalArgs = menuManager.add.calls[1].args[0] + const projectsMenu = finalArgs[0].submenu[0] + + const first = projectsMenu.submenu[0] + expect(first.label).toBe('/a') + expect(first.command).toBe('application:reopen-project') + expect(first.commandDetail).toEqual({ index: 0 }) + + const second = projectsMenu.submenu[1] + expect(second.label).toBe('b, c:\\') + expect(second.command).toBe('application:reopen-project') + expect(second.commandDetail).toEqual({ index: 1 }) + }) + }) + + describe("updateProjects", () => { + it("creates correct menu items commands for recent projects", () => { + const projects = [ + { paths: [ '/users/neila' ] }, + { paths: [ '/users/buzza', 'users/michaelc' ] } + ] + + const menu = ReopenProjectMenuManager.createProjectsMenu(projects) + expect(menu.label).toBe('File') + expect(menu.submenu.length).toBe(1) + + const recentMenu = menu.submenu[0] + expect(recentMenu.label).toBe('Reopen Project') + expect(recentMenu.submenu.length).toBe(2) + + const first = recentMenu.submenu[0] + expect(first.label).toBe('/users/neila') + expect(first.command).toBe('application:reopen-project') + expect(first.commandDetail).toEqual({index: 0}) + + const second = recentMenu.submenu[1] + expect(second.label).toBe('buzza, michaelc') + expect(second.command).toBe('application:reopen-project') + expect(second.commandDetail).toEqual({index: 1}) + }) + }) + + describe("createLabel", () => { + it("returns the Unix path unchanged if there is only one", () => { + const label = ReopenProjectMenuManager.createLabel({ paths: ['/a/b/c/d/e/f'] }) + expect(label).toBe('/a/b/c/d/e/f') + }) + + it("returns the Windows path unchanged if there is only one", () => { + const label = ReopenProjectMenuManager.createLabel({ paths: ['c:\\missions\\apollo11'] }) + expect(label).toBe('c:\\missions\\apollo11') + }) + + it("returns the URL unchanged if there is only one", () => { + const label = ReopenProjectMenuManager.createLabel({ paths: ['https://launch.pad/apollo/11'] }) + expect(label).toBe('https://launch.pad/apollo/11') + }) + + it("returns a comma-seperated list of base names if there are multiple", () => { + const project = { paths: [ '/var/one', '/usr/bin/two', '/etc/mission/control/three' ] } + const label = ReopenProjectMenuManager.createLabel(project) + expect(label).toBe('one, two, three') + }) + + describe("betterBaseName", () => { + it("returns the standard base name for an absolute Unix path", () => { + const name = ReopenProjectMenuManager.betterBaseName('/one/to/three') + expect(name).toBe('three') + }) + + it("returns the standard base name for a relative Windows path", () => { + if (process.platform is 'win32') { + const name = ReopenProjectMenuManager.betterBaseName('.\\one\\two') + expect(name).toBe('two') + } + }) + + it("returns the standard base name for an absolute Windows path", () => { + if (process.platform is 'win32') { + const name = ReopenProjectMenuManager.betterBaseName('c:\\missions\\apollo\\11') + expect(name).toBe('11') + } + }) + + it("returns the drive root for a Windows drive name", () => { + const name = ReopenProjectMenuManager.betterBaseName('d:') + expect(name).toBe('d:\\') + }) + + it("returns the drive root for a Windows drive root", () => { + const name = ReopenProjectMenuManager.betterBaseName('e:\\') + expect(name).toBe('e:\\') + }) + + it("returns the final path for a URI", () => { + const name = ReopenProjectMenuManager.betterBaseName('https://something/else') + expect(name).toBe('else') + }) + }) + }) +}) diff --git a/src/application-delegate.coffee b/src/application-delegate.coffee index 5d908a2c9..72b0ef655 100644 --- a/src/application-delegate.coffee +++ b/src/application-delegate.coffee @@ -244,6 +244,17 @@ class ApplicationDelegate didCancelWindowUnload: -> ipcRenderer.send('did-cancel-window-unload') + onDidChangeHistoryManager: (callback) -> + outerCallback = (event, message) -> + callback(event) + + ipcRenderer.on('did-change-history-manager', outerCallback) + new Disposable -> + ipcRenderer.removeListener('did-change-history-manager', outerCallback) + + didChangeHistoryManager: -> + ipcRenderer.send('did-change-history-manager') + openExternal: (url) -> shell.openExternal(url) diff --git a/src/atom-environment.coffee b/src/atom-environment.coffee index ca6a342f4..61ca495f2 100644 --- a/src/atom-environment.coffee +++ b/src/atom-environment.coffee @@ -22,6 +22,8 @@ KeymapManager = require './keymap-extensions' TooltipManager = require './tooltip-manager' CommandRegistry = require './command-registry' GrammarRegistry = require './grammar-registry' +{HistoryManager, HistoryProject} = require './history-manager' +ReopenProjectMenuManager = require './reopen-project-menu-manager' StyleManager = require './style-manager' PackageManager = require './package-manager' ThemeManager = require './theme-manager' @@ -94,6 +96,9 @@ class AtomEnvironment extends Model # Public: A {GrammarRegistry} instance grammars: null + # Public: A {HistoryManager} instance + history: null + # Public: A {PackageManager} instance packages: null @@ -226,6 +231,14 @@ class AtomEnvironment extends Model @observeAutoHideMenuBar() + @history = new HistoryManager({@project, @commands, localStorage}) + # Keep instances of HistoryManager in sync + @history.onDidChangeProjects (e) => + @applicationDelegate.didChangeHistoryManager() unless e.reloaded + @disposables.add @applicationDelegate.onDidChangeHistoryManager(=> @history.loadState()) + + new ReopenProjectMenuManager({@menu, @commands, @history, @config, open: (paths) => @open(pathsToOpen: paths)}) + checkPortableHomeWritable = => responseChannel = "check-portable-home-writable-response" ipcRenderer.on responseChannel, (event, response) -> diff --git a/src/config-schema.js b/src/config-schema.js index 694e132db..63be1273f 100644 --- a/src/config-schema.js +++ b/src/config-schema.js @@ -126,6 +126,11 @@ const configSchema = { type: 'boolean', default: true }, + reopenProjectMenuCount: { + description: 'How many recent projects to show in the Reopen Project menu.', + type: 'integer', + default: 15 + }, automaticallyUpdate: { description: 'Automatically update Atom when a new release is available.', type: 'boolean', diff --git a/src/history-manager.js b/src/history-manager.js new file mode 100644 index 000000000..657beed97 --- /dev/null +++ b/src/history-manager.js @@ -0,0 +1,141 @@ +/** @babel */ + +import {Emitter} from 'event-kit' + +// Extended: History manager for remembering which projects have been opened. +// +// An instance of this class is always available as the `atom.history` global. +// +// The project history is used to enable the 'Reopen Project' menu. +export class HistoryManager { + constructor ({project, commands, localStorage}) { + this.localStorage = localStorage + commands.add('atom-workspace', {'application:clear-project-history': this.clearProjects.bind(this)}) + this.emitter = new Emitter() + this.loadState() + project.onDidChangePaths((projectPaths) => this.addProject(projectPaths)) + } + + // Public: Obtain a list of previously opened projects. + // + // Returns an {Array} of {HistoryProject} objects, most recent first. + getProjects () { + return this.projects.map(p => new HistoryProject(p.paths, p.lastOpened)) + } + + // Public: Clear all projects from the history. + // + // Note: This is not a privacy function - other traces will still exist, + // e.g. window state. + clearProjects () { + this.projects = [] + this.saveState() + this.didChangeProjects() + } + + // Public: Invoke the given callback when the list of projects changes. + // + // * `callback` {Function} + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidChangeProjects (callback) { + return this.emitter.on('did-change-projects', callback) + } + + didChangeProjects (args) { + this.emitter.emit('did-change-projects', args || { reloaded: false }) + } + + addProject (paths, lastOpened) { + let project = this.getProject(paths) + if (!project) { + project = new HistoryProject(paths) + this.projects.push(project) + } + project.lastOpened = lastOpened || new Date() + this.projects.sort((a, b) => b.lastOpened - a.lastOpened) + + this.saveState() + this.didChangeProjects() + } + + getProject (paths) { + const pathsString = paths.toString() + for (var i = 0; i < this.projects.length; i++) { + if (this.projects[i].paths.toString() === pathsString) { + return this.projects[i] + } + } + + return null + } + + loadState () { + const state = JSON.parse(this.localStorage.getItem('history')) + if (state && state.projects) { + this.projects = state.projects.filter(p => Array.isArray(p.paths) && p.paths.length > 0).map(p => new HistoryProject(p.paths, new Date(p.lastOpened))) + this.didChangeProjects({ reloaded: true }) + } else { + this.projects = [] + } + } + + saveState () { + const state = JSON.stringify({ + projects: this.projects.map(p => ({ + paths: p.paths, lastOpened: p.lastOpened + })) + }) + this.localStorage.setItem('history', state) + } + + async importProjectHistory () { + for (let project of await HistoryImporter.getAllProjects()) { + this.addProject(project.paths, project.lastOpened) + } + this.saveState() + this.didChangeProjects() + } +} + +export class HistoryProject { + constructor (paths, lastOpened) { + this.paths = paths + this.lastOpened = lastOpened || new Date() + } + + set paths (paths) { this._paths = paths } + get paths () { return this._paths } + + set lastOpened (lastOpened) { this._lastOpened = lastOpened } + get lastOpened () { return this._lastOpened } +} + +class HistoryImporter { + static async getStateStoreCursor () { + const db = await atom.stateStore.dbPromise + const store = db.transaction(['states']).objectStore('states') + return store.openCursor() + } + + static async getAllProjects (stateStore) { + const request = await HistoryImporter.getStateStoreCursor() + return new Promise((resolve, reject) => { + const rows = [] + request.onerror = reject + request.onsuccess = event => { + const cursor = event.target.result + if (cursor) { + let project = cursor.value.value.project + let storedAt = cursor.value.storedAt + if (project && project.paths && storedAt) { + rows.push(new HistoryProject(project.paths, new Date(Date.parse(storedAt)))) + } + cursor.continue() + } else { + resolve(rows) + } + } + }) + } +} diff --git a/src/main-process/application-menu.coffee b/src/main-process/application-menu.coffee index b0a6e3267..f06e4933f 100644 --- a/src/main-process/application-menu.coffee +++ b/src/main-process/application-menu.coffee @@ -142,8 +142,8 @@ class ApplicationMenu item.metadata ?= {} if item.command item.accelerator = @acceleratorForCommand(item.command, keystrokesByCommand) - item.click = -> global.atomApplication.sendCommand(item.command) - item.metadata.windowSpecific = true unless /^application:/.test(item.command) + item.click = -> global.atomApplication.sendCommand(item.command, item.commandDetail) + item.metadata.windowSpecific = true unless /^application:/.test(item.command, item.commandDetail) @translateTemplate(item.submenu, keystrokesByCommand) if item.submenu template diff --git a/src/main-process/atom-application.coffee b/src/main-process/atom-application.coffee index aaceebffe..0da60467a 100644 --- a/src/main-process/atom-application.coffee +++ b/src/main-process/atom-application.coffee @@ -279,6 +279,12 @@ class AtomApplication @disposable.add ipcHelpers.on ipcMain, 'restart-application', => @restart() + @disposable.add ipcHelpers.on ipcMain, 'did-change-history-manager', (event) => + for atomWindow in @windows + webContents = atomWindow.browserWindow.webContents + if webContents isnt event.sender + webContents.send('did-change-history-manager') + # A request from the associated render process to open a new render process. @disposable.add ipcHelpers.on ipcMain, 'open', (event, options) => window = @atomWindowForEvent(event) diff --git a/src/reopen-project-list-view.js b/src/reopen-project-list-view.js new file mode 100644 index 000000000..0774c8db7 --- /dev/null +++ b/src/reopen-project-list-view.js @@ -0,0 +1,64 @@ +/** @babel */ + +import { SelectListView } from 'atom-space-pen-views' + +export default class ReopenProjectListView extends SelectListView { + initialize (callback) { + this.callback = callback + super.initialize() + this.addClass('reopen-project') + this.list.addClass('mark-active') + } + + getFilterKey () { + return 'name' + } + + destroy () { + this.cancel() + } + + viewForItem (project) { + let element = document.createElement('li') + if (project.name === this.currentProjectName) { + element.classList.add('active') + } + element.textContent = project.name + return element + } + + cancelled () { + if (this.panel != null) { + this.panel.destroy() + } + this.panel = null + this.currentProjectName = null + } + + confirmed (project) { + this.cancel() + this.callback(project.value) + } + + attach () { + this.storeFocusedElement() + if (this.panel == null) { + this.panel = atom.workspace.addModalPanel({item: this}) + } + this.focusFilterEditor() + } + + toggle () { + if (this.panel != null) { + this.cancel() + } else { + this.currentProjectName = atom.project != null ? this.makeName(atom.project.getPaths()) : null + this.setItems(atom.history.getProjects().map(p => ({ name: this.makeName(p.paths), value: p.paths }))) + this.attach() + } + } + + makeName (paths) { + return paths.join(', ') + } +} diff --git a/src/reopen-project-menu-manager.js b/src/reopen-project-menu-manager.js new file mode 100644 index 000000000..50c42e115 --- /dev/null +++ b/src/reopen-project-menu-manager.js @@ -0,0 +1,93 @@ +/** @babel */ + +import {CompositeDisposable} from 'event-kit' +import path from 'path' + +export default class ReopenProjectMenuManager { + constructor ({menu, commands, history, config, open}) { + this.menuManager = menu + this.historyManager = history + this.config = config + this.open = open + this.projects = [] + + this.subscriptions = new CompositeDisposable() + this.subscriptions.add( + history.onDidChangeProjects(this.update.bind(this)), + config.onDidChange('core.reopenProjectMenuCount', ({oldValue, newValue}) => { + this.update() + }), + commands.add('atom-workspace', { 'application:reopen-project': this.reopenProjectCommand.bind(this) }) + ) + } + + reopenProjectCommand (e) { + if (e.detail != null && e.detail.index != null) { + this.open(this.projects[e.detail.index].paths) + } else { + this.createReopenProjectListView() + } + } + + createReopenProjectListView () { + if (this.reopenProjectListView == null) { + const ReopenProjectListView = require('./reopen-project-list-view') + this.reopenProjectListView = new ReopenProjectListView(paths => { + if (paths != null) { + this.open(paths) + } + }) + } + this.reopenProjectListView.toggle() + } + + update () { + this.disposeProjectMenu() + this.projects = this.historyManager.getProjects().slice(0, this.config.get('core.reopenProjectMenuCount')) + const newMenu = ReopenProjectMenuManager.createProjectsMenu(this.projects) + this.lastProjectMenu = this.menuManager.add([newMenu]) + } + + dispose () { + this.subscriptions.dispose() + this.disposeProjectMenu() + if (this.reopenProjectListView != null) { + this.reopenProjectListView.dispose() + } + } + + disposeProjectMenu () { + if (this.lastProjectMenu) { + this.lastProjectMenu.dispose() + this.lastProjectMenu = null + } + } + + static createProjectsMenu (projects) { + return { + label: 'File', + submenu: [ + { + label: 'Reopen Project', + submenu: projects.map((project, index) => ({ + label: this.createLabel(project), + command: 'application:reopen-project', + commandDetail: {index: index} + })) + } + ] + } + } + + static createLabel (project) { + return project.paths.length === 1 + ? project.paths[0] + : project.paths.map(this.betterBaseName).join(', ') + } + + static betterBaseName (directory) { + // Handles Windows roots better than path.basename which returns '' for 'd:' and 'd:\' + const match = directory.match(/^([a-z]:)[\\]?$/i) + return match ? match[1] + '\\' : path.basename(directory) + } +}