Skip to content
This repository was archived by the owner on Dec 15, 2022. It is now read-only.

Implement multi-selection move (#232) #619

Closed
wants to merge 4 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions lib/helpers.coffee
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,32 @@ module.exports =
camelizedAttr = property.replace /\-([a-z])/g, (a, b) -> b.toUpperCase()
styleObject[camelizedAttr] = value
styleObject

lowestCommonAncestor: (paths) ->
basePaths = (thePath.substring(0, thePath.lastIndexOf(path.sep)) for thePath in paths)

# if we only have one path, then suggest the base directory
return basePaths[0] + path.sep if basePaths.length is 1

# split each path into its components
splits = ((elt for elt in thePath.split(path.sep) when elt) for thePath in basePaths)

# compute the maximum depth we have to check as the shortest path length
maxDepth = Math.min.apply(null, split.length for split in splits) - 1

for depth in [0..maxDepth]
level = (split[depth] for split in splits)
# is every path component on this level the same?
break unless level.reduce((prev, curr) -> if prev is curr then curr else false)

# join the paths with a trailing slash
result = path.join.apply(null, splits[0][0..depth-1]) + path.sep

# if this isn't windows, then prepend a slash
if process.platform isnt 'win32'
result = path.sep + result

return result

typeIsArray: Array.isArray or (value) ->
return {}.toString.call(value) is '[object Array]'
52 changes: 35 additions & 17 deletions lib/move-dialog.coffee
Original file line number Diff line number Diff line change
@@ -1,57 +1,75 @@
path = require 'path'
fs = require 'fs-plus'
Dialog = require './dialog'
{repoForPath} = require "./helpers"
{repoForPath, lowestCommonAncestor, typeIsArray} = require "./helpers"
_ = require 'underscore-plus'

module.exports =
class MoveDialog extends Dialog
constructor: (@initialPath) ->
select = true
if typeIsArray(@initialPath)
if @initialPath.length is 1
@initialPath = @initialPath[0]
else
prompt = 'Enter the new path for the files.'
suggestedPath = lowestCommonAncestor(@initialPath)
select = false
suggestedPath ?= @initialPath

if fs.isDirectorySync(@initialPath)
prompt = 'Enter the new path for the directory.'
prompt ?= 'Enter the new path for the directory.'
else
prompt = 'Enter the new path for the file.'
prompt ?= 'Enter the new path for the file.'

super
prompt: prompt
initialPath: atom.project.relativize(@initialPath)
select: true
initialPath: atom.project.relativize(suggestedPath)
select: select
iconClass: 'icon-arrow-right'

onConfirm: (newPath) ->
newPath = newPath.replace(/\s+$/, '') # Remove trailing whitespace
unless path.isAbsolute(newPath)
[rootPath] = atom.project.relativizePath(@initialPath)
[rootPath] = atom.project.relativizePath(
if typeIsArray(@initialPath) then @initialPath[0] else @initialPath)
newPath = path.join(rootPath, newPath)
return unless newPath

if @initialPath is newPath
if not typeIsArray(@initialPath) and @initialPath is newPath
@close()
return

unless @isNewPathValid(newPath)
@showError("'#{newPath}' already exists.")
return
suppliedPaths = if typeIsArray(@initialPath) then @initialPath else [@initialPath]
filesToMove = {}
for thePath in suppliedPaths
destination = if typeIsArray(@initialPath) then path.join(newPath, path.basename(thePath)) else newPath
unless @isNewPathValid(thePath, destination)
@showError("'#{destination}' already exists.")
return
filesToMove[thePath] = destination

directoryPath = path.dirname(newPath)
try
fs.makeTreeSync(directoryPath) unless fs.existsSync(directoryPath)
fs.moveSync(@initialPath, newPath)
if repo = repoForPath(newPath)
repo.getPathStatus(@initialPath)
repo.getPathStatus(newPath)
for src, dest of filesToMove
fs.moveSync(src, dest)
if repo = repoForPath(dest)
repo.getPathStatus(src)
repo.getPathStatus(dest)
@close()
catch error
@showError("#{error.message}.")

isNewPathValid: (newPath) ->
isNewPathValid: (oldPath, newPath) ->
try
oldStat = fs.statSync(@initialPath)
oldStat = fs.statSync(oldPath)
newStat = fs.statSync(newPath)

# New path exists so check if it points to the same file as the initial
# path to see if the case of the file name is being changed on a on a
# case insensitive filesystem.
@initialPath.toLowerCase() is newPath.toLowerCase() and
oldPath.toLowerCase() is newPath.toLowerCase() and
oldStat.dev is newStat.dev and
oldStat.ino is newStat.ino
catch
Expand Down
15 changes: 9 additions & 6 deletions lib/tree-view.coffee
Original file line number Diff line number Diff line change
Expand Up @@ -410,15 +410,18 @@ class TreeView extends View

moveSelectedEntry: ->
if @hasFocus()
entry = @selectedEntry()
return if not entry? or entry in @roots
oldPath = entry.getPath()
entries = @getSelectedEntries()
return unless entries.length > 0
oldPaths = _.map(entries, (entry) =>
entry.getPath() unless entry is @root
)
else
oldPath = @getActivePath()
oldPaths = []
oldPaths.push(@getActivePath()) if @getActivePath()?

if oldPath
if oldPaths and oldPaths.length > 0
MoveDialog ?= require './move-dialog'
dialog = new MoveDialog(oldPath)
dialog = new MoveDialog(oldPaths)
dialog.attach()

# Get the outline of a system call to the current platform's file manager.
Expand Down
3 changes: 2 additions & 1 deletion menus/tree-view.cson
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@
{'label': 'Copy Project Path', 'command': 'tree-view:copy-project-path'}
{'label': 'Open In New Window', 'command': 'tree-view:open-in-new-window'}
]

'.tree-view.full-menu [is="tree-view-file"]': [
{'label': 'Split Up', 'command': 'tree-view:open-selected-entry-up'}
{'label': 'Split Down', 'command': 'tree-view:open-selected-entry-down'}
Expand Down Expand Up @@ -88,6 +88,7 @@
{'label': 'Copy', 'command': 'tree-view:copy'}
{'label': 'Cut', 'command': 'tree-view:cut'}
{'label': 'Paste', 'command': 'tree-view:paste'}
{'label': 'Move', 'command': 'tree-view:move'}
]

'atom-pane[data-active-item-path] .item-views': [
Expand Down
97 changes: 97 additions & 0 deletions spec/tree-view-spec.coffee
Original file line number Diff line number Diff line change
Expand Up @@ -1802,6 +1802,103 @@ describe "TreeView", ->
expect(atom.workspace.getModalPanels().length).toBe 0
expect(atom.views.getView(atom.workspace.getActivePane())).toHaveFocus()

describe "when multiple files are selected", ->
moveDialog = null

beforeEach ->
jasmine.attachToDOM(workspaceElement)

waitsForFileToOpen ->
fileView2.click()

runs ->
fileView3.trigger($.Event('mousedown', {metaKey: true}))
treeView.focus()

waitsFor 'multiple selections to occur', ->
treeView.find('.tree-view').hasClass('multi-select')

runs ->
atom.commands.dispatch(treeView.element, "tree-view:move")
moveDialog = $(atom.workspace.getModalPanels()[0].getItem()).view()

afterEach ->
waits 50 # The move specs cause too many false positives because of their async nature, so wait a little bit before we cleanup

it "opens a move dialog with the files' lowest common ancestor as default suggested path", ->
expect(moveDialog).toExist()
expect(moveDialog.promptText.text()).toBe "Enter the new path for the files."
expect(moveDialog.miniEditor.getText()).toBe(atom.project.relativize(dirPath2 + path.sep))
expect(moveDialog.miniEditor.getModel().getSelectedText()).toBe ''
expect(moveDialog.miniEditor).toHaveFocus()

describe "when the path is changed and confirmed", ->
describe "when all the directories along the new path exist", ->
it "moves the files, updates the tree view, and closes the dialog", ->
moveDialog.miniEditor.setText(dirPath + path.sep)

atom.commands.dispatch moveDialog.element, 'core:confirm'

fileBasename2 = path.basename(filePath2)
fileBasename3 = path.basename(filePath3)

expect(fs.existsSync(path.join(dirPath, fileBasename2))).toBeTruthy()
expect(fs.existsSync(path.join(dirPath, fileBasename3))).toBeTruthy()
expect(fs.existsSync(path.join(dirPath2, fileBasename2))).toBeFalsy()
expect(fs.existsSync(path.join(dirPath2, fileBasename3))).toBeFalsy()
expect(atom.workspace.getModalPanels().length).toBe 0

waitsFor "tree view to update", ->
dirView.find(".file:contains(#{fileBasename2})").length > 0
dirView.find(".file:contains(#{fileBasename3})").length > 0

runs ->
dirView = $(treeView.roots[0].entries).find('.directory:contains(test-dir2)')
dirView[0].expand()
expect($(dirView[0].entries).children().length).toBe 0

describe "when the directories along the new path don't exist", ->
it "creates the target directory before moving the file", ->
newPath = path.join(rootDirPath, 'new', 'directory')
moveDialog.miniEditor.setText(newPath)

atom.commands.dispatch moveDialog.element, 'core:confirm'

waitsFor "tree view to update", ->
root1.find('> .entries > .directory:contains(new)').length > 0

runs ->
fileBasename2 = path.basename(filePath2)
fileBasename3 = path.basename(filePath3)

expect(fs.existsSync(newPath)).toBeTruthy()
expect(fs.existsSync(path.join(dirPath2, fileBasename2))).toBeFalsy()
expect(fs.existsSync(path.join(dirPath2, fileBasename3))).toBeFalsy()

describe "when a file or directory already exists at the target path", ->
it "shows an error message and does not close the dialog", ->
runs ->
fs.writeFileSync(path.join(dirPath, path.basename(filePath2)), '')
moveDialog.miniEditor.setText(dirPath)

atom.commands.dispatch moveDialog.element, 'core:confirm'

expect(moveDialog.errorMessage.text()).toContain 'already exists'
expect(moveDialog).toHaveClass('error')
expect(moveDialog.hasParent()).toBeTruthy()

describe "when 'core:cancel' is triggered on the move dialog", ->
it "removes the dialog and focuses the tree view", ->
atom.commands.dispatch moveDialog.element, 'core:cancel'
expect(atom.workspace.getModalPanels().length).toBe 0
expect(treeView.find(".tree-view")).toMatchSelector(':focus')

describe "when the move dialog's editor loses focus", ->
it "removes the dialog and focuses root view", ->
$(workspaceElement).focus()
expect(atom.workspace.getModalPanels().length).toBe 0
expect(atom.views.getView(atom.workspace.getActivePane())).toHaveFocus()

describe "when a file is selected that's name starts with a '.'", ->
[dotFilePath, dotFileView, moveDialog] = []

Expand Down