mirror of
https://github.com/atom/atom.git
synced 2026-01-25 23:08:18 -05:00
Merge pull request #13046 from atom/dg-reopen-project
Project history api, reopen project menu and command
This commit is contained in:
@@ -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' }
|
||||
|
||||
@@ -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' }
|
||||
|
||||
@@ -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' }
|
||||
|
||||
199
spec/history-manager-spec.js
Normal file
199
spec/history-manager-spec.js
Normal file
@@ -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'])
|
||||
})
|
||||
})
|
||||
})
|
||||
267
spec/reopen-project-menu-manager-spec.js
Normal file
267
spec/reopen-project-menu-manager-spec.js
Normal file
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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) ->
|
||||
|
||||
@@ -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',
|
||||
|
||||
141
src/history-manager.js
Normal file
141
src/history-manager.js
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
64
src/reopen-project-list-view.js
Normal file
64
src/reopen-project-list-view.js
Normal file
@@ -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(', ')
|
||||
}
|
||||
}
|
||||
93
src/reopen-project-menu-manager.js
Normal file
93
src/reopen-project-menu-manager.js
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user