require.config({
  paths: {
    angular: "../../../attachment/xwiki/FileManagerCode/DriveSheet/angular.min",
    'angular-resource': "../../../attachment/xwiki/FileManagerCode/DriveSheet/angular-resource.min",
    'angular-ui-bootstrap': "../../../attachment/xwiki/FileManagerCode/DriveSheet/ui-bootstrap-tpls.min",
    fileUploadHTML5Shim: "../../../attachment/xwiki/FileManagerCode/DriveSheet/angular-file-upload-html5-shim.min",
    fileUpload: "../../../attachment/xwiki/FileManagerCode/DriveSheet/angular-file-upload.min",
    jsTree: "../../../attachment/xwiki/FileManagerCode/DriveSheet/jstree.min",
    JobRunner: "../../../attachment/xwiki/FileManagerCode/JobRunner/jobRunner.min",
    xtree: "../../../attachment/xwiki/FileManagerCode/TreeWidget/tree.min",
    pagination: '../../../jsx/FileManagerCode/Pagination/jsx8844560853377594625.js',
    liveTable: '../../../jsx/FileManagerCode/LiveTable/jsx7009575629148991117.js',
    liveTableFilters: '../../../jsx/FileManagerCode/LiveTableFilter/jsx5375737854151753275.js',
  },
  shim: {
    angular: {
      deps: ['fileUploadHTML5Shim'],
      exports: 'angular'
    },
    'angular-resource': {
      deps: ['angular'],
      exports: 'angular'
    },
    'angular-ui-bootstrap': {
      deps: ['angular']
    },
    fileUpload: {
      deps: ['angular']
    },
    jsTree: {
      deps: ['jquery']
    }
  }
});

require(['jquery', 'angular', 'JobRunner', 'angular-resource', 'angular-ui-bootstrap', 'fileUpload', 'xtree', 'liveTable'], function($, angular, JobRunner) {
  var driveServices = angular.module('driveServices', ['ngResource']);

  var defaultParams = {
    outputSyntax: 'plain',
    space: XWiki.currentSpace,
    page: XWiki.currentPage
  };

  // The colon ':' is a special character for entity references so we use '!' instead to create the URL and then replace
  // it with ':' so that Angular can find the parameter.
  var url = new XWiki.Document('!page', '!space').getURL('get').replace(/\/!/g, '/:');

  var addGET = function(api, data) {
    api['get' + data.substr(0, 1).toUpperCase() + data.substr(1)] = {
      params: {data: data}
    }
  };

  var formToken = $('meta[name=form_token]').attr('content');

  var addPOST = function(api, action) {
    // TODO: Maybe we should submit the data as JSON (Angular's default behaviour) but for this we need to enhance the
    // JSON Velocity tool to read the request payload on the server side. We can use Jackson's raw/untyped/simple data
    // binding (see http://wiki.fasterxml.com/JacksonInFiveMinutes#A.22Raw.22_Data_Binding_Example ).
    api[action] = {
      method: 'POST',
      params: {
        action: action,
        form_token: formToken
      },
      headers: {
        'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8'
      }
    }
  }

  var createAPI = function(data, actions) {
    var api = {};

    $.each(data, function(index, value) {
      addGET(api, value);
    });

    $.each(actions, function(index, value) {
      addPOST(api, value);
    });

    return api;
  }

  driveServices.factory('Drive', ['$resource', function($resource) {
    var data = ['folders', 'allFiles', 'orphanFolders', 'orphanFiles', 'activeJobs', 'jobStatus'];
    var actions = ['createFolder', 'move', 'copy', 'delete', 'download'];
    var api = createAPI(data, actions);
    api.getActiveJobs.isArray = true;
    return $resource(url, defaultParams, api);
  }]);

  driveServices.factory('Folder', ['$resource', function($resource) {
    var data = ['files', 'folders'];
    var actions = ['createFolder', 'move', 'copy', 'delete'];
    var api = createAPI(data, actions);
    return $resource(url, defaultParams, api);
  }]);

  driveServices.factory('File', ['$resource', function($resource) {
    var data = [];
    var actions = ['label', 'move', 'copy', 'delete'];
    var api = createAPI(data, actions);
    var resource = $resource(url, defaultParams, api);
    resource.getDownloadURL = function(fileId) {
      return new XWiki.Document(fileId).getURL('get', 'action=download&form_token=' + formToken);
    };
    return resource;
  }]);

  driveServices.factory('User', ['$resource', function($resource) {
    // We use an URL dispatcher to overcome the fact that in a domain-based farm we cannot compute an entity URL on the
    // client side knowing only the entity reference. Precisely, we cannot compute the URL to access an entity from a
    // different wiki knowing only the wiki id. We need to know the domain associated with the wiki id, and the domain
    // is stored on the wiki descriptor (on the main wiki). Thus we would need to make an additional request to get the
    // entity URL. The workaround is to use a dedicated page (service) that redirects to the entity specified on the
    // request parameters.
    var urlDispatcher = new XWiki.Document('URLDispatcher', 'FileManagerCode');
    var getDocumentURL = function(wiki, space, page) {
      return urlDispatcher.getURL('get', $.param({
        entityType: 'document',
        wiki: wiki,
        space: space,
        page: page
      }));
    };

    var getAttachmentURL = function(docRef, file, queryString) {
      return urlDispatcher.getURL('get', $.param({
        entityType: 'attachment',
        wiki: docRef.wiki,
        space: docRef.space,
        page: docRef.page,
        file: file,
        queryString: queryString
      }));
    };

    var getAvatarURL = function(user) {
      if (user.avatar) {
        // Resize the avatar image on the server (use the medium avatar size as it may have been already cached on the
        // server since it is used for comments).
        return getAttachmentURL(user.reference, user.avatar, 'width=50');
      } else {
        return '../../../resources/icons/xwiki/noavatar.png';
      }
    };

    var getName = function(user) {
      var firstName = user.first_name || '';
      var lastName = user.last_name || '';
      var name = (firstName + ' ' + lastName).trim();
      if (name == '') {
        name = user.alias;
        if (user.reference.wiki != XWiki.currentWiki) {
          name += ' (' + user.reference.wiki + ')';
        }
      }
      return name;
    };

    /*
                */
    var wikiFilterQuery = 'fq=wiki:xwiki';

    var fromSolrDoc = function(solrDoc) {
      var reference = new XWiki.DocumentReference(solrDoc.wiki, solrDoc.space, solrDoc.name);
      reference = reference.relativeTo(new XWiki.WikiReference(XWiki.currentWiki));
      var user = {
        reference: {
          wiki: solrDoc.wiki,
          space: solrDoc.space,
          page: solrDoc.name
        },
        id: XWiki.Model.serialize(reference),
        alias: solrDoc.name,
        url: getDocumentURL(solrDoc.wiki, solrDoc.space, solrDoc.name)
      };
      var prefix = 'property.XWiki.XWikiUsers.';
      for (var key in solrDoc) {
        if (key.substr(0, prefix.length) == prefix) {
          var property = key.substr(prefix.length);
          if (property.substr(-2) == '__') {
            property = property.substr(0, property.length - 2);
          } else {
            var suffixPos = property.lastIndexOf('_');
            if (suffixPos > 0) {
              property = property.substr(0, suffixPos);
            }
          }
          var values = solrDoc[key];
          user[property] = (angular.isArray(values) && values.length == 1) ? values[0] : values;
        }
      }
      user.name = getName(user);
      user.avatarURL = getAvatarURL(user);
      return user;
    };

    var fromRest = function(result) {
      var reference = new XWiki.DocumentReference(result.wiki, result.space, result.pageName);
      reference = reference.relativeTo(new XWiki.WikiReference(XWiki.currentWiki));
      var user = {
        reference: {
          wiki: result.wiki,
          space: result.space,
          page: result.pageName
        },
        id: XWiki.Model.serialize(reference),
        alias: result.pageName,
        url: getDocumentURL(result.wiki, result.space, result.pageName)
      };
      angular.forEach(result.properties, function(property) {
        user[property.name] = property.value;
      })
      user.name = getName(user);
      user.avatarURL = getAvatarURL(user);
      return user;
    };

    // The colon ':' is a special character for entity references so we use '!' instead to create the URL and then
    // replace it with ':' so that Angular can find the parameter.
    var url = new XWiki.Document('!page', '!space', '!wiki').getRestURL('objects/XWiki.XWikiUsers/0')
      .replace(/\/!/g, '/:');
    var resource = $resource(url, {}, {
      get: {
        cache: true,
        transformResponse: function(response, headersGetter) {
          return fromRest(angular.fromJson(response));
        }
      },
      query: {
        params: {
          outputSyntax: 'plain',
          query: ['q=*__INPUT__*', 'fq=type:DOCUMENT', 'fq=class:XWiki.XWikiUsers', wikiFilterQuery,
            'qf=property.XWiki.XWikiUsers.last_name^10 property.XWiki.XWikiUsers.first_name^5 name^2.5'].join('\n')
        },
        url: new XWiki.Document('SolrService', 'FileManagerCode').getURL('get'),
        isArray: true,
        transformResponse: function(results, headersGetter) {
          var users = [];
          angular.forEach(angular.fromJson(results), function(result) {
            users.push(fromSolrDoc(result));
          });
          return users;
        }
      }
    });

    resource.fromId = function(id) {
      var reference = XWiki.Model.resolve(id, XWiki.EntityType.DOCUMENT);
      return fromSolrDoc({
        wiki: reference.extractReferenceValue(XWiki.EntityType.WIKI) || XWiki.currentWiki,
        space: reference.extractReferenceValue(XWiki.EntityType.SPACE) || XWiki.currentSpace,
        name: reference.name
      });
    };

    resource.getById = function(id, success, error) {
      var reference = XWiki.Model.resolve(id, XWiki.EntityType.DOCUMENT);
      return this.get({
        wiki: reference.extractReferenceValue(XWiki.EntityType.WIKI) || XWiki.currentWiki,
        space: reference.extractReferenceValue(XWiki.EntityType.SPACE) || XWiki.currentSpace,
        page: reference.name
      }, success, error);
    };

    return resource;
  }]);

  driveServices.factory('FileTree', function() {
    var createFolder = function(name, parent) {
      return {
        name: name,
        type: 'folder',
        size: 0,
        parent: parent,
        children: [],
        addFile: function(file) {
          this.children.push(file);
          var parent = this;
          while (parent) {
            parent.size += file.size;
            parent = parent.parent;
          }
        }
      };
    };

    var getPath = function(file) {
      var path = file.webkitRelativePath || '';
      return path.split('/').slice(0, -1);
    };

    var getFolder = function(parent, path) {
      if (!path.length) {
        return parent;
      } else {
        var folder;
        for (var i = 0; i < parent.children.length; i++) {
          var child = parent.children[i];
          if (child.type == 'folder' && child.name == path[0]) {
            folder = child;
            break;
          }
        }
        if (!folder) {
          folder = createFolder(path[0], parent);
          parent.children.push(folder);
        }
        return getFolder(folder, path.slice(1));
      }
    };

    return {
      fromFileList: function(files, callback) {
        var root = createFolder();
        var processing = files.length;

        var maybeCallback = function() {
          !--processing && callback(root.children);
        };

        angular.forEach(files, function(file) {
          var folder = getFolder(root, getPath(file));
          if (callback && folder == root && file.size <= 4096) {
            // Make sure the root files can be read. This is especially useful for browsers that don't support folder
            // upload: if you drop a folder the file list will contain an item that looks like a file but that cannot be
            // read. See http://hs2n.wordpress.com/2012/08/13/detecting-folders-in-html-drop-area/ .
            try {
              var reader = new FileReader();
              reader.onloadend = function (event) {
                !(event || window.event).target.error && folder.addFile(file);
                maybeCallback();
              };
              reader.readAsDataURL(file);
            } catch (error) {
              maybeCallback();
            }
          } else {
            folder.addFile(file);
            callback && maybeCallback();
          }
        });

        return root.children;
      },

      fromEntryList: function(items, callback) {
        items = items || [];
        var processing = items.length;
        var root = createFolder();

        var maybeCallback = function() {
          !--processing && callback(root.children);
        };

        var traverseFileTree = function(entry, parent) {
          if (entry.isDirectory) {
            var folder = createFolder(entry.name, parent);
            parent.children.push(folder);
            var directoryReader = entry.createReader();
            var onReadEntries = function(childEntries) {
              if (!childEntries.length) {
                maybeCallback();
              } else {
                processing += childEntries.length;
                angular.forEach(childEntries, function(childEntry) {
                  traverseFileTree(childEntry, folder);
                });
                // Keep calling readEntries() until no more results are returned.
                // See https://developer.mozilla.org/en-US/docs/Web/API/DirectoryReader#readEntries
                directoryReader.readEntries(onReadEntries, maybeCallback);
              }
            };
            directoryReader.readEntries(onReadEntries, maybeCallback);
          } else {
            entry.file(function(file) {
              parent.addFile(file);
              maybeCallback();
            }, maybeCallback);
          }
        };

        var isASCII = function(str) {
          return /^[\000-\177]*$/.test(str);
        };

        angular.forEach(items || [], function(item) {
          var entry = item.webkitGetAsEntry();
          if (entry) {
            // Fix for Chrome bug https://code.google.com/p/chromium/issues/detail?id=149735
            if (entry.isFile && !isASCII(entry.name)) {
              root.addFile(item.getAsFile());
              maybeCallback();
            } else {
              traverseFileTree(entry, root);
            }
          } else {
            maybeCallback();
          }
        });
      }
    };
  });

  driveServices.factory('DriveJob', function() {
    var jobServiceURL = XWiki.currentDocument.getURL('get', 'outputSyntax=plain');
    var jobRunner = new JobRunner({
      createStatusRequest: function(jobId) {
        return {
          url: jobServiceURL,
          data: {
            id: jobId,
            data: 'jobStatus'
          }
        };
      },
      createAnswerRequest: function(jobId, data) {
        return {
          url: jobServiceURL,
          data: $.extend({}, data, {
            id: jobId,
            action: 'answer',
            form_token: formToken
          })
        };
      }
    });

    return {
      run: function(action, data) {
        data = data || {};
        data.action = action;
        data.form_token = formToken;
        // We pass the serialized data because we want the traditional serialization mode.
        return jobRunner.run(jobServiceURL, $.param(data, true));
      },
      resume: function(jobId) {
        return jobRunner.resume(jobId);
      }
    };
  });
  var driveFilters = angular.module('driveFilters', []);

  driveFilters.filter('bytes', function() {
    return function(bytes) {
      bytes = parseFloat(bytes);
      if (isNaN(bytes) || !isFinite(bytes)) {
        return '-';
      }
      var precision = 10;
      var base = 1024;
      var units = ['bytes', 'kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
      var exponent = 0;
      if (bytes > 0) {
        exponent = Math.floor(Math.log(bytes) / Math.log(base));
      }
      var value = bytes / Math.pow(base, Math.floor(exponent));
      if (value != Math.floor(value)) {
        value = Math.floor(value * precision) / precision;
      }
      return value + ' ' + units[exponent];
    }
  });

  driveFilters.filter('mediaType', function() {
    var categories = {
      "Text":
        ['text/', 'application/xml', 'application/javascript', 'application/ecmascript',
        'application/json', 'application/x-sh', '+xml'],
      "Image": ['image/'],
      "Audio": ['audio/'],
      "Video": ['video/'],
      "Document":
        ['application/pdf', 'application/postscript', 'application/msword', 'application/vnd.ms-word.',
        'application/vnd.oasis.opendocument.text', 'application/vnd.openxmlformats-officedocument.word'],
      "Presentation":
        ['application/vnd.ms-powerpoint', 'application/vnd.oasis.opendocument.presentation',
        'application/vnd.openxmlformats-officedocument.presentation'],
      "Spreadsheet":
        ['application/vnd.ms-excel', 'application/vnd.oasis.opendocument.spreadsheet',
        'application/vnd.openxmlformats-officedocument.spreadsheet'],
      "Archive":
        ['application/zip', 'application/x-gzip', 'application/x-bzip', 'application/x-tar',
        'application/x-gtar', 'application/vnd.xara', '-archive', '-compressed', '-package', '+zip'],
      "Font": ['application/font-', 'application/x-font-']
    };

    var getCategory = function(mediaType) {
      for (var category in categories) {
        var patterns = categories[category];
        for (var i = 0; i < patterns.length; i++) {
          var pattern = patterns[i];
          if (mediaType == pattern || mediaType.substr(0, pattern.length) == pattern
              || mediaType.substring(mediaType.length - pattern.length) == pattern) {
            return category;
          }
        }
      }
    };

    return function(mediaType) {
      if (!mediaType) {
        return '-';
      }
      var category = getCategory(mediaType);
      if (category) {
        return category;
      } else {
        mediaType = mediaType.substring(mediaType.indexOf('/') + 1);
        if (mediaType.substr(0, 7) == 'x-font-') {
          // Fonts have a dedicated category so we can remove the 'x-font-' prefix.
          mediaType = mediaType.substr(7);
        } else if (mediaType.substr(0, 2) == 'x-') {
          // Non-standard media type, i.e. not registered with the Internet Assigned Numbers Authority (IANA).
          mediaType = mediaType.substr(2);
        } else if (mediaType.substr(0, 4) == 'vnd.') {
          // Vendor specific media type.
          mediaType = mediaType.substr(4);
        }
        // Many media types end with '+xml' or something similar. Let's remove this part.
        var plusIndex = mediaType.lastIndexOf('+');
        if (plusIndex > 0) {
          mediaType = mediaType.substr(0, plusIndex);
        }
        return mediaType.substr(0, 1).toUpperCase() + mediaType.substr(1);
      }
    }
  });

  driveFilters.filter('timeAgo', ['$filter', function($filter) {
    // We keep the reference time a bit (1s) in the future to prevent displaying absolutes dates for very recent events.
    var referenceTime = (new Date()).getTime() + 1000;
    // Inspired by http://momentjs.com/docs/#/displaying/fromnow/ .
    var milestones = [
      {
        range: '0 to 45 seconds',
        limit: 45,
        text: "Seconds ago"
      }, {
        range: '45 to 90 seconds',
        limit: 90,
        text: "A minute ago"
      }, {
        range: '90 seconds to 45 minutes',
        limit: 2700,
        text: "__x__ minutes ago",
        unit: 60
      }, {
        range: '45 to 90 minutes',
        limit: 5400,
        text: "An hour ago"
      }, {
        range: '90 minutes to 22 hours',
        limit: 22 * 3600,
        text: "__x__ hours ago",
        unit: 3600
      }, {
        range: '22 to 36 hours',
        limit: 36 * 3600,
        text: "A day ago"
      }, {
        range: '36 hours to 25 days',
        limit: 25 * 86400,
        text: "__x__ days ago",
        unit: 86400
      }, {
        range: '25 to 45 days',
        value: 45 * 86400,
        text: "A month ago"
      }, {
        range: '45 to 345 days',
        limit: 345 * 86400,
        text: "__x__ months ago",
        unit: 31 * 86400
      }, {
        range: '345 to 547 days (1.5 years)',
        limit: 547 * 86400,
        text: "A year ago"
      }, {
        range: '548 days+',
        text: "__x__ years ago",
        unit: 365 * 86400
      }
    ];

    return function(timestamp, dateFormat) {
      timestamp = parseInt(timestamp);
      if (isNaN(timestamp) || !isFinite(timestamp)) {
        return '-';
      }

      var now = (new Date()).getTime();
      if (now > referenceTime) {
        // The reference time is older than a second. Update it.
        // Note that we need this in order to avoid and infinite $digest loop.
        // See https://docs.angularjs.org/error/$rootScope/infdig .
        // We keep the reference time a bit (1s) in the future to prevent displaying
        // absolutes dates for very recent events.
        referenceTime = now + 1000;
      }

      var diff = referenceTime - timestamp;
      if (diff >= 0) {
        diff /= 1000;
        for (var i = 0; i < milestones.length; i++) {
          var milestone = milestones[i];
          if (diff <= milestone.limit) {
            var text = milestone.text;
            if (milestone.unit) {
              text = text.replace('__x__', Math.max(Math.floor(diff / milestone.unit), 2));
            }
            return text;
          } else if (!milestone.limit && milestone.unit) {
            return milestone.text.replace('__x__', Math.max(Math.floor(diff / milestone.unit), 2));
          }
        }
      }

      // Fall-back on the absolute date display.
      return $filter('date')(timestamp, dateFormat);
    }
  }]);

  driveFilters.filter('hide', function() {
    return function(value) {
      return '';
    }
  });

  driveFilters.filter('duration', function() {
    return function(millis) {
      var seconds = Math.floor(millis / 1000);
      if (seconds <= 1) {
        return '1s';
      } else if (seconds < 60) {
        return seconds + 's';
      } else if (seconds < 3600) {
        return Math.floor(seconds / 60) + 'm ' + Math.floor(seconds % 60) + 's';
      } else {
        return Math.floor(seconds / 3600) + 'h ' + Math.floor((seconds % 3600) / 60) + 'm';
      }
    }
  });
  var driveDirectives = angular.module('driveDirectives', ['driveServices', 'liveTable', 'angularFileUpload', 'ui.bootstrap.dropdown']);

  driveDirectives.directive('file', function() {
    var categories = {
      Text: {
        html: 'html',
        css: 'css',
        page_white_c: 'c',
        page_white_code: 'xml',
        page_white_cplusplus: ['cpp', 'c++'],
        page_white_csharp: 'cs',
        page_white_cup: 'java',
        page_white_database: 'sql',
        page_white_h: 'h',
        page_white_php: 'php',
        page_white_ruby: 'ruby',
        page_white_text: 'txt',
        script: 'js'
      },
      Image: {
        page_white_vector: 'svg',
        picture: ['jpg', 'jpeg', 'png', 'gif']
      },
      Audio: {
        music: ['ogg', 'flac', 'mp3', 'wav']
      },
      Video: {
        film: ['mp4', 'ogv', 'mk', 'avi', 'divx', 'mov'],
        page_white_flash: ['flv', 'fla', 'swf']
      },
      Document: {
        page_red: 'ps',
        page_white_acrobat: 'pdf',
        page_white_word: ['odt', 'odf', 'doc', 'docx', 'sxw', 'stw']
      },
      Presentation: {
        page_white_powerpoint: ['odp', 'ppt', 'pptx']
      },
      Spreadsheet: {
        page_white_excel: ['ods', 'xls', 'xlsx']
      },
      Archive: {
        cup: 'jar',
        'package': 'xar',
        page_white_compressed: ['bz', 'bz2', 'tbz', 'gz', 'tgz', 'rar'],
        page_white_zip: ['zip', '7z']
      },
      Font: {
        font: ['ttf', 'ttc']
      }
    };

    var getIcon = function(fileName) {
      var extensionIndex = fileName.lastIndexOf('.');
      if (extensionIndex >= 0) {
        var extension = fileName.substr(extensionIndex + 1);
        for (var category in categories) {
          var icons = categories[category];
          for (var icon in icons) {
            var extensions = icons[icon];
            if (!angular.isArray(extensions)) {
              extensions = [extensions];
            }
            for (var i = 0; i < extensions.length; i++) {
              if (extension == extensions[i]) {
                return {name: icon, type: category};
              }
            }
          }
        }
      }
      return null;
    };

    return {
      restrict: 'A',
      scope: {
        'file': '='
      },
      template: '<img ng-src="{{icon}}" alt="{{type}}" title="{{type}}" class="icon" /> '
        + '<a href="{{url}}" ng-if="url">{{file.name}}</a><span ng-if="!url">{{file.name || file.id || "-"}}</span>',
      link: function(scope, element, attrs) {
        scope.icon = "../../../resources/icons/silk/page_white.png";
        scope.type = 'Attachment';
        var icon = getIcon(scope.file.name || scope.file.id || '');
        if (icon) {
          scope.icon = scope.icon.replace('page_white', icon.name);
          scope.type = icon.type;
        }
        scope.$watch('file.id', function(newValue, oldValue) {
          scope.url = scope.file.id && scope.file.name ? new XWiki.Document(scope.file.id).getURL() : null;
        });
      }
    }
  });

  driveDirectives.directive('user',  ['User', function(User) {
    return {
      restrict: 'A',
      scope: {},
      template: '<img ng-src="{{user.avatarURL}}" alt="Avatar" class="avatar" /> <a href="{{user.url}}" title="{{user.alias}}" '
        + 'ng-if="user.name">{{user.name}}</a><span ng-if="!user.name">-</span>',
      link: function(scope, element, attrs) {
        scope.user = User.fromId(attrs.user);
        if (scope.user.name) {
          User.get(scope.user.reference, function(user) {
            scope.user = user;
          });
        }
      }
    }
  }]);

  driveDirectives.directive('paneSplitterHandler', function() {
    return {
      restrict: 'C',
      scope: {},
      link: function(scope, element, attrs) {
        var splitter = $(element).closest('.pane-splitter');
        var leftPane = $(element).prev();
        var startClientX, leftPaneWidth, splitterWidthFactor;

        var resize = function(delta) {
          var absoluteWidth = leftPaneWidth + delta;
          var width = Math.max(absoluteWidth, 0) * splitterWidthFactor;
          // The width must be between 10% and 90%.
          width = Math.min(Math.max(width, 10), 90);
          leftPane.css('width', width + '%');
        };

        splitter.on('mousemove', function (event) {
          if (!startClientX) {
            return;
          }

          var delta = event.clientX - startClientX;
          var callback = function() {
            resize(delta);
          };

          // See https://developer.mozilla.org/en-US/docs/Web/Events/resize#requestAnimationFrame
          if (window.requestAnimationFrame) {
            window.requestAnimationFrame(callback);
          } else {
            setTimeout(callback, 66);
          }
        });

        element.on('mousedown', function (event) {
          event.preventDefault();
          startClientX = event.clientX;
          leftPaneWidth = leftPane[0].offsetWidth;
          splitterWidthFactor = 100 / splitter[0].offsetWidth;
          // Make sure the cursor doesn't flicker.
          splitter.css('cursor', 'ew-resize');
        });

        $(document).on('mouseup', function (event) {
          startClientX = null;
          // Reset the cursor.
          splitter.css('cursor', '');
        });
      }
    };
  });

  driveDirectives.directive('files', ['File', function(File) {
    return {
      restrict: 'E',
      scope: {
        drive: '='
      },
      templateUrl: new XWiki.Document('DriveSheet', 'FileManagerCode').getURL('get', 'template=files&outputSyntax=plain'),
      link: function(scope, element, attrs) {
        scope.id = attrs.id || 'files';
        scope.selection = {};

        scope.getSelectedFiles = function(selection) {
          var selectedFiles = [];
          for (var fileId in selection) {
            var file = selection[fileId];
            if (selection.hasOwnProperty(fileId) && file) {
              selectedFiles.push(file);
            }
          }
          return selectedFiles;
        };

        var getPaths = function(files) {
          var paths = [];
          var folder = scope.drive.location.type == 'folder' ? scope.drive.location.id : '';
          for (var i = 0; i < files.length; i++) {
            paths.push(folder + '/' + files[i].id);
          }
          return paths;
        };

        var refreshLiveTable = function() {
          scope.source = scope.drive.getFileSource(scope.drive.location.id);
        };

        scope.backToList = function() {
          scope.drive.viewer = 'files';
          refreshLiveTable();
        }

        scope.$watch('drive.location', scope.backToList);

        var canPerformBatchOperation = function(files, action) {
          for (var i = 0; i < files.length; i++) {
            var file = files[i];
            if (!file['can' + action]) {
              return false;
            }
          }
          return files.length > 0;
        };

        scope.canCut = function(files) {
          return canPerformBatchOperation(files, 'Move');
        };
        scope.cut = function(files) {
          scope.clipboard = {
            action: 'move',
            paths: getPaths(files)
          };
        };

        scope.canCopy = function(files) {
          return canPerformBatchOperation(files, 'Copy');
        };
        scope.copy = function(files) {
          scope.clipboard = {
            action: 'copy',
            paths: getPaths(files)
          };
        };

        scope.paste = function() {
          scope.drive[scope.clipboard.action](scope.clipboard.paths, scope.drive.location.id)
            .done(refreshLiveTable);
          if (scope.clipboard.action == 'move') {
            delete scope.clipboard;
          }
        };

        scope.download = function(files) {
          if (files.length == 1) {
            window.location = File.getDownloadURL(files[0].id);
          } else {
            scope.drive.download(getPaths(files));
          }
        };

        scope.rename = function(file) {
          // Clear the selection.
          delete scope.selection[file.id];

          var selectedRows = $(element).find('.xwiki-livetable-display-body').find('input:checked').closest('tr');
          if (selectedRows.length != 1) return;
          var rowScope = angular.element(selectedRows[0]).scope();

          var backToView = function(event) {
            // 'this' is the rename text input.
            // Reset the cell width (it is set below when the rename text input is added).
            $(this).parent().css('width', '').children().show();
            $(this).remove();
          };

          var onRename = function(event) {
            // 'this' is the rename text input.
            backToView.call(this, event);
            // Don't allow slash character inside the file name and make sure the file name is really changed.
            if (this.value != file.name && this.value.indexOf('/') < 0) {
              var oldName = file.name;
              scope.drive.move(['/' + file.id], '/' + this.value)
                .done(refreshLiveTable)
                .fail(function() {
                  // Revert the rename.
                  file.name = oldName;
                  rowScope.$apply();
                });
              // Visual rename, until we get confirmation from the server.
              file.name = this.value;
              rowScope.$apply();
            }
          };

          var onKeyUp = function(event) {
            // 'this' is the rename text input;
            if (event.keyCode == 13) {
              // Enter/Return Key
              onRename.call(this, event);
            } else if (event.keyCode == 27) {
              // Escape Key
              backToView.call(this, event);
            }
          };

          var inputElement = $(document.createElement('input'))
            .prop('type', 'text')
            .val(file.name)
            .css('width', '100%')
            .one('blur', onRename)
            .keyup(onKeyUp);

          var cell = selectedRows.find('td').eq(1);
          // Make sure the column width doesn't change when we insert the rename text input.
          // 6px represents the padding which is included in clientWidth.
          cell.css('width', (cell.prop('clientWidth') - 6) + 'px')
            .children().hide()
            .end().append(inputElement);

          // Focus and select the current file name.
          inputElement.select();
        };

        scope.canDelete = function(files) {
          return canPerformBatchOperation(files, 'Delete');
        };
        scope['delete'] = function(files) {
          if (window.confirm("Are you sure you want to delete the selected files?")) {
            // Clear the selection.
            scope.selection = {};
            scope.drive['delete'](getPaths(files)).done(refreshLiveTable);
          }
        };
      }
    };
  }]);

  driveDirectives.directive('xprogress', function() {
    return {
      restrict: 'E',
      scope: {
        value: '@',
        max: '@'
      },
      template: '<div class="progress"><div class="progress-bar" role="progressbar" aria-valuemin="0" '
        + 'aria-valuemax="{{max}}" aria-valuenow="{{value}}" style="width: {{100 * value / max}}%;"></div></div>'
    };
  });

  driveDirectives.directive('fileUpload', ['$upload', function($upload) {
    var formToken = $('meta[name=form_token]').attr('content');

    var scheduleUpload = function(file, folder, uploads) {
      var upload = {
        file: file,
        path: [folder],
        loaded: 0,
        total: file.size,
        status: 'Pending',
        abort: function() {
          this.status = 'Aborted';
          this.request.abort();
          endUpload(this, uploads);
        }
      };
      uploads.pending.push(upload);
      maybePerformUpload(uploads);
    };

    var maybePerformUpload = function(uploads) {
      if (uploads.pending.length == 0 || uploads.running.length >= 4) {
        return;
      }
      var upload = uploads.pending.shift();
      uploads.running.unshift(upload);
      upload.status = 'InProgress';
      upload.start = new Date().getTime();
      var parentFolder = upload.path[upload.path.length - 1];
      upload.request = $upload.upload({
        // We need to put the parameters in the URL because if the size of the uploaded file is greater than the
        // configured maximum upload size then an exception is thrown before the parameters are read from the body of
        // the multi-part request, preventing us from detecting and handling the error on the server.
        url: new XWiki.Document(parentFolder).getURL('get', 'action=createFile&form_token=' + formToken),
        file: upload.file,
        fileFormDataName: 'filepath'
      }).progress(function(event) {
        upload.loaded = event.loaded;
        upload.total = event.total;
      }).success(function(data, status, headers, config) {
        upload.status = 'Done';
        upload.file = data;
        endUpload(upload, uploads);
      }).error(function() {
        upload.abort();
      });
    };

    var endUpload = function(upload, uploads) {
      upload.end = new Date().getTime();
      uploads.running.splice(uploads.running.indexOf(upload), 1);
      maybePerformUpload(uploads);
      uploads.finished.unshift(upload);
      if (uploads.finished.length > 6) {
        uploads.finished.splice(-1, 1);
      }
    };

    var uploadFileTree = function(node, parent, drive) {
      if (node.type == 'folder') {
        drive.createFolder(node.name, parent, function(folder) {
          angular.forEach(node.children, function(child) {
            uploadFileTree(child, folder.id, drive);
          });
        });
      } else {
        scheduleUpload(node, parent, drive.uploads);
      }
    };

    return {
      restrict: 'E',
      scope: {
        'drive': '=',
        'back': '&?'
      },
      templateUrl: new XWiki.Document('DriveSheet', 'FileManagerCode').getURL('get', 'template=fileUpload&outputSyntax=plain'),
      link: function(scope, element, attrs) {
        scope.folderSelectionSupported = typeof document.createElement('input').webkitdirectory == 'boolean';
        scope.folderSelectionEnabled = false;
        scope.dropSupported = 'draggable' in document.createElement('span');

        scope.clearUploads = function() {
          scope.drive.uploads.finished.splice(0, scope.drive.uploads.finished.length);
        };

        scope.uploadFiles = function(rootNodes) {
          angular.forEach(rootNodes, function(rootNode) {
            uploadFileTree(rootNode, scope.drive.location.id, scope.drive);
          });
        };
      }
    };
  }]);

  driveDirectives.directive('fileUploadStatus', function() {
    return {
      restrict: 'A',
      scope: {
        'upload': '=fileUploadStatus'
      },
      templateUrl: new XWiki.Document('DriveSheet', 'FileManagerCode').getURL('get', 'template=fileUploadStatus&outputSyntax=plain'),
      link: function(scope, element, attrs) {
        scope.$watch('upload.loaded', function(newValue, oldValue) {
          if (scope.upload.loaded > 0 && scope.upload.loaded < scope.upload.total) {
            scope.upload.estimatedRemainingTime = (new Date().getTime() - scope.upload.start) *
              (scope.upload.total - scope.upload.loaded) / scope.upload.loaded;
          }
        });
      }
    };
  });

  driveDirectives.directive('onFileSelect', ['FileTree', function(FileTree) {
    return {
      restrict: 'A',
      scope: {
        'onFileSelect': '&',
        'folderSelectionEnabled': '='
      },
      link: function(scope, element, attrs) {
        scope.$watch('folderSelectionEnabled', function(newValue, oldValue) {
          element.prop('webkitdirectory', scope.folderSelectionEnabled);
        });
        element.bind('change', function(event) {
          scope.onFileSelect({$files: FileTree.fromFileList(event.target.files)});
        });
      }
    };
  }]);

  driveDirectives.directive('onFileDrop', ['FileTree', function(FileTree) {
    return {
      restrict: 'A',
      scope: {
        'onFileDrop': '&'
      },
      link: function(scope, element, attrs) {
        var dragOverClass = attrs.dragOverClass || 'dragOver';
        element.bind('dragenter', function(event) {
          element.addClass(dragOverClass);
        });
        element.bind('dragover', function(event) {
          event.preventDefault();
        });
        element.bind('dragleave', function(event) {
          element.removeClass(dragOverClass);
        });
        element.bind('drop', function(event) {
          event.preventDefault();
          element.removeClass(dragOverClass);
          var dataTransfer = event.dataTransfer || event.originalEvent.dataTransfer;
          var items = dataTransfer.items;
          if (items && items.length > 0 && items[0].webkitGetAsEntry) {
            FileTree.fromEntryList(items, function(fileTree) {
              scope.onFileDrop({$files: fileTree});
            });
          } else {
            FileTree.fromFileList(dataTransfer.files, function(fileTree) {
              scope.onFileDrop({$files: fileTree});
            });
          }
        });
      }
    };
  }]);
  var driveDirectives = angular.module('driveDirectives');

  driveDirectives.directive('driveTree', ['$location', function($location) {
    var isDrive = function(node) {
      return node.id == XWiki.currentPage;
    };

    var defaultViewerByNodeType = {
      drive: 'files',
      folder: 'files',
      folders: null,
      files: 'files',
      jobs: 'jobs'
    };

    return {
      restrict: 'E',
      scope: {
        drive: '='
      },
      link: function(scope, element, attrs) {
        $(element).xtree()
        // Select the node specified on the request URL after the tree is loaded. We tried to listen to the
        // 'ready.jstree' event but it is fired before the drive child nodes are fetched. As a workaround we listen once
        // to the 'after_open.jstree' event, knowing that the drive node is the first tree node that will be opened.
        .one('after_open.jstree', function(event, data) {
          if (isDrive(data.node)) {
            // The drive node has been opened.
            data.instance.openTo($location.search()['driveNode'] || '/allFiles');
          }

        }).on('select_node.jstree deselect_node.jstree', function(event, data) {
          // Prevent nested $apply calls by scheduling the changes to the scope in a future call stack.
          // See https://docs.angularjs.org/error/$rootScope/inprog#inconsistent-api-sync-async-
          setTimeout(function() {
            scope.$apply(function() {
              if (data.selected.length != 1) {
                delete scope.drive.location;
                $location.search('driveNode', null);
              } else {
                var selectedNode = data.selected[0];
                if (!scope.drive.location || selectedNode != scope.drive.location.id) {
                  $location.search('driveNode', selectedNode);
                  scope.drive.viewer = defaultViewerByNodeType[data.node.data.type];
                  scope.drive.location = {
                    id: selectedNode,
                    type: data.node.data.type,
                    node: data.node
                  };
                }
              }
            })
          }, 0);

        // Enable/Disable context menu items before the context menu is shown.
        }).on('xtree.openContextMenu', function(event, data) {
          if (data.menu.createFolder) {
            data.menu.createFolder._disabled = !data.node.data || !data.node.data.canCreate;
          }
          if (data.menu.createFile) {
            data.menu.createFile._disabled = !data.node.data || !data.node.data.canCreate;
          }

        }).on('xtree.contextMenu.download', function(event, data) {
          var tree = $.jstree.reference(data.reference);
          scope.drive.download(tree.get_selected());
          scope.$apply();

        }).on('xtree.contextMenu.createFile', function(event, data) {
          var tree = $.jstree.reference(data.reference);
          var node = tree.get_node(data.reference);
          tree.deselect_all(true);
          tree.select_node(node);
          setTimeout(function() {
            scope.drive.viewer = 'upload';
            scope.$apply();
          }, 0);

        }).on('xtree.runJob', function(event, promise) {
          promise.progress(function(job) {
            scope.drive.updateJob(job);
            scope.$apply();
          });
        });

        scope.$on('$locationChangeSuccess', function(event) {
          // Update the selected tree node when the location changes (if the tree is loaded).
          var tree = $(element).jstree(true);
          var nodeId = $location.search()['driveNode'];
          if (tree && nodeId && tree.get_node('/allFiles') && !tree.is_selected(nodeId)) {
            tree.deselect_all();
            tree.openTo(nodeId);
          }
        });
      }
    };
  }]);
  var driveControllers = angular.module('driveControllers', []);

  driveControllers.controller('DownloadCtrl', ['$scope', function ($scope) {
    $scope.isDownloadReady = function(ready) {
      return function(job) {
        return job.request.type == 'fileManager/pack' && ((ready && job.state == 'FINISHED')
          || (!ready && job.state != 'FINISHED'));
      };
    }
  }]);
  var drive = angular.module('drive', ['driveServices', 'driveFilters', 'driveDirectives', 'driveControllers']);

  drive.controller('DriveCtrl', ['$scope', 'Drive', 'Folder', 'DriveJob', function ($scope, Drive, Folder, DriveJob) {
    var asSource = function(service) {
      return {get: service};
    };

    var updateJob = function(job) {
      var jobs = $scope.drive.jobs;
      for (var i = 0; i < jobs.length; i++) {
        if (jobs[i].id == job.id) {
          jobs[i] = job;
          return;
        }
      }
      jobs.unshift(job);
      if (jobs.length > 100) {
        jobs.splice(-1, 1);
      }
    };

    var runJob = function(type, data) {
      $scope.drive.readOnly = true;
      // We have to schedule a scope update immediately after the state of the promise changes.
      var scheduleScopeUpdate = function() {
        setTimeout(function() {
          $scope.$apply();
        }, 0);
      };
      return DriveJob.run(type, data)
        .progress(function(job) {
          updateJob(job);
          scheduleScopeUpdate();
        })
        .always(function() {
          $scope.drive.readOnly = false;
          scheduleScopeUpdate();
        });
    };

    $scope.drive = {
      readOnly: false,

      location: {
        id: null,
        type: null
      },

      viewer: null,

      jobs: [],
      updateJob: updateJob,

      uploads: {
        pending: [],
        running: [],
        finished: []
      },

      getFileSource: function(locationId) {
        if (locationId == '/allFiles') {
          return asSource(Drive.getAllFiles);
        } else if (locationId == '/orphanFiles') {
          return asSource(Drive.getOrphanFiles);
        } else {
          var folder = Folder.bind({page: locationId});
          return asSource($.proxy(folder.getFiles, folder));
        }
      },

      createFolder: function(name, parent, onSuccess, onError) {
        Drive.createFolder({}, $.param({
          name: name,
          parent: parent
        }), onSuccess, onError);
      },

      move: function(paths, destination) {
        return runJob('move', {
          path: paths,
          destination: destination
        });
      },

      copy: function(paths, destination) {
        return runJob('copy', {
          path: paths,
          destination: destination
        });
      },

      'delete': function(paths) {
        return runJob('delete', {path: paths});
      },

      download: function(paths, outputFileName) {
        this.viewer = 'downloads';
        return runJob('download', {path: paths, name: outputFileName})
          .done(function(job) {
            window.location = job.request.outputFile.url;
          });
      }
    };
  }]);

  angular.bootstrap($('#drive')[0], ['drive']);
});
