diff --git a/gulpfile.js b/gulpfile.js index 4dd4449c3..a57b8d762 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -120,7 +120,7 @@ gulp.task('js-logs', function () { }); gulp.task('js-filemanager', function () { - return gulp.src(['src/js/filemanager.js', 'src/js/client.js', 'src/js/utils.js']) + return gulp.src(['src/js/filemanager.js', 'src/js/client.js', 'src/js/utils.js', 'src/components/*.js']) .pipe(ejs({ apiOrigin: apiOrigin, revision: revision, appstore: appstore }, {}, { ext: '.js' })) .pipe(sourcemaps.init()) .pipe(concat('filemanager.js', { newLine: ';' })) @@ -192,6 +192,10 @@ gulp.task('html-views', function () { return gulp.src('src/views/**/*.html').pipe(gulp.dest('dist/views')); }); +gulp.task('html-components', function () { + return gulp.src('src/components/**/*.html').pipe(gulp.dest('dist/components')); +}); + gulp.task('html-templates', function () { return gulp.src('src/templates/**/*').pipe(gulp.dest('dist/templates')); }); @@ -200,7 +204,7 @@ gulp.task('html-raw', function () { return gulp.src('src/*.html').pipe(ejs({ apiOrigin: apiOrigin, revision: revision }, {}, { ext: '.html' })).pipe(gulp.dest('dist')); }); -gulp.task('html', gulp.series(['html-views', 'html-templates', 'html-raw'])); +gulp.task('html', gulp.series(['html-views', 'html-components', 'html-templates', 'html-raw'])); // -------------- // CSS @@ -248,13 +252,14 @@ gulp.task('watch', function (done) { gulp.watch(['src/translation/*'], gulp.series(['translation'])); gulp.watch(['src/**/*.html'], gulp.series(['html'])); gulp.watch(['src/views/*.html'], gulp.series(['html-views'])); + gulp.watch(['src/components/*.html'], gulp.series(['html-components'])); gulp.watch(['src/templates/*.html'], gulp.series(['html-templates'])); gulp.watch(['scripts/createTimezones.js', 'src/js/utils.js'], gulp.series(['timezones'])); gulp.watch(['src/js/setup.js', 'src/js/client.js', 'src/js/utils.js'], gulp.series(['js-setup'])); gulp.watch(['src/js/setupdns.js', 'src/js/client.js', 'src/js/utils.js'], gulp.series(['js-setupdns'])); gulp.watch(['src/js/restore.js', 'src/js/client.js', 'src/js/utils.js'], gulp.series(['js-restore'])); gulp.watch(['src/js/logs.js', 'src/js/client.js', 'src/js/utils.js'], gulp.series(['js-logs'])); - gulp.watch(['src/js/filemanager.js', 'src/js/client.js', 'src/js/utils.js'], gulp.series(['js-filemanager'])); + gulp.watch(['src/js/filemanager.js', 'src/js/client.js', 'src/js/utils.js', 'src/components/*.js'], gulp.series(['js-filemanager'])); gulp.watch(['src/js/terminal.js', 'src/js/client.js', 'src/js/utils.js'], gulp.series(['js-terminal'])); gulp.watch(['src/js/login.js', 'src/js/utils.js'], gulp.series(['js-login'])); gulp.watch(['src/js/setupaccount.js', 'src/js/utils.js'], gulp.series(['js-setupaccount'])); diff --git a/src/components/filetree.html b/src/components/filetree.html new file mode 100644 index 000000000..412887188 --- /dev/null +++ b/src/components/filetree.html @@ -0,0 +1,231 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + +
+
+ +
+
+ + + +
+
+ +
+ + + + + + + + + + + +
 {{ 'filemanager.list.name' | tr }}{{ 'filemanager.list.owner' | tr }}{{ 'filemanager.list.size' | tr }}{{ 'filemanager.list.mtime' | tr }} 
+
+ +
+ + + + + + + + + + + + + + + + + +

{{ 'filemanager.list.empty' | tr }}
+ + + {{ entry.fileName }}{{ 'filemanager.list.symlink' | tr:{ target: entry.target } }}{{ entry.uid | prettyOwner }}{{ entry.size | prettyByteSize }}{{ entry.mtime | prettyDate }} + +
+
\ No newline at end of file diff --git a/src/components/filetree.js b/src/components/filetree.js new file mode 100644 index 000000000..3d3379ca0 --- /dev/null +++ b/src/components/filetree.js @@ -0,0 +1,1022 @@ +'use strict'; + +/* global angular */ +/* global sanitize */ + +angular.module('Application').component('filetree', { + bindings: { + backendId: '<', + backendType: '<' + }, + templateUrl: 'components/filetree.html?<%= revision %>', + controller: [ '$scope', '$translate', '$timeout', 'Client', FileTreeController ] +}); + +function FileTreeController($scope, $translate, $timeout, Client) { + $scope.backendId = this.backendId; + $scope.backendType = this.backendType; + + $scope.busy = true; + $scope.client = Client; + $scope.cwd = null; + $scope.cwdParts = []; + $scope.rootDirLabel = ''; + $scope.entries = []; + $scope.selected = []; // holds selected entries + $scope.clipboard = []; // holds cut or copied entries + $scope.clipboardCut = false; // if action is cut or copy + $scope.dropToBody = false; + $scope.view = 'fileTree'; + $scope.applicationLink = ''; + + $scope.owners = [ + { name: 'cloudron', value: 1000 }, + { name: 'www-data', value: 33 }, + { name: 'git', value: 1001 }, + { name: 'root', value: 0 } + ]; + + 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) 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; + + // 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 = []; + + $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('/') }; }); + + // 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.textEditor.show(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 + + // amend icons + result.entries.forEach(function (e) { + e.icon = 'fa-file'; + + if (e.isDirectory) e.icon = 'fa-folder'; + if (e.isSymbolicLink) e.icon = 'fa-link'; + if (e.isFile) { + var mimeType = Mimer().get(e.fileName); + var mimeGroup = mimeType.split('/')[0]; + if (mimeGroup === 'text') e.icon = 'fa-file-alt'; + if (mimeGroup === 'image') e.icon = 'fa-file-image'; + 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'; + } + }); + + $scope.entries = result.entries; + sort(); + + // call itself now that we know + $scope.cwd = parentPath; + openPath(path); + }); + } + + $scope.isSelected = function (entry) { + return $scope.selected.indexOf(entry) !== -1; + }; + + $scope.extractStatus = { + error: null, + busy: false, + fileName: '' + }; + + function extract(entry) { + var filePath = sanitize($scope.cwd + '/' + entry.fileName); + + if (entry.isDirectory) return; + + // prevent it from getting closed + $('#extractModal').modal({ + backdrop: 'static', + keyboard: false + }); + + $scope.extractStatus.fileName = entry.fileName; + $scope.extractStatus.error = null; + $scope.extractStatus.busy = true; + + Client.filesExtract($scope.backendId, $scope.backendType, filePath, function (error) { + $scope.extractStatus.busy = false; + + if (error) { + console.error(error); + $scope.extractStatus.error = $translate.instant('filemanager.extract.error', error.message); + return; + } + + $('#extractModal').modal('hide'); + + $scope.refresh(); + }); + } + + 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) { + $event.originalEvent.dataTransfer.setData('text/plain', entry.fileName); + $event.originalEvent.dataTransfer.setData('application/cloudron-filemanager', entry.fileName); + }; + + $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 (typeof entry === 'string') targetFolder = sanitize(entry); + else targetFolder = sanitize($scope.cwd + '/' + (entry && entry.isDirectory ? entry.fileName : '')); + + var dataTransfer = event.originalEvent.dataTransfer; + + // check if we have internal drag'n'drop + if (dataTransfer.getData('application/cloudron-filemanager')) { + if ($scope.selected.length === 0) return; + + var moved = 0; + + // move files + async.eachLimit($scope.selected, 5, function (entry, callback) { + var oldFilePath = sanitize($scope.cwd + '/' + entry.fileName); + var newFilePath = sanitize(targetFolder + '/' + entry.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); + + $scope.selected = []; + + // 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 uploadFiles(event.originalEvent.dataTransfer.files, targetFolder, false); + } catch (e) { + return 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 + $scope.uploadStatus.busy = true; + $scope.uploadStatus.count = 0; + + var fileList = []; + function traverseFileTree(item, path, callback) { + if (item.isFile) { + // Get file + item.file(function (file) { + fileList.push(file); + ++$scope.uploadStatus.count; + 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) { + $scope.uploadStatus.busy = false; + $scope.uploadStatus.count = 0; + + if (error) return console.error(error); + + uploadFiles(fileList, targetFolder, false); + }); + }; + + $scope.refresh = function () { + $scope.selected = []; + + Client.filesGet($scope.backendId, $scope.backendType, $scope.cwd, 'data', function (error, result) { + if (error) return Client.error(error); + + // amend icons + result.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); + 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'; + } + }); + + $scope.entries = result.entries; + sort(); + + $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.select = function ($event, entry) { + // we don't stop propagation for context menu closing, but if targets don't match we got the whole list click event + if ($event.currentTarget !== $event.target) return; + + if (!entry) { + $scope.selected = []; + return; + } + + var i = $scope.selected.indexOf(entry); + var multi = ($event.ctrlKey || $event.metaKey); + + if ($event.button === 0) { + // left click + + if (multi) { + if (i === -1) $scope.selected.push(entry); + else $scope.selected.splice(i, 1); + } else { + $scope.selected = [ entry ]; + } + } else if ($event.button === 2) { + // right click + + if (i === -1) { + if (multi) $scope.selected.push(entry); + else $scope.selected = [ entry ]; + } + } + }; + + $scope.onEntryContextMenu = function ($event, entry) { + if ($scope.selected.indexOf(entry) !== -1) return; + $scope.selected.push(entry); + }; + + $scope.actionCut = function () { + $scope.clipboard = $scope.selected.slice(); + $scope.clipboard.forEach(function (entry) { + entry.fullFilePath = sanitize($scope.cwd + '/' + entry.fileName); + }); + $scope.clipboardCut = true; + }; + + $scope.actionCopy = function () { + $scope.clipboard = $scope.selected.slice(); + $scope.clipboard.forEach(function (entry) { + entry.fullFilePath = sanitize($scope.cwd + '/' + entry.fileName); + entry.pathFrom = $scope.cwd; // we stash the original path for pasting + }); + $scope.clipboardCut = false; + }; + + function collectFiles(entry, callback) { + var pathFrom = entry.pathFrom; + + if (entry.isDirectory) { + Client.filesGet($scope.backendId, $scope.backendType, entry.fullFilePath, 'data', function (error, result) { + if (error) return callback(error); + if (!result.entries) return callback(new Error('not a folder')); + + // amend fullFilePath + result.entries.forEach(function (e) { + e.fullFilePath = sanitize(entry.fullFilePath + '/' + e.fileName); + e.pathFrom = pathFrom; // we stash the original path for pasting + }); + + var collectedFiles = []; + async.eachLimit(result.entries, 5, function (entry, callback) { + collectFiles(entry, function (error, result) { + if (error) return callback(error); + + collectedFiles = collectedFiles.concat(result); + + callback(); + }); + }, function (error) { + if (error) return callback(error); + + callback(null, collectedFiles); + }); + }); + return; + } + + callback(null, [ entry ]); + } + + $scope.actionPaste = function (destinationEntry) { + if ($scope.clipboardCut) { + // move files + async.eachLimit($scope.clipboard, 5, function (entry, callback) { + var newFilePath = sanitize($scope.cwd + '/' + ((destinationEntry && destinationEntry.isDirectory) ? destinationEntry.fileName : '') + '/' + entry.fileName); + + // TODO this will overwrite files in destination! + Client.filesRename($scope.backendId, $scope.backendType, entry.fullFilePath, newFilePath, callback); + }, function (error) { + if (error) return Client.error(error); + + // clear clipboard + $scope.clipboard = []; + + $scope.refresh(); + }); + } else { + // copy files + + // first collect all files recursively + var collectedFiles = []; + + async.eachLimit($scope.clipboard, 5, function (entry, callback) { + collectFiles(entry, function (error, result) { + if (error) return callback(error); + + collectedFiles = collectedFiles.concat(result); + + callback(); + }); + }, function (error) { + if (error) return Client.error(error); + + async.eachLimit(collectedFiles, 5, function (entry, callback) { + var newFilePath = sanitize($scope.cwd + '/' + ((destinationEntry && destinationEntry.isDirectory) ? destinationEntry.fileName : '') + '/' + entry.fullFilePath.slice(entry.pathFrom.length)); + + // This will NOT overwrite but finds a unique new name to copy to + // we prefix with a / to ensure we don't do relative target paths + Client.filesCopy($scope.backendId, $scope.backendType, entry.fullFilePath, '/' + newFilePath, callback); + }, function (error) { + if (error) return Client.error(error); + + // clear clipboard + $scope.clipboard = []; + + $scope.refresh(); + }); + }); + } + }; + + $scope.actionSelectAll = function () { + $scope.selected = $scope.entries.slice(); + }; + + $scope.uploadStatus = { + error: null, + busy: false, + fileName: '', + count: 0, + countDone: 0, + size: 0, + done: 0, + percentDone: 0, + files: [], + targetFolder: '' + }; + + function uploadFiles(files, targetFolder, overwrite) { + if (!files || !files.length) return; + + targetFolder = targetFolder || $scope.cwd; + overwrite = !!overwrite; + + // prevent it from getting closed + $('#uploadModal').modal({ + backdrop: 'static', + keyboard: false + }); + + $scope.uploadStatus.files = files; + $scope.uploadStatus.targetFolder = targetFolder; + $scope.uploadStatus.error = null; + $scope.uploadStatus.busy = true; + $scope.uploadStatus.count = files.length; + $scope.uploadStatus.countDone = 0; + $scope.uploadStatus.size = 0; + $scope.uploadStatus.sizeDone = 0; + $scope.uploadStatus.done = 0; + $scope.uploadStatus.percentDone = 0; + + for (var i = 0; i < files.length; ++i) { + $scope.uploadStatus.size += files[i].size; + } + + async.eachSeries(files, function (file, callback) { + var filePath = sanitize(targetFolder + '/' + (file.webkitRelativePath || file.name)); + + $scope.uploadStatus.fileName = file.name; + + Client.filesUpload($scope.backendId, $scope.backendType, filePath, file, overwrite, function (loaded) { + $scope.uploadStatus.percentDone = ($scope.uploadStatus.done+loaded) * 100 / $scope.uploadStatus.size; + $scope.uploadStatus.sizeDone = loaded; + }, function (error) { + if (error) return callback(error); + + $scope.uploadStatus.done += file.size; + $scope.uploadStatus.percentDone = $scope.uploadStatus.done * 100 / $scope.uploadStatus.size; + $scope.uploadStatus.countDone++; + + callback(); + }); + }, function (error) { + $scope.uploadStatus.busy = false; + + if (error && error.statusCode === 409) { + $scope.uploadStatus.error = 'exists'; + return; + } else if (error) { + console.error(error); + $scope.uploadStatus.error = 'generic'; + return; + } + + $('#uploadModal').modal('hide'); + + $scope.uploadStatus.fileName = ''; + $scope.uploadStatus.count = 0; + $scope.uploadStatus.size = 0; + $scope.uploadStatus.sizeDone = 0; + $scope.uploadStatus.done = 0; + $scope.uploadStatus.percentDone = 100; + $scope.uploadStatus.files = []; + $scope.uploadStatus.targetFolder = ''; + + $scope.refresh(); + }); + } + + $scope.retryUpload = function (overwrite) { + uploadFiles($scope.uploadStatus.files, $scope.uploadStatus.targetFolder, !!overwrite); + }; + + // file upload + $('#uploadFileInput').on('change', function (e) { uploadFiles(e.target.files || [], $scope.cwd, false); }); + $scope.onUploadFile = function () { $('#uploadFileInput').click(); }; + + // folder upload + $('#uploadFolderInput').on('change', function (e ) { uploadFiles(e.target.files || [], $scope.cwd, false); }); + $scope.onUploadFolder = function () { $('#uploadFolderInput').click(); }; + + $scope.restartBusy = false; + $scope.onRestartApp = function () { + $scope.restartBusy = true; + + function waitUntilRestarted(callback) { + Client.getApp($scope.backendId, function (error, result) { + if (error) return callback(error); + + if (result.installationState === ISTATES.INSTALLED) return callback(); + setTimeout(waitUntilRestarted.bind(null, callback), 2000); + }); + } + + Client.restartApp($scope.backendId, function (error) { + if (error) console.error('Failed to restart app.', error); + + waitUntilRestarted(function (error) { + if (error) console.error('Failed wait for restart.', error); + + $scope.restartBusy = false; + }); + }); + }; + + $scope.onRestartMail = function () { + $scope.restartBusy = true; + + function waitUntilRestarted(callback) { + Client.getService('mail', function (error, result) { + if (error) return callback(error); + + if (result.status === 'active') return callback(); + setTimeout(waitUntilRestarted.bind(null, callback), 2000); + }); + } + + Client.restartService('mail', function (error) { + if (error) console.error('Failed to restart mail.', error); + + waitUntilRestarted(function (error) { + if (error) console.error('Failed wait for restart.', error); + + $scope.restartBusy = false; + }); + }); + }; + + $scope.newDirectory = { + busy: false, + error: null, + name: '', + + show: function () { + $scope.newDirectory.error = null; + $scope.newDirectory.name = ''; + $scope.newDirectory.busy = false; + + $scope.newDirectoryForm.$setUntouched(); + $scope.newDirectoryForm.$setPristine(); + + $('#newDirectoryModal-' + $scope.$id).modal('show'); + }, + + submit: function () { + $scope.newDirectory.busy = true; + $scope.newDirectory.error = null; + + var filePath = sanitize($scope.cwd + '/' + $scope.newDirectory.name); + + Client.filesCreateDirectory($scope.backendId, $scope.backendType, filePath, function (error) { + $scope.newDirectory.busy = false; + if (error && error.statusCode === 409) return $scope.newDirectory.error = 'exists'; + if (error) return Client.error(error); + + $scope.refresh(); + + $('#newDirectoryModal-' + $scope.$id).modal('hide'); + }); + } + }; + + $scope.renameEntry = { + busy: false, + error: null, + entry: null, + newName: '', + + show: function (entry) { + $scope.renameEntry.error = null; + $scope.renameEntry.entry = entry; + $scope.renameEntry.newName = entry.fileName; + $scope.renameEntry.busy = false; + + $('#renameEntryModal-' + $scope.$id).modal('show'); + }, + + submit: function () { + $scope.renameEntry.busy = true; + + var oldFilePath = sanitize($scope.cwd + '/' + $scope.renameEntry.entry.fileName); + var newFilePath = sanitize(($scope.renameEntry.newName[0] === '/' ? '' : ($scope.cwd + '/')) + $scope.renameEntry.newName); + + Client.filesRename($scope.backendId, $scope.backendType, oldFilePath, newFilePath, function (error) { + $scope.renameEntry.busy = false; + if (error) return Client.error(error); + + $scope.refresh(); + + $('#renameEntryModal-' + $scope.$id).modal('hide'); + }); + } + }; + + $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'); + } + }; + + $scope.chownEntry = { + busy: false, + error: null, + newOwner: 0, + recursive: false, + showRecursiveOption: false, + + show: function () { + $scope.chownEntry.error = null; + // set default uid from first file + $scope.chownEntry.newOwner = $scope.selected[0].uid; + $scope.chownEntry.busy = false; + + // default for directories is recursive + $scope.chownEntry.recursive = !!$scope.selected.find(function (entry) { return entry.isDirectory; }); + $scope.chownEntry.showRecursiveOption = !!$scope.chownEntry.recursive; + + $('#chownEntryModal-' + $scope.$id).modal('show'); + }, + + submit: function () { + $scope.chownEntry.busy = true; + + async.eachLimit($scope.selected, 5, function (entry, callback) { + var filePath = sanitize($scope.cwd + '/' + entry.fileName); + + Client.filesChown($scope.backendId, $scope.backendType, filePath, $scope.chownEntry.newOwner, $scope.chownEntry.recursive, callback); + }, function (error) { + $scope.chownEntry.busy = false; + if (error) return Client.error(error); + + $scope.refresh(); + + $('#chownEntryModal-' + $scope.$id).modal('hide'); + }); + } + }; + + $scope.newFile = { + busy: false, + error: null, + name: '', + + show: function () { + $scope.newFile.error = null; + $scope.newFile.name = ''; + $scope.newFile.busy = false; + + $scope.newFileForm.$setUntouched(); + $scope.newFileForm.$setPristine(); + + $('#newFileModal-' + $scope.$id).modal('show'); + }, + + submit: function () { + $scope.newFile.busy = true; + $scope.newFile.error = null; + + var filePath = sanitize($scope.cwd + '/' + $scope.newFile.name); + + Client.filesUpload($scope.backendId, $scope.backendType, filePath, new File([], $scope.newFile.name), false, function () {}, function (error) { + $scope.newFile.busy = false; + if (error && error.statusCode === 409) return $scope.newFile.error = 'exists'; + if (error) return Client.error(error); + + $scope.refresh(); + + $('#newFileModal-' + $scope.$id).modal('hide'); + }); + } + }; + + $scope.entryRemove = { + busy: false, + error: null, + + show: function () { + $scope.entryRemove.error = null; + + $('#entryRemoveModal-' + $scope.$id).modal('show'); + }, + + submit: function () { + $scope.entryRemove.busy = true; + + async.eachLimit($scope.selected, 5, function (entry, callback) { + var filePath = sanitize($scope.cwd + '/' + entry.fileName); + + Client.filesRemove($scope.backendId, $scope.backendType, filePath, callback); + }, function (error) { + $scope.entryRemove.busy = false; + if (error) return Client.error(error); + + $scope.refresh(); + + $('#entryRemoveModal-' + $scope.$id).modal('hide'); + }); + + } + }; + + function isModalVisible() { + return !!document.getElementsByClassName('modal in').length; + } + + $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) { $scope.actionCut(); } + }, { + text: tr['filemanager.list.menu.copy'], + click: function ($itemScope, $event, entry) { $scope.actionCopy(); } + }, { + text: tr['filemanager.list.menu.paste'], + hasBottomDivider: true, + enabled: function () { return $scope.clipboard.length; }, + click: function ($itemScope, $event, entry) { $scope.actionPaste(entry); } + }, { + text: tr['filemanager.list.menu.rename'], + enabled: function () { return $scope.selected.length === 1; }, + click: function ($itemScope, $event, entry) { $scope.renameEntry.show(entry); } + }, { + text: tr['filemanager.list.menu.chown'], + click: function ($itemScope, $event, entry) { $scope.chownEntry.show(); } + }, { + text: tr['filemanager.list.menu.extract'], + displayed: function ($itemScope, $event, entry) { return !entry.isDirectory && isArchive(entry.fileName); }, + click: function ($itemScope, $event, entry) { extract(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) { $scope.entryRemove.show(); } + } + ]; + }); + + $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) { $scope.newFile.show(); } + }, { + text: tr['filemanager.toolbar.newFolder'], + click: function ($itemScope, $event) { $scope.newDirectory.show(); } + }, { + text: tr['filemanager.list.menu.paste'], + hasTopDivider: true, + hasBottomDivider: true, + enabled: function () { return $scope.clipboard.length; }, + click: function ($itemScope, $event) { $scope.actionPaste(); } + }, { + text: tr['filemanager.list.menu.selectAll'], + click: function ($itemScope, $event) { $scope.actionSelectAll(); } + } + ]; + }); + + $('.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'); + }); + + // setup all the dialog focus handling + ['newFileModal', 'newDirectoryModal', 'renameEntryModal'].forEach(function (id) { + $('#' + id).on('shown.bs.modal', function () { + $(this).find('[autofocus]:first').focus(); + }); + }); + + // selects filename (without extension) + ['renameEntryModal'].forEach(function (id) { + $('#' + id).on('shown.bs.modal', function () { + var elem = $(this).find('[autofocus]:first'); + var text = elem.val(); + elem[0].setSelectionRange(0, text.indexOf('.')); + }); + }); + + 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'] + '"]')); + } + } + + // handle save shortcuts + window.addEventListener('keydown', function (event) { + 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(); + } else if((navigator.platform.match('Mac') ? event.metaKey : event.ctrlKey) && event.key === 's') { + if ($scope.view !== 'textEditor') return; + + event.preventDefault(); + $scope.$apply($scope.textEditor.save); + } else if((navigator.platform.match('Mac') ? event.metaKey : event.ctrlKey) && event.key === 'c') { + if ($scope.view === 'textEditor') return; + if ($scope.selected.length === 0) return; + if (isModalVisible()) return; + + event.preventDefault(); + $scope.$apply($scope.actionCopy); + } else if((navigator.platform.match('Mac') ? event.metaKey : event.ctrlKey) && event.key === 'x') { + if ($scope.view === 'textEditor') return; + if ($scope.selected.length === 0) return; + if (isModalVisible()) return; + + event.preventDefault(); + $scope.$apply($scope.actionCut); + } else if((navigator.platform.match('Mac') ? event.metaKey : event.ctrlKey) && event.key === 'v') { + if ($scope.view === 'textEditor') return; + if ($scope.clipboard.length === 0) return; + if (isModalVisible()) return; + + event.preventDefault(); + $scope.$apply($scope.actionPaste); + } else if((navigator.platform.match('Mac') ? event.metaKey : event.ctrlKey) && event.key === 'a') { + if ($scope.view === 'textEditor') return; + if (isModalVisible()) return; + + event.preventDefault(); + $scope.$apply($scope.actionSelectAll); + } else if(event.key === 'Escape') { + $scope.$apply(function () { $scope.selected = []; }); + } + }); + + openPath('.'); +} diff --git a/src/filemanager.html b/src/filemanager.html index 4b4de16e5..e97c28709 100644 --- a/src/filemanager.html +++ b/src/filemanager.html @@ -1,5 +1,5 @@ - + @@ -69,144 +69,12 @@ - + {{ 'main.offline' | tr }}
{{ 'filemanager.status.restartingApp' | tr}}
- - - - - - - - - - - - - - - - - - - - -
-
-
-
-

{{ 'filemanager.notFound' | tr }}

-
-
- -
- - - - -

{{ title }}

-

{{ title }}

- - - -
- - - - - - - - - - - -
 {{ 'filemanager.list.name' | tr }}{{ 'filemanager.list.owner' | tr }}{{ 'filemanager.list.size' | tr }}{{ 'filemanager.list.mtime' | tr }} 
-
- -
- - - - - - - - - - - - - - - - - -

{{ 'filemanager.list.empty' | tr }}
- - - {{ entry.fileName }}{{ 'filemanager.list.symlink' | tr:{ target: entry.target } }}{{ entry.uid | prettyOwner }}{{ entry.size | prettyByteSize }}{{ entry.mtime | prettyDate }} - -
-
+
+
+
+

{{ 'filemanager.notFound' | tr }}

-
-
-
-
{{ textEditor.entry.fileName }}
- - -
-
-
-
+ + -
- -
+
+

+ {{ title }} + +
+ + + + + + +
+

+ +
+ +
+ +
+ +
+
+
+
+
{{ textEditor.entry.fileName }}
+ + +
+
+
+
+ + +
+ diff --git a/src/js/filemanager.js b/src/js/filemanager.js index eb293e11f..98a375189 100644 --- a/src/js/filemanager.js +++ b/src/js/filemanager.js @@ -56,50 +56,38 @@ angular.forEach( ); angular.module('ngDrag', []).directive(ngDragEventDirectives); +function sanitize(filePath) { + filePath = filePath.split('/').filter(function (a) { return !!a; }).reduce(function (a, v) { + if (v === '.'); // do nothing + else if (v === '..') a.pop(); + else a.push(v); + return a; + }, []).map(function (p) { + // small detour to safely handle special characters and whitespace + return encodeURIComponent(decodeURIComponent(p)); + }).join('/'); + + return filePath; +} + app.controller('FileManagerController', ['$scope', '$translate', '$timeout', 'Client', function ($scope, $translate, $timeout, Client) { var search = decodeURIComponent(window.location.search).slice(1).split('&').map(function (item) { return item.split('='); }).reduce(function (o, k) { o[k[0]] = k[1]; return o; }, {}); $scope.initialized = false; $scope.status = null; - $scope.busy = true; $scope.client = Client; - $scope.cwd = null; - $scope.cwdParts = []; + $scope.title = ''; $scope.id = search.id; $scope.type = search.type; - $scope.rootDirLabel = ''; - $scope.title = ''; - $scope.entries = []; - $scope.selected = []; // holds selected entries - $scope.clipboard = []; // holds cut or copied entries - $scope.clipboardCut = false; // if action is cut or copy - $scope.dropToBody = false; - $scope.sortAsc = true; - $scope.sortProperty = 'fileName'; - $scope.view = 'fileTree'; + + // move to this instead of above + $scope.backendId = search.id; + $scope.backendType = search.type; + $scope.volumes = []; - $scope.applicationLink = ''; - - $scope.owners = [ - { name: 'cloudron', value: 1000 }, - { name: 'www-data', value: 33 }, - { name: 'git', value: 1001 }, - { name: 'root', value: 0 } - ]; - - 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 + $scope.splitView = false; + // for monaco editor var LANGUAGES = []; require(['vs/editor/editor.main'], function() { LANGUAGES = monaco.languages.getLanguages(); @@ -114,741 +102,18 @@ app.controller('FileManagerController', ['$scope', '$translate', '$timeout', 'Cl return language ? language.id : ''; } - function sanitize(filePath) { - filePath = filePath.split('/').filter(function (a) { return !!a; }).reduce(function (a, v) { - if (v === '.'); // do nothing - else if (v === '..') a.pop(); - else a.push(v); - return a; - }, []).map(function (p) { - // small detour to safely handle special characters and whitespace - return encodeURIComponent(decodeURIComponent(p)); - }).join('/'); - - return filePath; - } - - 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) 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; - - // 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 = []; - - $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('/') }; }); - - // 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.id, $scope.type, path, 'open', function (error) { if (error) return Client.error(error); }); - } else if (mimeGroup === 'text' || mimeGroup === 'application') { - $scope.textEditor.show(entry); - } else { - Client.filesGet($scope.id, $scope.type, path, 'open', function (error) { if (error) return Client.error(error); }); - } - - $scope.busy = false; - } - - return; - } - - Client.filesGet($scope.id, $scope.type, parentPath, 'data', function (error, result) { - if (error) return setTimeout(function () { openPath(path); }, 2000); // try again in some time - - // amend icons - result.entries.forEach(function (e) { - e.icon = 'fa-file'; - - if (e.isDirectory) e.icon = 'fa-folder'; - if (e.isSymbolicLink) e.icon = 'fa-link'; - if (e.isFile) { - var mimeType = Mimer().get(e.fileName); - var mimeGroup = mimeType.split('/')[0]; - if (mimeGroup === 'text') e.icon = 'fa-file-alt'; - if (mimeGroup === 'image') e.icon = 'fa-file-image'; - 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'; - } - }); - - $scope.entries = result.entries; - sort(); - - // call itself now that we know - $scope.cwd = parentPath; - openPath(path); - }); - } - - $scope.isSelected = function (entry) { - return $scope.selected.indexOf(entry) !== -1; - }; - - $scope.extractStatus = { - error: null, - busy: false, - fileName: '' - }; - - function extract(entry) { - var filePath = sanitize($scope.cwd + '/' + entry.fileName); - - if (entry.isDirectory) return; - - // prevent it from getting closed - $('#extractModal').modal({ - backdrop: 'static', - keyboard: false - }); - - $scope.extractStatus.fileName = entry.fileName; - $scope.extractStatus.error = null; - $scope.extractStatus.busy = true; - - Client.filesExtract($scope.id, $scope.type, filePath, function (error) { - $scope.extractStatus.busy = false; - - if (error) { - console.error(error); - $scope.extractStatus.error = $translate.instant('filemanager.extract.error', error.message); - return; - } - - $('#extractModal').modal('hide'); - - $scope.refresh(); - }); - } - - function download(entry) { - var filePath = sanitize($scope.cwd + '/' + entry.fileName); - - Client.filesGet($scope.id, $scope.type, filePath, 'download', function (error) { - if (error) return Client.error(error); - }); - } - - $scope.dragStart = function ($event, entry) { - $event.originalEvent.dataTransfer.setData('text/plain', entry.fileName); - $event.originalEvent.dataTransfer.setData('application/cloudron-filemanager', entry.fileName); - }; - - $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 (typeof entry === 'string') targetFolder = sanitize(entry); - else targetFolder = sanitize($scope.cwd + '/' + (entry && entry.isDirectory ? entry.fileName : '')); - - var dataTransfer = event.originalEvent.dataTransfer; - - // check if we have internal drag'n'drop - if (dataTransfer.getData('application/cloudron-filemanager')) { - if ($scope.selected.length === 0) return; - - var moved = 0; - - // move files - async.eachLimit($scope.selected, 5, function (entry, callback) { - var oldFilePath = sanitize($scope.cwd + '/' + entry.fileName); - var newFilePath = sanitize(targetFolder + '/' + entry.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.id, $scope.type, oldFilePath, newFilePath, callback); - }, function (error) { - if (error) return Client.error(error); - - $scope.selected = []; - - // 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 uploadFiles(event.originalEvent.dataTransfer.files, targetFolder, false); - } catch (e) { - return 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 - $scope.uploadStatus.busy = true; - $scope.uploadStatus.count = 0; - - var fileList = []; - function traverseFileTree(item, path, callback) { - if (item.isFile) { - // Get file - item.file(function (file) { - fileList.push(file); - ++$scope.uploadStatus.count; - 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) { - $scope.uploadStatus.busy = false; - $scope.uploadStatus.count = 0; - - if (error) return console.error(error); - - uploadFiles(fileList, targetFolder, false); - }); - }; - - $scope.refresh = function () { - $scope.selected = []; - - Client.filesGet($scope.id, $scope.type, $scope.cwd, 'data', function (error, result) { - if (error) return Client.error(error); - - // amend icons - result.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); - 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.id, $scope.type, 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'; - } - }); - - $scope.entries = result.entries; - sort(); - - $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.select = function ($event, entry) { - // we don't stop propagation for context menu closing, but if targets don't match we got the whole list click event - if ($event.currentTarget !== $event.target) return; - - if (!entry) { - $scope.selected = []; - return; - } - - var i = $scope.selected.indexOf(entry); - var multi = ($event.ctrlKey || $event.metaKey); - - if ($event.button === 0) { - // left click - - if (multi) { - if (i === -1) $scope.selected.push(entry); - else $scope.selected.splice(i, 1); - } else { - $scope.selected = [ entry ]; - } - } else if ($event.button === 2) { - // right click - - if (i === -1) { - if (multi) $scope.selected.push(entry); - else $scope.selected = [ entry ]; - } - } - }; - - $scope.onEntryContextMenu = function ($event, entry) { - if ($scope.selected.indexOf(entry) !== -1) return; - $scope.selected.push(entry); - }; - - $scope.actionCut = function () { - $scope.clipboard = $scope.selected.slice(); - $scope.clipboard.forEach(function (entry) { - entry.fullFilePath = sanitize($scope.cwd + '/' + entry.fileName); - }); - $scope.clipboardCut = true; - }; - - $scope.actionCopy = function () { - $scope.clipboard = $scope.selected.slice(); - $scope.clipboard.forEach(function (entry) { - entry.fullFilePath = sanitize($scope.cwd + '/' + entry.fileName); - entry.pathFrom = $scope.cwd; // we stash the original path for pasting - }); - $scope.clipboardCut = false; - }; - - function collectFiles(entry, callback) { - var pathFrom = entry.pathFrom; - - if (entry.isDirectory) { - Client.filesGet($scope.id, $scope.type, entry.fullFilePath, 'data', function (error, result) { - if (error) return callback(error); - if (!result.entries) return callback(new Error('not a folder')); - - // amend fullFilePath - result.entries.forEach(function (e) { - e.fullFilePath = sanitize(entry.fullFilePath + '/' + e.fileName); - e.pathFrom = pathFrom; // we stash the original path for pasting - }); - - var collectedFiles = []; - async.eachLimit(result.entries, 5, function (entry, callback) { - collectFiles(entry, function (error, result) { - if (error) return callback(error); - - collectedFiles = collectedFiles.concat(result); - - callback(); - }); - }, function (error) { - if (error) return callback(error); - - callback(null, collectedFiles); - }); - }); - return; - } - - callback(null, [ entry ]); - } - - $scope.actionPaste = function (destinationEntry) { - if ($scope.clipboardCut) { - // move files - async.eachLimit($scope.clipboard, 5, function (entry, callback) { - var newFilePath = sanitize($scope.cwd + '/' + ((destinationEntry && destinationEntry.isDirectory) ? destinationEntry.fileName : '') + '/' + entry.fileName); - - // TODO this will overwrite files in destination! - Client.filesRename($scope.id, $scope.type, entry.fullFilePath, newFilePath, callback); - }, function (error) { - if (error) return Client.error(error); - - // clear clipboard - $scope.clipboard = []; - - $scope.refresh(); - }); - } else { - // copy files - - // first collect all files recursively - var collectedFiles = []; - - async.eachLimit($scope.clipboard, 5, function (entry, callback) { - collectFiles(entry, function (error, result) { - if (error) return callback(error); - - collectedFiles = collectedFiles.concat(result); - - callback(); - }); - }, function (error) { - if (error) return Client.error(error); - - async.eachLimit(collectedFiles, 5, function (entry, callback) { - var newFilePath = sanitize($scope.cwd + '/' + ((destinationEntry && destinationEntry.isDirectory) ? destinationEntry.fileName : '') + '/' + entry.fullFilePath.slice(entry.pathFrom.length)); - - // This will NOT overwrite but finds a unique new name to copy to - // we prefix with a / to ensure we don't do relative target paths - Client.filesCopy($scope.id, $scope.type, entry.fullFilePath, '/' + newFilePath, callback); - }, function (error) { - if (error) return Client.error(error); - - // clear clipboard - $scope.clipboard = []; - - $scope.refresh(); - }); - }); - } - }; - - $scope.actionSelectAll = function () { - $scope.selected = $scope.entries.slice(); - }; - - $scope.uploadStatus = { - error: null, - busy: false, - fileName: '', - count: 0, - countDone: 0, - size: 0, - done: 0, - percentDone: 0, - files: [], - targetFolder: '' - }; - - function uploadFiles(files, targetFolder, overwrite) { - if (!files || !files.length) return; - - targetFolder = targetFolder || $scope.cwd; - overwrite = !!overwrite; - - // prevent it from getting closed - $('#uploadModal').modal({ - backdrop: 'static', - keyboard: false - }); - - $scope.uploadStatus.files = files; - $scope.uploadStatus.targetFolder = targetFolder; - $scope.uploadStatus.error = null; - $scope.uploadStatus.busy = true; - $scope.uploadStatus.count = files.length; - $scope.uploadStatus.countDone = 0; - $scope.uploadStatus.size = 0; - $scope.uploadStatus.sizeDone = 0; - $scope.uploadStatus.done = 0; - $scope.uploadStatus.percentDone = 0; - - for (var i = 0; i < files.length; ++i) { - $scope.uploadStatus.size += files[i].size; - } - - async.eachSeries(files, function (file, callback) { - var filePath = sanitize(targetFolder + '/' + (file.webkitRelativePath || file.name)); - - $scope.uploadStatus.fileName = file.name; - - Client.filesUpload($scope.id, $scope.type, filePath, file, overwrite, function (loaded) { - $scope.uploadStatus.percentDone = ($scope.uploadStatus.done+loaded) * 100 / $scope.uploadStatus.size; - $scope.uploadStatus.sizeDone = loaded; - }, function (error) { - if (error) return callback(error); - - $scope.uploadStatus.done += file.size; - $scope.uploadStatus.percentDone = $scope.uploadStatus.done * 100 / $scope.uploadStatus.size; - $scope.uploadStatus.countDone++; - - callback(); - }); - }, function (error) { - $scope.uploadStatus.busy = false; - - if (error && error.statusCode === 409) { - $scope.uploadStatus.error = 'exists'; - return; - } else if (error) { - console.error(error); - $scope.uploadStatus.error = 'generic'; - return; - } - - $('#uploadModal').modal('hide'); - - $scope.uploadStatus.fileName = ''; - $scope.uploadStatus.count = 0; - $scope.uploadStatus.size = 0; - $scope.uploadStatus.sizeDone = 0; - $scope.uploadStatus.done = 0; - $scope.uploadStatus.percentDone = 100; - $scope.uploadStatus.files = []; - $scope.uploadStatus.targetFolder = ''; - - $scope.refresh(); - }); - } - - $scope.retryUpload = function (overwrite) { - uploadFiles($scope.uploadStatus.files, $scope.uploadStatus.targetFolder, !!overwrite); - }; - - // file upload - $('#uploadFileInput').on('change', function (e) { uploadFiles(e.target.files || [], $scope.cwd, false); }); - $scope.onUploadFile = function () { $('#uploadFileInput').click(); }; - - // folder upload - $('#uploadFolderInput').on('change', function (e ) { uploadFiles(e.target.files || [], $scope.cwd, false); }); - $scope.onUploadFolder = function () { $('#uploadFolderInput').click(); }; - - $scope.restartBusy = false; - $scope.onRestartApp = function () { - $scope.restartBusy = true; - - function waitUntilRestarted(callback) { - Client.getApp($scope.id, function (error, result) { - if (error) return callback(error); - - if (result.installationState === ISTATES.INSTALLED) return callback(); - setTimeout(waitUntilRestarted.bind(null, callback), 2000); - }); - } - - Client.restartApp($scope.id, function (error) { - if (error) console.error('Failed to restart app.', error); - - waitUntilRestarted(function (error) { - if (error) console.error('Failed wait for restart.', error); - - $scope.restartBusy = false; - }); - }); - }; - - $scope.onRestartMail = function () { - $scope.restartBusy = true; - - function waitUntilRestarted(callback) { - Client.getService('mail', function (error, result) { - if (error) return callback(error); - - if (result.status === 'active') return callback(); - setTimeout(waitUntilRestarted.bind(null, callback), 2000); - }); - } - - Client.restartService('mail', function (error) { - if (error) console.error('Failed to restart mail.', error); - - waitUntilRestarted(function (error) { - if (error) console.error('Failed wait for restart.', error); - - $scope.restartBusy = false; - }); - }); - }; - - $scope.newDirectory = { - busy: false, - error: null, - name: '', - - show: function () { - $scope.newDirectory.error = null; - $scope.newDirectory.name = ''; - $scope.newDirectory.busy = false; - - $scope.newDirectoryForm.$setUntouched(); - $scope.newDirectoryForm.$setPristine(); - - $('#newDirectoryModal').modal('show'); - }, - - submit: function () { - $scope.newDirectory.busy = true; - $scope.newDirectory.error = null; - - var filePath = sanitize($scope.cwd + '/' + $scope.newDirectory.name); - - Client.filesCreateDirectory($scope.id, $scope.type, filePath, function (error) { - $scope.newDirectory.busy = false; - if (error && error.statusCode === 409) return $scope.newDirectory.error = 'exists'; - if (error) return Client.error(error); - - $scope.refresh(); - - $('#newDirectoryModal').modal('hide'); - }); - } - }; - - $scope.newFile = { - busy: false, - error: null, - name: '', - - show: function () { - $scope.newFile.error = null; - $scope.newFile.name = ''; - $scope.newFile.busy = false; - - $scope.newFileForm.$setUntouched(); - $scope.newFileForm.$setPristine(); - - $('#newFileModal').modal('show'); - }, - - submit: function () { - $scope.newFile.busy = true; - $scope.newFile.error = null; - - var filePath = sanitize($scope.cwd + '/' + $scope.newFile.name); - - Client.filesUpload($scope.id, $scope.type, filePath, new File([], $scope.newFile.name), false, function () {}, function (error) { - $scope.newFile.busy = false; - if (error && error.statusCode === 409) return $scope.newFile.error = 'exists'; - if (error) return Client.error(error); - - $scope.refresh(); - - $('#newFileModal').modal('hide'); - }); - } - }; - - $scope.renameEntry = { - busy: false, - error: null, - entry: null, - newName: '', - - show: function (entry) { - $scope.renameEntry.error = null; - $scope.renameEntry.entry = entry; - $scope.renameEntry.newName = entry.fileName; - $scope.renameEntry.busy = false; - - $('#renameEntryModal').modal('show'); - }, - - submit: function () { - $scope.renameEntry.busy = true; - - var oldFilePath = sanitize($scope.cwd + '/' + $scope.renameEntry.entry.fileName); - var newFilePath = sanitize(($scope.renameEntry.newName[0] === '/' ? '' : ($scope.cwd + '/')) + $scope.renameEntry.newName); - - Client.filesRename($scope.id, $scope.type, oldFilePath, newFilePath, function (error) { - $scope.renameEntry.busy = false; - if (error) return Client.error(error); - - $scope.refresh(); - - $('#renameEntryModal').modal('hide'); - }); - } - }; - - $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.id, $scope.type, filePath); - - $('#mediaViewerModal').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').modal('hide'); - } - }; - $scope.textEditor = { busy: false, entry: null, editor: null, unsaved: false, + visible: false, show: function (entry) { $scope.textEditor.entry = entry; $scope.textEditor.busy = false; $scope.textEditor.unsaved = false; + $scope.textEditor.visible = true; // clear model if any if ($scope.textEditor.editor && $scope.textEditor.editor.getModel()) $scope.textEditor.editor.setModel(null); @@ -900,12 +165,12 @@ app.controller('FileManagerController', ['$scope', '$translate', '$timeout', 'Cl }, close: function () { - $scope.view = 'fileTree'; + $scope.textEditor.visible = false; $('#textEditorCloseModal').modal('hide'); }, onClose: function () { - $scope.view = 'fileTree'; + $scope.textEditor.visible = false; $('#textEditorCloseModal').modal('hide'); }, @@ -921,73 +186,6 @@ app.controller('FileManagerController', ['$scope', '$translate', '$timeout', 'Cl }, }; - $scope.chownEntry = { - busy: false, - error: null, - newOwner: 0, - recursive: false, - showRecursiveOption: false, - - show: function () { - $scope.chownEntry.error = null; - // set default uid from first file - $scope.chownEntry.newOwner = $scope.selected[0].uid; - $scope.chownEntry.busy = false; - - // default for directories is recursive - $scope.chownEntry.recursive = !!$scope.selected.find(function (entry) { return entry.isDirectory; }); - $scope.chownEntry.showRecursiveOption = !!$scope.chownEntry.recursive; - - $('#chownEntryModal').modal('show'); - }, - - submit: function () { - $scope.chownEntry.busy = true; - - async.eachLimit($scope.selected, 5, function (entry, callback) { - var filePath = sanitize($scope.cwd + '/' + entry.fileName); - - Client.filesChown($scope.id, $scope.type, filePath, $scope.chownEntry.newOwner, $scope.chownEntry.recursive, callback); - }, function (error) { - $scope.chownEntry.busy = false; - if (error) return Client.error(error); - - $scope.refresh(); - - $('#chownEntryModal').modal('hide'); - }); - } - }; - - $scope.entryRemove = { - busy: false, - error: null, - - show: function () { - $scope.entryRemove.error = null; - - $('#entryRemoveModal').modal('show'); - }, - - submit: function () { - $scope.entryRemove.busy = true; - - async.eachLimit($scope.selected, 5, function (entry, callback) { - var filePath = sanitize($scope.cwd + '/' + entry.fileName); - - Client.filesRemove($scope.id, $scope.type, filePath, callback); - }, function (error) { - $scope.entryRemove.busy = false; - if (error) return Client.error(error); - - $scope.refresh(); - - $('#entryRemoveModal').modal('hide'); - }); - - } - }; - function fetchVolumesInfo(mounts) { $scope.volumes = []; @@ -1070,7 +268,7 @@ app.controller('FileManagerController', ['$scope', '$translate', '$timeout', 'Cl // now mark the Client to be ready Client.setReady(); - openPath(''); + // openPath(''); $scope.initialized = true; }); @@ -1079,195 +277,4 @@ app.controller('FileManagerController', ['$scope', '$translate', '$timeout', 'Cl } init(); - - function isModalVisible() { - return !!document.getElementsByClassName('modal in').length; - } - - $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) { $scope.actionCut(); } - }, { - text: tr['filemanager.list.menu.copy'], - click: function ($itemScope, $event, entry) { $scope.actionCopy(); } - }, { - text: tr['filemanager.list.menu.paste'], - hasBottomDivider: true, - enabled: function () { return $scope.clipboard.length; }, - click: function ($itemScope, $event, entry) { $scope.actionPaste(entry); } - }, { - text: tr['filemanager.list.menu.rename'], - enabled: function () { return $scope.selected.length === 1; }, - click: function ($itemScope, $event, entry) { $scope.renameEntry.show(entry); } - }, { - text: tr['filemanager.list.menu.chown'], - click: function ($itemScope, $event, entry) { $scope.chownEntry.show(); } - }, { - text: tr['filemanager.list.menu.extract'], - displayed: function ($itemScope, $event, entry) { return !entry.isDirectory && isArchive(entry.fileName); }, - click: function ($itemScope, $event, entry) { extract(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) { $scope.entryRemove.show(); } - } - ]; - }); - - $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) { $scope.newFile.show(); } - }, { - text: tr['filemanager.toolbar.newFolder'], - click: function ($itemScope, $event) { $scope.newDirectory.show(); } - }, { - text: tr['filemanager.list.menu.paste'], - hasTopDivider: true, - hasBottomDivider: true, - enabled: function () { return $scope.clipboard.length; }, - click: function ($itemScope, $event) { $scope.actionPaste(); } - }, { - text: tr['filemanager.list.menu.selectAll'], - click: function ($itemScope, $event) { $scope.actionSelectAll(); } - } - ]; - }); - - $('.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'); - }); - - // setup all the dialog focus handling - ['newFileModal', 'newDirectoryModal', 'renameEntryModal'].forEach(function (id) { - $('#' + id).on('shown.bs.modal', function () { - $(this).find('[autofocus]:first').focus(); - }); - }); - - // selects filename (without extension) - ['renameEntryModal'].forEach(function (id) { - $('#' + id).on('shown.bs.modal', function () { - var elem = $(this).find('[autofocus]:first'); - var text = elem.val(); - elem[0].setSelectionRange(0, text.indexOf('.')); - }); - }); - - 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'] + '"]')); - } - } - - // handle save shortcuts - window.addEventListener('keydown', function (event) { - 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(); - } else if((navigator.platform.match('Mac') ? event.metaKey : event.ctrlKey) && event.key === 's') { - if ($scope.view !== 'textEditor') return; - - event.preventDefault(); - $scope.$apply($scope.textEditor.save); - } else if((navigator.platform.match('Mac') ? event.metaKey : event.ctrlKey) && event.key === 'c') { - if ($scope.view === 'textEditor') return; - if ($scope.selected.length === 0) return; - if (isModalVisible()) return; - - event.preventDefault(); - $scope.$apply($scope.actionCopy); - } else if((navigator.platform.match('Mac') ? event.metaKey : event.ctrlKey) && event.key === 'x') { - if ($scope.view === 'textEditor') return; - if ($scope.selected.length === 0) return; - if (isModalVisible()) return; - - event.preventDefault(); - $scope.$apply($scope.actionCut); - } else if((navigator.platform.match('Mac') ? event.metaKey : event.ctrlKey) && event.key === 'v') { - if ($scope.view === 'textEditor') return; - if ($scope.clipboard.length === 0) return; - if (isModalVisible()) return; - - event.preventDefault(); - $scope.$apply($scope.actionPaste); - } else if((navigator.platform.match('Mac') ? event.metaKey : event.ctrlKey) && event.key === 'a') { - if ($scope.view === 'textEditor') return; - if (isModalVisible()) return; - - event.preventDefault(); - $scope.$apply($scope.actionSelectAll); - } else if(event.key === 'Escape') { - $scope.$apply(function () { $scope.selected = []; }); - } - }); }]); diff --git a/src/theme.scss b/src/theme.scss index ad9e10e88..c8a5476a5 100644 --- a/src/theme.scss +++ b/src/theme.scss @@ -1827,6 +1827,27 @@ tag-input { // ---------------------------- .filemanager { + .layout-root { + height: calc(100% - 30px); + } + + .filemanager-layout { + } + + .file-trees { + display: flex; + } + + .spacer { + width: 2px; + background-color: #d3d3d3; + margin: 0 10px; + } + + filetree { + // flex-basis: 50%; + width: 100%; + } .toolbar { display: flex;