| // Copyright (c) 2012 The Chromium Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| 'use strict'; |
| |
| // If directory files changes too often, don't rescan directory more than once |
| // per specified interval |
| var SIMULTANEOUS_RESCAN_INTERVAL = 1000; |
| // Used for operations that require almost instant rescan. |
| var SHORT_RESCAN_INTERVAL = 100; |
| |
| /** |
| * Data model of the file manager. |
| * |
| * @param {boolean} singleSelection True if only one file could be selected |
| * at the time. |
| * @param {FileFilter} fileFilter Instance of FileFilter. |
| * @param {FileWatcher} fileWatcher Instance of FileWatcher. |
| * @param {MetadataCache} metadataCache The metadata cache service. |
| * @param {VolumeManagerWrapper} volumeManager The volume manager. |
| * @param {boolean} showSpecialSearchRoots True if special-search roots are |
| * available. They should be hidden for the dialogs to save files. |
| * @constructor |
| */ |
| function DirectoryModel(singleSelection, fileFilter, fileWatcher, |
| metadataCache, volumeManager, |
| showSpecialSearchRoots) { |
| this.fileListSelection_ = singleSelection ? |
| new cr.ui.ListSingleSelectionModel() : new cr.ui.ListSelectionModel(); |
| |
| this.runningScan_ = null; |
| this.pendingScan_ = null; |
| this.rescanTime_ = null; |
| this.scanFailures_ = 0; |
| this.showSpecialSearchRoots_ = showSpecialSearchRoots; |
| |
| this.fileFilter_ = fileFilter; |
| this.fileFilter_.addEventListener('changed', |
| this.onFilterChanged_.bind(this)); |
| |
| this.currentFileListContext_ = new FileListContext( |
| fileFilter, metadataCache); |
| this.currentDirContents_ = |
| DirectoryContents.createForDirectory(this.currentFileListContext_, null); |
| |
| this.volumeManager_ = volumeManager; |
| this.volumeManager_.volumeInfoList.addEventListener( |
| 'splice', this.onVolumeInfoListUpdated_.bind(this)); |
| |
| this.fileWatcher_ = fileWatcher; |
| this.fileWatcher_.addEventListener( |
| 'watcher-directory-changed', |
| this.onWatcherDirectoryChanged_.bind(this)); |
| } |
| |
| /** |
| * Fake entry to be used in currentDirEntry_ when current directory is |
| * unmounted DRIVE. TODO(haruki): Support "drive/root" and "drive/other". |
| * @type {Object} |
| * @const |
| * @private |
| */ |
| DirectoryModel.fakeDriveEntry_ = { |
| fullPath: RootDirectory.DRIVE + '/' + DriveSubRootDirectory.ROOT, |
| isDirectory: true |
| }; |
| |
| /** |
| * Fake entry representing a psuedo directory, which contains Drive files |
| * available offline. This entry works as a trigger to start a search for |
| * offline files. |
| * @type {Object} |
| * @const |
| * @private |
| */ |
| DirectoryModel.fakeDriveOfflineEntry_ = { |
| fullPath: RootDirectory.DRIVE_OFFLINE, |
| isDirectory: true |
| }; |
| |
| /** |
| * Fake entry representing a pseudo directory, which contains shared-with-me |
| * Drive files. This entry works as a trigger to start a search for |
| * shared-with-me files. |
| * @type {Object} |
| * @const |
| * @private |
| */ |
| DirectoryModel.fakeDriveSharedWithMeEntry_ = { |
| fullPath: RootDirectory.DRIVE_SHARED_WITH_ME, |
| isDirectory: true |
| }; |
| |
| /** |
| * Fake entry representing a pseudo directory, which contains Drive files |
| * accessed recently. This entry works as a trigger to start a metadata search |
| * implemented as DirectoryContentsDriveRecent. |
| * DirectoryModel is responsible to start the search when the UI tries to open |
| * this fake entry (e.g. changeDirectory()). |
| * @type {Object} |
| * @const |
| * @private |
| */ |
| DirectoryModel.fakeDriveRecentEntry_ = { |
| fullPath: RootDirectory.DRIVE_RECENT, |
| isDirectory: true |
| }; |
| |
| /** |
| * List of fake entries for special searches. |
| * |
| * @type {Array.<Object>} |
| * @const |
| */ |
| DirectoryModel.FAKE_DRIVE_SPECIAL_SEARCH_ENTRIES = [ |
| DirectoryModel.fakeDriveSharedWithMeEntry_, |
| DirectoryModel.fakeDriveRecentEntry_, |
| DirectoryModel.fakeDriveOfflineEntry_ |
| ]; |
| |
| /** |
| * DirectoryModel extends cr.EventTarget. |
| */ |
| DirectoryModel.prototype.__proto__ = cr.EventTarget.prototype; |
| |
| /** |
| * Disposes the directory model by removing file watchers. |
| */ |
| DirectoryModel.prototype.dispose = function() { |
| this.fileWatcher_.dispose(); |
| }; |
| |
| /** |
| * @return {cr.ui.ArrayDataModel} Files in the current directory. |
| */ |
| DirectoryModel.prototype.getFileList = function() { |
| return this.currentFileListContext_.fileList; |
| }; |
| |
| /** |
| * Sort the file list. |
| * @param {string} sortField Sort field. |
| * @param {string} sortDirection "asc" or "desc". |
| */ |
| DirectoryModel.prototype.sortFileList = function(sortField, sortDirection) { |
| this.getFileList().sort(sortField, sortDirection); |
| }; |
| |
| /** |
| * @return {cr.ui.ListSelectionModel|cr.ui.ListSingleSelectionModel} Selection |
| * in the fileList. |
| */ |
| DirectoryModel.prototype.getFileListSelection = function() { |
| return this.fileListSelection_; |
| }; |
| |
| /** |
| * @return {RootType} Root type of current root. |
| */ |
| DirectoryModel.prototype.getCurrentRootType = function() { |
| var entry = this.currentDirContents_.getDirectoryEntry(); |
| return PathUtil.getRootType(entry ? entry.fullPath : ''); |
| }; |
| |
| /** |
| * @return {string} Root path. |
| */ |
| DirectoryModel.prototype.getCurrentRootPath = function() { |
| var entry = this.currentDirContents_.getDirectoryEntry(); |
| return entry ? PathUtil.getRootPath(entry.fullPath) : ''; |
| }; |
| |
| /** |
| * @return {string} Filesystem URL representing the mountpoint for the current |
| * contents. |
| */ |
| DirectoryModel.prototype.getCurrentMountPointUrl = function() { |
| var rootPath = this.getCurrentRootPath(); |
| // Special search roots are just showing a search results from DRIVE. |
| if (PathUtil.getRootType(rootPath) == RootType.DRIVE || |
| PathUtil.isSpecialSearchRoot(rootPath)) |
| return util.makeFilesystemUrl(RootDirectory.DRIVE); |
| |
| return util.makeFilesystemUrl(rootPath); |
| }; |
| |
| /** |
| * @return {boolean} on True if offline. |
| */ |
| DirectoryModel.prototype.isDriveOffline = function() { |
| var connection = this.volumeManager_.getDriveConnectionState(); |
| return connection.type == util.DriveConnectionType.OFFLINE; |
| }; |
| |
| /** |
| * TODO(haruki): This actually checks the current root. Fix the method name and |
| * related code. |
| * @return {boolean} True if the root for the current directory is read only. |
| */ |
| DirectoryModel.prototype.isReadOnly = function() { |
| return this.isPathReadOnly(this.getCurrentRootPath()); |
| }; |
| |
| /** |
| * @return {boolean} True if the a scan is active. |
| */ |
| DirectoryModel.prototype.isScanning = function() { |
| return this.currentDirContents_.isScanning(); |
| }; |
| |
| /** |
| * @return {boolean} True if search is in progress. |
| */ |
| DirectoryModel.prototype.isSearching = function() { |
| return this.currentDirContents_.isSearch(); |
| }; |
| |
| /** |
| * @param {string} path Path to check. |
| * @return {boolean} True if the |path| is read only. |
| */ |
| DirectoryModel.prototype.isPathReadOnly = function(path) { |
| // TODO(hidehiko): Migrate this into VolumeInfo. |
| switch (PathUtil.getRootType(path)) { |
| case RootType.REMOVABLE: |
| var volumeInfo = this.volumeManager_.getVolumeInfo( |
| PathUtil.getRootPath(path)); |
| // Returns true if the volume is actually read only, or if an error |
| // is found during the mounting. |
| // TODO(hidehiko): Remove "error" check here, by removing error'ed volume |
| // info from VolumeManager. |
| return volumeInfo && (volumeInfo.isReadOnly || !!volumeInfo.error); |
| case RootType.ARCHIVE: |
| return true; |
| case RootType.DOWNLOADS: |
| return false; |
| case RootType.DRIVE: |
| // TODO(haruki): Maybe add DRIVE_OFFLINE as well to allow renaming in the |
| // offline tab. |
| return this.isDriveOffline(); |
| default: |
| return true; |
| } |
| }; |
| |
| /** |
| * Updates the selection by using the updateFunc and publish the change event. |
| * If updateFunc returns true, it force to dispatch the change event even if the |
| * selection index is not changed. |
| * |
| * @param {cr.ui.ListSelectionModel|cr.ui.ListSingleSelectionModel} selection |
| * Selection to be updated. |
| * @param {function(): boolean} updateFunc Function updating the selection. |
| * @private |
| */ |
| DirectoryModel.prototype.updateSelectionAndPublishEvent_ = |
| function(selection, updateFunc) { |
| // Begin change. |
| selection.beginChange(); |
| |
| // If dispatchNeeded is true, we should ensure the change event is |
| // dispatched. |
| var dispatchNeeded = updateFunc(); |
| |
| // Check if the change event is dispatched in the endChange function |
| // or not. |
| var eventDispatched = function() { dispatchNeeded = false; }; |
| selection.addEventListener('change', eventDispatched); |
| selection.endChange(); |
| selection.removeEventListener('change', eventDispatched); |
| |
| // If the change event have been already dispatched, dispatchNeeded is false. |
| if (dispatchNeeded) { |
| var event = new Event('change'); |
| // The selection status (selected or not) is not changed because |
| // this event is caused by the change of selected item. |
| event.changes = []; |
| selection.dispatchEvent(event); |
| } |
| }; |
| |
| /** |
| * Invoked when a change in the directory is detected by the watcher. |
| * @private |
| */ |
| DirectoryModel.prototype.onWatcherDirectoryChanged_ = function() { |
| this.rescanSoon(); |
| }; |
| |
| /** |
| * Invoked when filters are changed. |
| * @private |
| */ |
| DirectoryModel.prototype.onFilterChanged_ = function() { |
| this.rescanSoon(); |
| }; |
| |
| /** |
| * Returns the filter. |
| * @return {FileFilter} The file filter. |
| */ |
| DirectoryModel.prototype.getFileFilter = function() { |
| return this.fileFilter_; |
| }; |
| |
| /** |
| * @return {DirectoryEntry} Current directory. |
| */ |
| DirectoryModel.prototype.getCurrentDirEntry = function() { |
| return this.currentDirContents_.getDirectoryEntry(); |
| }; |
| |
| /** |
| * @return {string} URL of the current directory. or null if unavailable. |
| */ |
| DirectoryModel.prototype.getCurrentDirectoryURL = function() { |
| var entry = this.currentDirContents_.getDirectoryEntry(); |
| if (!entry) |
| return null; |
| if (entry === DirectoryModel.fakeDriveOfflineEntry_) |
| return util.makeFilesystemUrl(entry.fullPath); |
| return entry.toURL(); |
| }; |
| |
| /** |
| * @return {string} Path for the current directory, or empty string if the |
| * current directory is not yet set. |
| */ |
| DirectoryModel.prototype.getCurrentDirPath = function() { |
| var entry = this.currentDirContents_.getDirectoryEntry(); |
| return entry ? entry.fullPath : ''; |
| }; |
| |
| /** |
| * @return {Array.<string>} File paths of selected files. |
| * @private |
| */ |
| DirectoryModel.prototype.getSelectedPaths_ = function() { |
| var indexes = this.fileListSelection_.selectedIndexes; |
| var fileList = this.getFileList(); |
| if (fileList) { |
| return indexes.map(function(i) { |
| return fileList.item(i).fullPath; |
| }); |
| } |
| return []; |
| }; |
| |
| /** |
| * @param {Array.<string>} value List of file paths of selected files. |
| * @private |
| */ |
| DirectoryModel.prototype.setSelectedPaths_ = function(value) { |
| var indexes = []; |
| var fileList = this.getFileList(); |
| |
| var safeKey = function(key) { |
| // The transformation must: |
| // 1. Never generate a reserved name ('__proto__') |
| // 2. Keep different keys different. |
| return '#' + key; |
| }; |
| |
| var hash = {}; |
| |
| for (var i = 0; i < value.length; i++) |
| hash[safeKey(value[i])] = 1; |
| |
| for (var i = 0; i < fileList.length; i++) { |
| if (hash.hasOwnProperty(safeKey(fileList.item(i).fullPath))) |
| indexes.push(i); |
| } |
| this.fileListSelection_.selectedIndexes = indexes; |
| }; |
| |
| /** |
| * @return {string} Lead item file path. |
| * @private |
| */ |
| DirectoryModel.prototype.getLeadPath_ = function() { |
| var index = this.fileListSelection_.leadIndex; |
| return index >= 0 && this.getFileList().item(index).fullPath; |
| }; |
| |
| /** |
| * @param {string} value The name of new lead index. |
| * @private |
| */ |
| DirectoryModel.prototype.setLeadPath_ = function(value) { |
| var fileList = this.getFileList(); |
| for (var i = 0; i < fileList.length; i++) { |
| if (fileList.item(i).fullPath === value) { |
| this.fileListSelection_.leadIndex = i; |
| return; |
| } |
| } |
| }; |
| |
| /** |
| * Schedule rescan with short delay. |
| */ |
| DirectoryModel.prototype.rescanSoon = function() { |
| this.scheduleRescan(SHORT_RESCAN_INTERVAL); |
| }; |
| |
| /** |
| * Schedule rescan with delay. Designed to handle directory change |
| * notification. |
| */ |
| DirectoryModel.prototype.rescanLater = function() { |
| this.scheduleRescan(SIMULTANEOUS_RESCAN_INTERVAL); |
| }; |
| |
| /** |
| * Schedule rescan with delay. If another rescan has been scheduled does |
| * nothing. File operation may cause a few notifications what should cause |
| * a single refresh. |
| * @param {number} delay Delay in ms after which the rescan will be performed. |
| */ |
| DirectoryModel.prototype.scheduleRescan = function(delay) { |
| if (this.rescanTime_) { |
| if (this.rescanTime_ <= Date.now() + delay) |
| return; |
| clearTimeout(this.rescanTimeoutId_); |
| } |
| |
| this.rescanTime_ = Date.now() + delay; |
| this.rescanTimeoutId_ = setTimeout(this.rescan.bind(this), delay); |
| }; |
| |
| /** |
| * Cancel a rescan on timeout if it is scheduled. |
| * @private |
| */ |
| DirectoryModel.prototype.clearRescanTimeout_ = function() { |
| this.rescanTime_ = null; |
| if (this.rescanTimeoutId_) { |
| clearTimeout(this.rescanTimeoutId_); |
| this.rescanTimeoutId_ = null; |
| } |
| }; |
| |
| /** |
| * Rescan current directory. May be called indirectly through rescanLater or |
| * directly in order to reflect user action. Will first cache all the directory |
| * contents in an array, then seamlessly substitute the fileList contents, |
| * preserving the select element etc. |
| * |
| * This should be to scan the contents of current directory (or search). |
| */ |
| DirectoryModel.prototype.rescan = function() { |
| this.clearRescanTimeout_(); |
| if (this.runningScan_) { |
| this.pendingRescan_ = true; |
| return; |
| } |
| |
| var dirContents = this.currentDirContents_.clone(); |
| dirContents.setFileList([]); |
| |
| var successCallback = (function() { |
| this.replaceDirectoryContents_(dirContents); |
| cr.dispatchSimpleEvent(this, 'rescan-completed'); |
| }).bind(this); |
| |
| this.scan_(dirContents, |
| successCallback, function() {}, function() {}, function() {}); |
| }; |
| |
| /** |
| * Run scan on the current DirectoryContents. The active fileList is cleared and |
| * the entries are added directly. |
| * |
| * This should be used when changing directory or initiating a new search. |
| * |
| * @param {DirectoryContentes} newDirContents New DirectoryContents instance to |
| * replace currentDirContents_. |
| * @param {function()=} opt_callback Called on success. |
| * @private |
| */ |
| DirectoryModel.prototype.clearAndScan_ = function(newDirContents, |
| opt_callback) { |
| if (this.currentDirContents_.isScanning()) |
| this.currentDirContents_.cancelScan(); |
| this.currentDirContents_ = newDirContents; |
| this.clearRescanTimeout_(); |
| |
| if (this.pendingScan_) |
| this.pendingScan_ = false; |
| |
| if (this.runningScan_) { |
| if (this.runningScan_.isScanning()) |
| this.runningScan_.cancelScan(); |
| this.runningScan_ = null; |
| } |
| |
| var onDone = function() { |
| cr.dispatchSimpleEvent(this, 'scan-completed'); |
| if (opt_callback) |
| opt_callback(); |
| }.bind(this); |
| |
| var onFailed = function() { |
| cr.dispatchSimpleEvent(this, 'scan-failed'); |
| }.bind(this); |
| |
| var onUpdated = function() { |
| cr.dispatchSimpleEvent(this, 'scan-updated'); |
| }.bind(this); |
| |
| var onCancelled = function() { |
| cr.dispatchSimpleEvent(this, 'scan-cancelled'); |
| }.bind(this); |
| |
| // Clear the table, and start scanning. |
| cr.dispatchSimpleEvent(this, 'scan-started'); |
| var fileList = this.getFileList(); |
| fileList.splice(0, fileList.length); |
| this.scan_(this.currentDirContents_, |
| onDone, onFailed, onUpdated, onCancelled); |
| }; |
| |
| /** |
| * Perform a directory contents scan. Should be called only from rescan() and |
| * clearAndScan_(). |
| * |
| * @param {DirectoryContents} dirContents DirectoryContents instance on which |
| * the scan will be run. |
| * @param {function()} successCallback Callback on success. |
| * @param {function()} failureCallback Callback on failure. |
| * @param {function()} updatedCallback Callback on update. Only on the last |
| * update, {@code successCallback} is called instead of this. |
| * @param {function()} cancelledCallback Callback on cancel. |
| * @private |
| */ |
| DirectoryModel.prototype.scan_ = function( |
| dirContents, |
| successCallback, failureCallback, updatedCallback, cancelledCallback) { |
| var self = this; |
| |
| /** |
| * Runs pending scan if there is one. |
| * |
| * @return {boolean} Did pending scan exist. |
| */ |
| var maybeRunPendingRescan = function() { |
| if (self.pendingRescan_) { |
| self.rescanSoon(); |
| self.pendingRescan_ = false; |
| return true; |
| } |
| return false; |
| }; |
| |
| var onSuccess = function() { |
| self.runningScan_ = null; |
| successCallback(); |
| self.scanFailures_ = 0; |
| maybeRunPendingRescan(); |
| }; |
| |
| var onFailure = function() { |
| self.runningScan_ = null; |
| self.scanFailures_++; |
| failureCallback(); |
| |
| if (maybeRunPendingRescan()) |
| return; |
| |
| if (self.scanFailures_ <= 1) |
| self.rescanLater(); |
| }; |
| |
| this.runningScan_ = dirContents; |
| |
| dirContents.addEventListener('scan-completed', onSuccess); |
| dirContents.addEventListener('scan-updated', updatedCallback); |
| dirContents.addEventListener('scan-failed', onFailure); |
| dirContents.addEventListener('scan-cancelled', cancelledCallback); |
| dirContents.scan(); |
| }; |
| |
| /** |
| * @param {DirectoryContents} dirContents DirectoryContents instance. |
| * @private |
| */ |
| DirectoryModel.prototype.replaceDirectoryContents_ = function(dirContents) { |
| cr.dispatchSimpleEvent(this, 'begin-update-files'); |
| this.updateSelectionAndPublishEvent_(this.fileListSelection_, function() { |
| var selectedPaths = this.getSelectedPaths_(); |
| var selectedIndices = this.fileListSelection_.selectedIndexes; |
| |
| // Restore leadIndex in case leadName no longer exists. |
| var leadIndex = this.fileListSelection_.leadIndex; |
| var leadPath = this.getLeadPath_(); |
| |
| this.currentDirContents_ = dirContents; |
| dirContents.replaceContextFileList(); |
| |
| this.setSelectedPaths_(selectedPaths); |
| this.fileListSelection_.leadIndex = leadIndex; |
| this.setLeadPath_(leadPath); |
| |
| // If nothing is selected after update, then select file next to the |
| // latest selection |
| var forceChangeEvent = false; |
| if (this.fileListSelection_.selectedIndexes.length == 0 && |
| selectedIndices.length != 0) { |
| var maxIdx = Math.max.apply(null, selectedIndices); |
| this.selectIndex(Math.min(maxIdx - selectedIndices.length + 2, |
| this.getFileList().length) - 1); |
| forceChangeEvent = true; |
| } |
| return forceChangeEvent; |
| }.bind(this)); |
| |
| cr.dispatchSimpleEvent(this, 'end-update-files'); |
| }; |
| |
| /** |
| * Callback when an entry is changed. |
| * @param {util.EntryChangedKind} kind How the entry is changed. |
| * @param {Entry} entry The changed entry. |
| */ |
| DirectoryModel.prototype.onEntryChanged = function(kind, entry) { |
| // TODO(hidehiko): We should update directory model even the search result |
| // is shown. |
| var rootType = this.getCurrentRootType(); |
| if ((rootType === RootType.DRIVE || |
| rootType === RootType.DRIVE_SHARED_WITH_ME || |
| rootType === RootType.DRIVE_RECENT || |
| rootType === RootType.DRIVE_OFFLINE) && |
| this.isSearching()) |
| return; |
| |
| if (kind == util.EntryChangedKind.CREATED) { |
| entry.getParent(function(parentEntry) { |
| if (this.getCurrentDirEntry().fullPath != parentEntry.fullPath) { |
| // Do nothing if current directory changed during async operations. |
| return; |
| } |
| this.currentDirContents_.prefetchMetadata([entry], function() { |
| if (this.getCurrentDirEntry().fullPath != parentEntry.fullPath) { |
| // Do nothing if current directory changed during async operations. |
| return; |
| } |
| |
| var index = this.findIndexByEntry_(entry); |
| if (index >= 0) |
| this.getFileList().splice(index, 1, entry); |
| else |
| this.getFileList().push(entry); |
| }.bind(this)); |
| }.bind(this)); |
| } else { |
| // This is the delete event. |
| var index = this.findIndexByEntry_(entry); |
| if (index >= 0) |
| this.getFileList().splice(index, 1); |
| } |
| }; |
| |
| /** |
| * @param {Entry} entry The entry to be searched. |
| * @return {number} The index in the fileList, or -1 if not found. |
| * @private |
| */ |
| DirectoryModel.prototype.findIndexByEntry_ = function(entry) { |
| var fileList = this.getFileList(); |
| for (var i = 0; i < fileList.length; i++) { |
| if (util.isSameEntry(fileList.item(i), entry)) |
| return i; |
| } |
| return -1; |
| }; |
| |
| /** |
| * Called when rename is done successfully. |
| * Note: conceptually, DirectoryModel should work without this, because entries |
| * can be renamed by other systems anytime and Files.app should reflect it |
| * correctly. |
| * TODO(hidehiko): investigate more background, and remove this if possible. |
| * |
| * @param {Entry} oldEntry The old entry. |
| * @param {Entry} newEntry The new entry. |
| * @param {function()} opt_callback Called on completion. |
| */ |
| DirectoryModel.prototype.onRenameEntry = function( |
| oldEntry, newEntry, opt_callback) { |
| this.currentDirContents_.prefetchMetadata([newEntry], function() { |
| // If the current directory is the old entry, then quietly change to the |
| // new one. |
| if (util.isSameEntry(oldEntry, this.getCurrentDirEntry())) |
| this.changeDirectory(newEntry.fullPath); |
| |
| // Look for the old entry. |
| // If the entry doesn't exist in the list, it has been updated from |
| // outside (probably by directory rescan). |
| var index = this.findIndexByEntry_(oldEntry); |
| if (index >= 0) { |
| // Update the content list and selection status. |
| var wasSelected = this.fileListSelection_.getIndexSelected(index); |
| this.updateSelectionAndPublishEvent_(this.fileListSelection_, function() { |
| this.fileListSelection_.setIndexSelected(index, false); |
| this.getFileList().splice(index, 1, newEntry); |
| if (wasSelected) { |
| // We re-search the index, because splice may trigger sorting so that |
| // index may be stale. |
| this.fileListSelection_.setIndexSelected( |
| this.findIndexByEntry_(newEntry), true); |
| } |
| return true; |
| }.bind(this)); |
| } |
| |
| // Run callback, finally. |
| if (opt_callback) |
| opt_callback(); |
| }.bind(this)); |
| }; |
| |
| /** |
| * Creates directory and updates the file list. |
| * |
| * @param {string} name Directory name. |
| * @param {function(DirectoryEntry)} successCallback Callback on success. |
| * @param {function(FileError)} errorCallback Callback on failure. |
| */ |
| DirectoryModel.prototype.createDirectory = function(name, successCallback, |
| errorCallback) { |
| var entry = this.getCurrentDirEntry(); |
| if (!entry) { |
| errorCallback(util.createFileError(FileError.INVALID_MODIFICATION_ERR)); |
| return; |
| } |
| |
| var onSuccess = function(newEntry) { |
| // Do not change anything or call the callback if current |
| // directory changed. |
| if (entry.fullPath != this.getCurrentDirPath()) |
| return; |
| |
| var existing = this.getFileList().slice().filter( |
| function(e) {return e.name == name;}); |
| |
| if (existing.length) { |
| this.selectEntry(name); |
| successCallback(existing[0]); |
| } else { |
| this.fileListSelection_.beginChange(); |
| this.getFileList().splice(0, 0, newEntry); |
| this.selectEntry(name); |
| this.fileListSelection_.endChange(); |
| successCallback(newEntry); |
| } |
| }; |
| |
| this.currentDirContents_.createDirectory(name, onSuccess.bind(this), |
| errorCallback); |
| }; |
| |
| /** |
| * Changes directory. Causes 'directory-change' event. |
| * |
| * @param {string} path New current directory path. |
| * @param {function(FileError)=} opt_errorCallback Executed if the change |
| * directory failed. |
| */ |
| DirectoryModel.prototype.changeDirectory = function(path, opt_errorCallback) { |
| if (PathUtil.isSpecialSearchRoot(path)) { |
| this.specialSearch(path, ''); |
| return; |
| } |
| |
| this.resolveDirectory(path, function(directoryEntry) { |
| this.changeDirectoryEntry_(directoryEntry); |
| }.bind(this), function(error) { |
| console.error('Error changing directory to ' + path + ': ', error); |
| if (opt_errorCallback) |
| opt_errorCallback(error); |
| }); |
| }; |
| |
| /** |
| * Resolves absolute directory path. Handles Drive stub. If the drive is |
| * mounting, callbacks will be called after the mount is completed. |
| * |
| * @param {string} path Path to the directory. |
| * @param {function(DirectoryEntry)} successCallback Success callback. |
| * @param {function(FileError)} errorCallback Error callback. |
| */ |
| DirectoryModel.prototype.resolveDirectory = function( |
| path, successCallback, errorCallback) { |
| if (PathUtil.getRootType(path) == RootType.DRIVE) { |
| if (!this.volumeManager_.getVolumeInfo(RootDirectory.DRIVE)) { |
| errorCallback(util.createFileError(FileError.NOT_FOUND_ERR)); |
| return; |
| } |
| } |
| |
| var onError = function(error) { |
| // Handle the special case, when in offline mode, and there are no cached |
| // contents on the C++ side. In such case, let's display the stub. |
| // The INVALID_STATE_ERR error code is returned from the drive filesystem |
| // in such situation. |
| // |
| // TODO(mtomasz, hashimoto): Consider rewriting this logic. |
| // crbug.com/253464. |
| if (PathUtil.getRootType(path) == RootType.DRIVE && |
| error.code == FileError.INVALID_STATE_ERR) { |
| successCallback(DirectoryModel.fakeDriveEntry_); |
| return; |
| } |
| errorCallback(error); |
| }.bind(this); |
| |
| this.volumeManager_.resolvePath( |
| path, |
| function(entry) { |
| if (entry.isFile) { |
| onError(util.createFileError(FileError.TYPE_MISMATCH_ERR)); |
| return; |
| } |
| successCallback(entry); |
| }, |
| onError); |
| }; |
| |
| /** |
| * @param {DirectoryEntry} dirEntry The absolute path to the new directory. |
| * @param {function()=} opt_callback Executed if the directory loads |
| * successfully. |
| * @private |
| */ |
| DirectoryModel.prototype.changeDirectoryEntrySilent_ = function(dirEntry, |
| opt_callback) { |
| var onScanComplete = function() { |
| if (opt_callback) |
| opt_callback(); |
| // For tests that open the dialog to empty directories, everything |
| // is loaded at this point. |
| chrome.test.sendMessage('directory-change-complete'); |
| }; |
| this.clearAndScan_( |
| DirectoryContents.createForDirectory(this.currentFileListContext_, |
| dirEntry), |
| onScanComplete.bind(this)); |
| }; |
| |
| /** |
| * Change the current directory to the directory represented by a |
| * DirectoryEntry. |
| * |
| * Dispatches the 'directory-changed' event when the directory is successfully |
| * changed. |
| * |
| * @param {DirectoryEntry} dirEntry The absolute path to the new directory. |
| * @param {function()=} opt_callback Executed if the directory loads |
| * successfully. |
| * @private |
| */ |
| DirectoryModel.prototype.changeDirectoryEntry_ = function( |
| dirEntry, opt_callback) { |
| this.fileWatcher_.changeWatchedDirectory(dirEntry, function() { |
| var previous = this.currentDirContents_.getDirectoryEntry(); |
| this.clearSearch_(); |
| this.changeDirectoryEntrySilent_(dirEntry, opt_callback); |
| |
| var e = new Event('directory-changed'); |
| e.previousDirEntry = previous; |
| e.newDirEntry = dirEntry; |
| this.dispatchEvent(e); |
| }.bind(this)); |
| }; |
| |
| /** |
| * Creates an object which could say whether directory has changed while it has |
| * been active or not. Designed for long operations that should be cancelled |
| * if the used change current directory. |
| * @return {Object} Created object. |
| */ |
| DirectoryModel.prototype.createDirectoryChangeTracker = function() { |
| var tracker = { |
| dm_: this, |
| active_: false, |
| hasChanged: false, |
| |
| start: function() { |
| if (!this.active_) { |
| this.dm_.addEventListener('directory-changed', |
| this.onDirectoryChange_); |
| this.active_ = true; |
| this.hasChanged = false; |
| } |
| }, |
| |
| stop: function() { |
| if (this.active_) { |
| this.dm_.removeEventListener('directory-changed', |
| this.onDirectoryChange_); |
| this.active_ = false; |
| } |
| }, |
| |
| onDirectoryChange_: function(event) { |
| tracker.stop(); |
| tracker.hasChanged = true; |
| } |
| }; |
| return tracker; |
| }; |
| |
| /** |
| * Change the state of the model to reflect the specified path (either a |
| * file or directory). |
| * TODO(hidehiko): This logic should be merged with |
| * FileManager.setupCurrentDirectory_. |
| * |
| * @param {string} path The root path to use. |
| * @param {function(string, string, boolean)=} opt_pathResolveCallback Invoked |
| * as soon as the path has been resolved, and called with the base and leaf |
| * portions of the path name, and a flag indicating if the entry exists. |
| * Will be called even if another directory change happened while setupPath |
| * was in progress, but will pass |false| as |exist| parameter. |
| */ |
| DirectoryModel.prototype.setupPath = function(path, opt_pathResolveCallback) { |
| var tracker = this.createDirectoryChangeTracker(); |
| tracker.start(); |
| |
| var self = this; |
| var resolveCallback = function(directoryPath, fileName, exists) { |
| tracker.stop(); |
| if (!opt_pathResolveCallback) |
| return; |
| opt_pathResolveCallback(directoryPath, fileName, |
| exists && !tracker.hasChanged); |
| }; |
| |
| var changeDirectoryEntry = function(directoryEntry, opt_callback) { |
| tracker.stop(); |
| if (!tracker.hasChanged) |
| self.changeDirectoryEntry_(directoryEntry, opt_callback); |
| }; |
| |
| var EXISTS = true; |
| |
| var changeToDefault = function(leafName) { |
| var def = PathUtil.DEFAULT_DIRECTORY; |
| self.resolveDirectory(def, function(directoryEntry) { |
| resolveCallback(def, leafName, !EXISTS); |
| changeDirectoryEntry(directoryEntry); |
| }, function(error) { |
| console.error('Failed to resolve default directory: ' + def, error); |
| resolveCallback('/', leafName, !EXISTS); |
| }); |
| }; |
| |
| var noParentDirectory = function(leafName, error) { |
| console.warn('Can\'t resolve parent directory: ' + path, error); |
| changeToDefault(leafName); |
| }; |
| |
| if (DirectoryModel.isSystemDirectory(path)) { |
| changeToDefault(''); |
| return; |
| } |
| |
| this.resolveDirectory(path, function(directoryEntry) { |
| resolveCallback(directoryEntry.fullPath, '', !EXISTS); |
| changeDirectoryEntry(directoryEntry); |
| }, function(error) { |
| // Usually, leaf does not exist, because it's just a suggested file name. |
| var fileExists = error.code == FileError.TYPE_MISMATCH_ERR; |
| var nameDelimiter = path.lastIndexOf('/'); |
| var parentDirectoryPath = path.substr(0, nameDelimiter); |
| var leafName = path.substr(nameDelimiter + 1); |
| if (fileExists || error.code == FileError.NOT_FOUND_ERR) { |
| if (DirectoryModel.isSystemDirectory(parentDirectoryPath)) { |
| changeToDefault(leafName); |
| return; |
| } |
| self.resolveDirectory(parentDirectoryPath, |
| function(parentDirectoryEntry) { |
| var fileName = path.substr(nameDelimiter + 1); |
| resolveCallback(parentDirectoryEntry.fullPath, fileName, fileExists); |
| changeDirectoryEntry(parentDirectoryEntry, |
| function() { |
| self.selectEntry(fileName); |
| }); |
| }, noParentDirectory.bind(null, leafName)); |
| } else { |
| // Unexpected errors. |
| console.error('Directory resolving error: ', error); |
| changeToDefault(leafName); |
| } |
| }); |
| }; |
| |
| /** |
| * @param {string} name Filename. |
| */ |
| DirectoryModel.prototype.selectEntry = function(name) { |
| var fileList = this.getFileList(); |
| for (var i = 0; i < fileList.length; i++) { |
| if (fileList.item(i).name == name) { |
| this.selectIndex(i); |
| return; |
| } |
| } |
| }; |
| |
| /** |
| * @param {Array.<string>} urls Array of URLs. |
| */ |
| DirectoryModel.prototype.selectUrls = function(urls) { |
| var fileList = this.getFileList(); |
| this.fileListSelection_.beginChange(); |
| this.fileListSelection_.unselectAll(); |
| for (var i = 0; i < fileList.length; i++) { |
| if (urls.indexOf(fileList.item(i).toURL()) >= 0) |
| this.fileListSelection_.setIndexSelected(i, true); |
| } |
| this.fileListSelection_.endChange(); |
| }; |
| |
| /** |
| * @param {number} index Index of file. |
| */ |
| DirectoryModel.prototype.selectIndex = function(index) { |
| // this.focusCurrentList_(); |
| if (index >= this.getFileList().length) |
| return; |
| |
| // If a list bound with the model it will do scrollIndexIntoView(index). |
| this.fileListSelection_.selectedIndex = index; |
| }; |
| |
| /** |
| * Called when VolumeInfoList is updated. |
| * |
| * @param {Event} event Event of VolumeInfoList's 'splice'. |
| * @private |
| */ |
| DirectoryModel.prototype.onVolumeInfoListUpdated_ = function(event) { |
| var driveVolume = this.volumeManager_.getVolumeInfo(RootDirectory.DRIVE); |
| if (driveVolume && !driveVolume.error) { |
| var currentDirEntry = this.getCurrentDirEntry(); |
| if (currentDirEntry) { |
| if (currentDirEntry === DirectoryModel.fakeDriveEntry_) { |
| // Replace the fake entry by real DirectoryEntry silently. |
| this.volumeManager_.resolvePath( |
| DirectoryModel.fakeDriveEntry_.fullPath, |
| function(entry) { |
| // If the current entry is still fake drive entry, replace it. |
| if (this.getCurrentDirEntry() === DirectoryModel.fakeDriveEntry_) |
| this.changeDirectoryEntrySilent_(entry); |
| }, |
| function(error) {}); |
| } else if (PathUtil.isSpecialSearchRoot(currentDirEntry.fullPath)) { |
| for (var i = 0; i < event.added.length; i++) { |
| if (event.added[i].volumeType == util.VolumeType.DRIVE) { |
| // If the Drive volume is newly mounted, rescan it. |
| this.rescan(); |
| break; |
| } |
| } |
| } |
| } |
| } |
| |
| var rootPath = this.getCurrentRootPath(); |
| var rootType = PathUtil.getRootType(rootPath); |
| |
| // If the path is on drive, reduce to the Drive's mount point. |
| if (rootType === RootType.DRIVE) |
| rootPath = RootDirectory.DRIVE; |
| |
| // When the volume where we are is unmounted, fallback to |
| // DEFAULT_DIRECTORY. |
| // Note: during the initialization, rootType can be undefined. |
| if (rootType && !this.volumeManager_.getVolumeInfo(rootPath)) |
| this.changeDirectory(PathUtil.DEFAULT_DIRECTORY); |
| }; |
| |
| /** |
| * @param {string} path Path. |
| * @return {boolean} If current directory is system. |
| */ |
| DirectoryModel.isSystemDirectory = function(path) { |
| path = path.replace(/\/+$/, ''); |
| return path === RootDirectory.REMOVABLE || path === RootDirectory.ARCHIVE; |
| }; |
| |
| /** |
| * Check if the root of the given path is mountable or not. |
| * |
| * @param {string} path Path. |
| * @return {boolean} Return true, if the given path is under mountable root. |
| * Otherwise, return false. |
| */ |
| DirectoryModel.isMountableRoot = function(path) { |
| var rootType = PathUtil.getRootType(path); |
| switch (rootType) { |
| case RootType.DOWNLOADS: |
| return false; |
| case RootType.ARCHIVE: |
| case RootType.REMOVABLE: |
| case RootType.DRIVE: |
| return true; |
| default: |
| throw new Error('Unknown root type!'); |
| } |
| }; |
| |
| /** |
| * Performs search and displays results. The search type is dependent on the |
| * current directory. If we are currently on drive, server side content search |
| * over drive mount point. If the current directory is not on the drive, file |
| * name search over current directory will be performed. |
| * |
| * @param {string} query Query that will be searched for. |
| * @param {function(Event)} onSearchRescan Function that will be called when the |
| * search directory is rescanned (i.e. search results are displayed). |
| * @param {function()} onClearSearch Function to be called when search state |
| * gets cleared. |
| * TODO(olege): Change callbacks to events. |
| */ |
| DirectoryModel.prototype.search = function(query, |
| onSearchRescan, |
| onClearSearch) { |
| query = query.trimLeft(); |
| |
| this.clearSearch_(); |
| |
| var currentDirEntry = this.getCurrentDirEntry(); |
| if (!currentDirEntry) { |
| // Not yet initialized. Do nothing. |
| return; |
| } |
| |
| if (!query) { |
| if (this.isSearching()) { |
| var newDirContents = DirectoryContents.createForDirectory( |
| this.currentFileListContext_, |
| this.currentDirContents_.getLastNonSearchDirectoryEntry()); |
| this.clearAndScan_(newDirContents); |
| } |
| return; |
| } |
| |
| this.onSearchCompleted_ = onSearchRescan; |
| this.onClearSearch_ = onClearSearch; |
| |
| this.addEventListener('scan-completed', this.onSearchCompleted_); |
| |
| // If we are offline, let's fallback to file name search inside dir. |
| // A search initiated from directories in Drive or special search results |
| // should trigger Drive search. |
| var newDirContents; |
| if (!this.isDriveOffline() && |
| PathUtil.isDriveBasedPath(currentDirEntry.fullPath)) { |
| // Drive search is performed over the whole drive, so pass drive root as |
| // |directoryEntry|. |
| newDirContents = DirectoryContents.createForDriveSearch( |
| this.currentFileListContext_, |
| currentDirEntry, |
| this.currentDirContents_.getLastNonSearchDirectoryEntry(), |
| query); |
| } else { |
| newDirContents = DirectoryContents.createForLocalSearch( |
| this.currentFileListContext_, currentDirEntry, query); |
| } |
| this.clearAndScan_(newDirContents); |
| }; |
| |
| /** |
| * Performs special search and displays results. e.g. Drive files available |
| * offline, shared-with-me files, recently modified files. |
| * @param {string} path Path string representing special search. See fake |
| * entries in PathUtil.RootDirectory. |
| * @param {string=} opt_query Query string used for the search. |
| */ |
| DirectoryModel.prototype.specialSearch = function(path, opt_query) { |
| var query = opt_query || ''; |
| |
| this.clearSearch_(); |
| |
| this.onSearchCompleted_ = null; |
| this.onClearSearch_ = null; |
| |
| var onDriveDirectoryResolved = function(driveRoot) { |
| if (!driveRoot || driveRoot == DirectoryModel.fakeDriveEntry_) { |
| // Drive root not available or not ready. onVolumeInfoListUpdated_() |
| // handles the rescan if necessary. |
| driveRoot = null; |
| } |
| |
| var specialSearchType = PathUtil.getRootType(path); |
| var searchOption; |
| var dirEntry; |
| if (specialSearchType == RootType.DRIVE_OFFLINE) { |
| dirEntry = DirectoryModel.fakeDriveOfflineEntry_; |
| searchOption = |
| DriveMetadataSearchContentScanner.SearchType.SEARCH_OFFLINE; |
| } else if (specialSearchType == RootType.DRIVE_SHARED_WITH_ME) { |
| dirEntry = DirectoryModel.fakeDriveSharedWithMeEntry_; |
| searchOption = |
| DriveMetadataSearchContentScanner.SearchType.SEARCH_SHARED_WITH_ME; |
| } else if (specialSearchType == RootType.DRIVE_RECENT) { |
| dirEntry = DirectoryModel.fakeDriveRecentEntry_; |
| searchOption = |
| DriveMetadataSearchContentScanner.SearchType.SEARCH_RECENT_FILES; |
| |
| } else { |
| // Unknown path. |
| this.changeDirectory(PathUtil.DEFAULT_DIRECTORY); |
| return; |
| } |
| |
| var newDirContents = DirectoryContents.createForDriveMetadataSearch( |
| this.currentFileListContext_, |
| dirEntry, driveRoot, query, searchOption); |
| var previous = this.currentDirContents_.getDirectoryEntry(); |
| this.clearAndScan_(newDirContents); |
| |
| var e = new Event('directory-changed'); |
| e.previousDirEntry = previous; |
| e.newDirEntry = dirEntry; |
| this.dispatchEvent(e); |
| }.bind(this); |
| |
| this.resolveDirectory(DirectoryModel.fakeDriveEntry_.fullPath, |
| onDriveDirectoryResolved /* success */, |
| function() {} /* failed */); |
| }; |
| |
| /** |
| * In case the search was active, remove listeners and send notifications on |
| * its canceling. |
| * @private |
| */ |
| DirectoryModel.prototype.clearSearch_ = function() { |
| if (!this.isSearching()) |
| return; |
| |
| if (this.onSearchCompleted_) { |
| this.removeEventListener('scan-completed', this.onSearchCompleted_); |
| this.onSearchCompleted_ = null; |
| } |
| |
| if (this.onClearSearch_) { |
| this.onClearSearch_(); |
| this.onClearSearch_ = null; |
| } |
| }; |