'use strict'; /* global angular */ /* global sanitize, isModalVisible */ angular.module('Application').component('filetree', { bindings: { backendId: '<', backendType: '<', view: '<', clipboard: '<', onUploadFile: '&', onUploadFolder: '&', onNewFile: '&', onNewFolder: '&', onRenameEntry: '&', onExtractEntry: '&', onChownEntries: '&', onDeleteEntries: '&', onCopyEntries: '&', onCutEntries: '&', onPasteEntries: '&' }, templateUrl: 'components/filetree.html?<%= revision %>', controller: [ '$scope', '$translate', '$timeout', 'Client', FileTreeController ] }); function FileTreeController($scope, $translate, $timeout, Client) { var ctrl = this; $scope.backendId = this.backendId; $scope.backendType = this.backendType; $scope.view = this.view; $scope.busy = true; $scope.busyRefresh = false; $scope.client = Client; $scope.cwd = null; $scope.cwdParts = []; $scope.rootDirLabel = ''; $scope.entries = []; $scope.selected = []; // holds selected entries $scope.dropToBody = false; $scope.applicationLink = ''; // register so parent can call child $scope.$parent.registerChild($scope); function isArchive(f) { return f.match(/\.tgz$/) || f.match(/\.tar$/) || f.match(/\.7z$/) || f.match(/\.zip$/) || f.match(/\.tar\.gz$/) || f.match(/\.tar\.xz$/) || f.match(/\.tar\.bz2$/); } $scope.menuOptions = []; // shown for entries $scope.menuOptionsBlank = []; // shown for empty space in folder function sort() { return $scope.entries.sort(function (a, b) { if (a.fileName.toLowerCase() < b.fileName.toLowerCase()) return -1; return 1; }).sort(function (a, b) { if ((a.isDirectory && b.isDirectory) || (!a.isDirectory && !b.isDirectory)) return 0; if (a.isDirectory && !b.isDirectory) return -1; return 1; }); } function openPath(path) { path = sanitize(path); // we always show the parent path, even if overlayed by a mediaviewer var parentPath = sanitize(path + '/..'); $scope.busy = true; $scope.cwdParts = path.split('/').filter(function (p) { return !!p; }).map(function (p, i) { return { name: decodeURIComponent(p), path: path.split('/').slice(0, i+1).join('/') }; }); // nothing changes here, mostly triggered when editor is closed if ($scope.cwd === path) { $scope.busy = false; return; } if ($scope.cwd === parentPath) { var entry = $scope.entries.find(function (e) { return path === sanitize(parentPath + '/' + e.fileName); }); if (!entry) return Client.error('No such file or folder: ' + path); if (entry.isDirectory) { $scope.cwd = path; $scope.selected = []; // refresh will set busy to false once done $scope.refresh(); } else if (entry.isFile) { var mimeType = Mimer().get(entry.fileName); var mimeGroup = mimeType.split('/')[0]; if (mimeGroup === 'video' || mimeGroup === 'image') { $scope.mediaViewer.show(entry); } else if (mimeType === 'application/pdf') { Client.filesGet($scope.backendId, $scope.backendType, path, 'open', function (error) { if (error) return Client.error(error); }); } else if (mimeGroup === 'text' || mimeGroup === 'application') { $scope.$parent.textEditor.show($scope.cwd, entry); } else { Client.filesGet($scope.backendId, $scope.backendType, path, 'open', function (error) { if (error) return Client.error(error); }); } $scope.busy = false; } return; } Client.filesGet($scope.backendId, $scope.backendType, parentPath, 'data', function (error, result) { if (error) return setTimeout(function () { openPath(path); }, 2000); // try again in some time // call itself now that we know $scope.cwd = parentPath; $scope.entries = result.entries; amendIcons(); sort(); openPath(path); }); } $scope.isSelected = function (entry) { return $scope.selected.indexOf(entry) !== -1; }; function download(entry) { var filePath = sanitize($scope.cwd + '/' + entry.fileName); Client.filesGet($scope.backendId, $scope.backendType, filePath, 'download', function (error) { if (error) return Client.error(error); }); } $scope.dragStart = function ($event, entry) { var filePaths = $scope.selected.map(function (entry) { return sanitize($scope.cwd + '/' + entry.fileName); }); $event.originalEvent.dataTransfer.setData('application/cloudron-filemanager', JSON.stringify(filePaths)); }; $scope.dragEnter = function ($event, entry) { $event.originalEvent.stopPropagation(); $event.originalEvent.preventDefault(); // if entry is string, we come from breadcrumb if (entry && typeof entry === 'string') $event.currentTarget.classList.add('entry-hovered'); else if (entry && entry.isDirectory) entry.hovered = true; else $scope.dropToBody = true; $event.originalEvent.dataTransfer.dropEffect = 'move'; }; $scope.dragExit = function ($event, entry) { $event.originalEvent.stopPropagation(); $event.originalEvent.preventDefault(); // if entry is string, we come from breadcrumb if (entry && typeof entry === 'string') $event.currentTarget.classList.remove('entry-hovered'); else if (entry && entry.isDirectory) entry.hovered = false; $scope.dropToBody = false; $event.originalEvent.dataTransfer.dropEffect = 'move'; }; $scope.drop = function (event, entry) { event.originalEvent.stopPropagation(); event.originalEvent.preventDefault(); $scope.dropToBody = false; if (!event.originalEvent.dataTransfer.items[0]) return; var targetFolder; if (entry === null) targetFolder = $scope.cwd + '/'; else if (typeof entry === 'string') targetFolder = sanitize(entry); else targetFolder = sanitize($scope.cwd + '/' + (entry && entry.isDirectory ? entry.fileName : '')); var dataTransfer = event.originalEvent.dataTransfer; var dragContent = dataTransfer.getData('application/cloudron-filemanager'); // check if we have internal drag'n'drop if (dragContent) { var moved = 0; // we expect a JSON.stringified Array here try { dragContent = JSON.parse(dragContent); } catch (e) { console.error('Wrong drag content.', e); return; } // move files async.eachLimit(dragContent, 5, function (oldFilePath, callback) { var fileName = oldFilePath.split('/').slice(-1); var newFilePath = sanitize(targetFolder + '/' + fileName); // if we drop the item on itself if (oldFilePath === targetFolder) return callback(); // if nothing changes if (newFilePath === oldFilePath) return callback(); moved++; // TODO this will overwrite files in destination! Client.filesRename($scope.backendId, $scope.backendType, oldFilePath, newFilePath, callback); }, function (error) { if (error) return Client.error(error); // only refresh if anything has changed if (moved) $scope.refresh(); }); return; } // figure if a folder was dropped on a modern browser, in this case the first would have to be a directory var folderItem; try { folderItem = dataTransfer.items[0].webkitGetAsEntry(); if (folderItem.isFile) return $scope.$parent.uploadFiles(event.originalEvent.dataTransfer.files, targetFolder, false); } catch (e) { return $scope.$parent.uploadFiles(event.originalEvent.dataTransfer.files, targetFolder, false); } // if we got here we have a folder drop and a modern browser // now traverse the folder tree and create a file list var fileList = []; function traverseFileTree(item, path, callback) { if (item.isFile) { // Get file item.file(function (file) { fileList.push(file); callback(); }); } else if (item.isDirectory) { // Get folder contents var dirReader = item.createReader(); dirReader.readEntries(function (entries) { async.each(entries, function (entry, callback) { traverseFileTree(entry, path + item.name + '/', callback); }, callback); }); } } traverseFileTree(folderItem, '', function (error) { if (error) return console.error(error); $scope.$parent.uploadFiles(fileList, targetFolder, false); }); }; $scope.refresh = function () { $scope.$parent.refresh(); }; function amendIcons() { $scope.entries.forEach(function (e) { e.icon = 'fa-file'; e.previewUrl = null; if (e.isDirectory) e.icon = 'fa-folder'; if (e.isSymbolicLink) e.icon = 'fa-link'; if (e.isFile) { var mimeType = Mimer().get(e.fileName.toLowerCase()); var mimeGroup = mimeType.split('/')[0]; if (mimeGroup === 'text') e.icon = 'fa-file-alt'; // if (mimeGroup === 'image') e.icon = 'fa-file-image'; if (mimeGroup === 'image') { e.icon = 'fa-file-image'; e.previewUrl = Client.filesGetLink($scope.backendId, $scope.backendType, sanitize($scope.cwd + '/' + e.fileName)); } if (mimeGroup === 'video') e.icon = 'fa-file-video'; if (mimeGroup === 'audio') e.icon = 'fa-file-audio'; if (mimeType === 'text/csv') e.icon = 'fa-file-csv'; if (mimeType === 'application/pdf') e.icon = 'fa-file-pdf'; } }); } // called from the parent $scope.onRefresh = function () { $scope.selected = []; $scope.busy = true; $scope.busyRefresh = true; Client.filesGet($scope.backendId, $scope.backendType, $scope.cwd, 'data', function (error, result) { if (error) return Client.error(error); $scope.entries = result.entries; amendIcons(); sort(); $scope.busyRefresh = false; $scope.busy = false; }); }; $scope.open = function (entry) { openPath(sanitize($scope.cwd + '/' + entry.fileName)); }; $scope.goDirectoryUp = function () { openPath(sanitize($scope.cwd + '/..')); }; $scope.changeDirectory = function (path) { openPath(sanitize(path)); }; $scope.onClearSelection = function ($event) { // we don't stop propagation if targets don't match we got the whole list click event if ($event.currentTarget !== $event.target) return; $scope.selected = []; }; $scope.onMousedown = function ($event, entry) { var i = $scope.selected.indexOf(entry); var multi = ($event.ctrlKey || $event.metaKey); var shift = $event.shiftKey; if (shift) { if ($scope.selected.length === 0) { $scope.selected = [ entry ]; } else { var pos = $scope.entries.indexOf(entry); var selectedPositions = $scope.selected.map(function (s) { return $scope.entries.indexOf(s); }).sort(); if (pos < selectedPositions[0]) { $scope.selected = $scope.entries.slice(pos, selectedPositions[0]+1); } else if (selectedPositions[1] && pos > selectedPositions[1]) { $scope.selected = $scope.entries.slice(selectedPositions[1], pos+1); } else { $scope.selected = $scope.entries.slice(selectedPositions[0], pos+1); } } } else if (multi) { if (i === -1) { $scope.selected.push(entry); } else if ($event.button === 0) { // only do this on left click $scope.selected.splice(i, 1); } } else { $scope.selected = [ entry ]; } }; $scope.onEntryContextMenu = function ($event, entry) { if ($scope.selected.indexOf(entry) !== -1) return; $scope.selected.push(entry); }; $scope.actionSelectAll = function () { $scope.selected = $scope.entries.slice(); }; // just events to the parent controller $scope.onUploadFile = function () { ctrl.onUploadFile({ cwd: $scope.cwd }); }; $scope.onUploadFolder = function () { ctrl.onUploadFolder({ cwd: $scope.cwd }); }; $scope.onNewFile = function () { ctrl.onNewFile({ cwd: $scope.cwd }); }; $scope.onNewFolder = function () { ctrl.onNewFolder({ cwd: $scope.cwd }); }; $scope.mediaViewer = { type: '', src: '', entry: null, show: function (entry) { var filePath = sanitize($scope.cwd + '/' + entry.fileName); $scope.mediaViewer.entry = entry; $scope.mediaViewer.type = Mimer().get(entry.fileName).split('/')[0]; $scope.mediaViewer.src = Client.filesGetLink($scope.backendId, $scope.backendType, filePath); $('#mediaViewerModal-' + $scope.$id).modal('show'); }, close: function () { // set an empty pixel image to bust the cached img to avoid flickering on slow load $scope.mediaViewer.src = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z/C/HgAGgwJ/lK3Q6wAAAABJRU5ErkJggg=='; $('#mediaViewerModal-' + $scope.$id).modal('hide'); } }; $translate(['filemanager.list.menu.edit', 'filemanager.list.menu.cut', 'filemanager.list.menu.copy', 'filemanager.list.menu.paste', 'filemanager.list.menu.rename', 'filemanager.list.menu.chown', 'filemanager.list.menu.extract', 'filemanager.list.menu.download', 'filemanager.list.menu.delete' ]).then(function (tr) { $scope.menuOptions = [ { text: tr['filemanager.list.menu.edit'], displayed: function ($itemScope, $event, entry) { return !entry.isDirectory && !entry.isSymbolicLink; }, enabled: function () { return $scope.selected.length === 1; }, hasBottomDivider: true, click: function ($itemScope, $event, entry) { $scope.open(entry); } }, { text: tr['filemanager.list.menu.cut'], click: function ($itemScope, $event, entry) { ctrl.onCutEntries({ cwd: $scope.cwd, entries: $scope.selected.slice() }); } }, { text: tr['filemanager.list.menu.copy'], click: function ($itemScope, $event, entry) { ctrl.onCopyEntries({ cwd: $scope.cwd, entries: $scope.selected.slice() }); } }, { text: tr['filemanager.list.menu.paste'], hasBottomDivider: true, enabled: function () { return ctrl.clipboard.length; }, click: function ($itemScope, $event, entry) { ctrl.onPasteEntries({ cwd: $scope.cwd, entry: entry }); } }, { text: tr['filemanager.list.menu.rename'], enabled: function () { return $scope.selected.length === 1; }, click: function ($itemScope, $event, entry) { ctrl.onRenameEntry({ cwd: $scope.cwd, entry: entry }); } }, { text: tr['filemanager.list.menu.chown'], click: function ($itemScope, $event, entry) { ctrl.onChownEntries({ cwd: $scope.cwd, entries: $scope.selected }); } }, { text: tr['filemanager.list.menu.extract'], displayed: function ($itemScope, $event, entry) { return !entry.isDirectory && isArchive(entry.fileName); }, click: function ($itemScope, $event, entry) { ctrl.onExtractEntry({ cwd: $scope.cwd, entry: entry }); } }, { text: tr['filemanager.list.menu.download'], enabled: function () { return $scope.selected.length === 1; }, click: function ($itemScope, $event, entry) { download(entry); } }, { text: tr['filemanager.list.menu.delete'], hasTopDivider: true, click: function ($itemScope, $event, entry) { ctrl.onDeleteEntries({ cwd: $scope.cwd, entries: $scope.selected }); } } ]; }); $translate(['filemanager.toolbar.newFile', 'filemanager.toolbar.newFolder', 'filemanager.list.menu.paste', 'filemanager.list.menu.selectAll' ]).then(function (tr) { $scope.menuOptionsBlank = [ { text: tr['filemanager.toolbar.newFile'], click: function ($itemScope, $event) { ctrl.onNewFile({ cwd: $scope.cwd }); } }, { text: tr['filemanager.toolbar.newFolder'], click: function ($itemScope, $event) { ctrl.onNewFolder({ cwd: $scope.cwd }); } }, { text: tr['filemanager.list.menu.paste'], hasTopDivider: true, hasBottomDivider: true, enabled: function () { return ctrl.clipboard.length; }, click: function ($itemScope, $event) { ctrl.onPasteEntries({ cwd: $scope.cwd, entry: null }); } }, { text: tr['filemanager.list.menu.selectAll'], click: function ($itemScope, $event) { $scope.actionSelectAll(); } } ]; }); function scrollInView(element) { if (!element) return; // This assumes the DOM tree being that rigid function isVisible(ele) { var container = ele.parentElement.parentElement.parentElement; var eleTop = ele.offsetTop; var eleBottom = eleTop + ele.clientHeight; var containerTop = container.scrollTop; var containerBottom = containerTop + container.clientHeight; // The element is fully visible in the container return ( (eleTop >= containerTop && eleBottom <= containerBottom) || // Some part of the element is visible in the container (eleTop < containerTop && containerTop < eleBottom) || (eleTop < containerBottom && containerBottom < eleBottom) ); } if (!isVisible(element)) element.scrollIntoView(); } function openSelected() { if (!$scope.selected.length) return; $scope.open($scope.selected[0]); } function selectNext() { var entries = sort(); if (!$scope.selected.length) return $scope.selected = [ entries[0] ]; var curIndex = $scope.entries.indexOf($scope.selected[0]); if (curIndex !== -1 && curIndex < $scope.entries.length-1) { var entry = entries[++curIndex]; $scope.selected = [ entry ]; scrollInView(document.querySelector('[entry-hashkey="' + entry['$$hashKey'] + '"]')); } } function selectPrev() { var entries = sort(); if (!$scope.selected.length) return $scope.selected = [ entries.slice(-1) ]; var curIndex = $scope.entries.indexOf($scope.selected[0]); if (curIndex !== -1 && curIndex !== 0) { var entry = entries[--curIndex]; $scope.selected = [ entry ]; scrollInView(document.querySelector('[entry-hashkey="' + entry['$$hashKey'] + '"]')); } } openPath('.'); $('.file-list').on('scroll', function (event) { if (event.target.scrollTop > 10) event.target.classList.add('top-scroll-indicator'); else event.target.classList.remove('top-scroll-indicator'); }); // handle shortcuts window.addEventListener('keydown', function (event) { if ($scope.$parent.activeView !== $scope.view || $scope.$parent.viewerOpen || isModalVisible()) return; if (event.key === 'ArrowDown') { $scope.$apply(selectNext); } else if (event.key === 'ArrowUp') { $scope.$apply(selectPrev); } else if (event.key === 'Enter') { $scope.$apply(openSelected); } else if (event.key === 'Backspace') { if ($scope.view === 'fileTree') $scope.goDirectoryUp(); } }); }