diff options
Diffstat (limited to 'devtools/client/projecteditor/lib/stores/resource.js')
-rw-r--r-- | devtools/client/projecteditor/lib/stores/resource.js | 398 |
1 files changed, 398 insertions, 0 deletions
diff --git a/devtools/client/projecteditor/lib/stores/resource.js b/devtools/client/projecteditor/lib/stores/resource.js new file mode 100644 index 000000000..53e3e7348 --- /dev/null +++ b/devtools/client/projecteditor/lib/stores/resource.js @@ -0,0 +1,398 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { Cc, Ci, Cu } = require("chrome"); +const { TextEncoder, TextDecoder } = require("sdk/io/buffer"); +const { Class } = require("sdk/core/heritage"); +const { EventTarget } = require("sdk/event/target"); +const { emit } = require("sdk/event/core"); +const URL = require("sdk/url"); +const promise = require("promise"); +const { OS } = Cu.import("resource://gre/modules/osfile.jsm", {}); +const { FileUtils } = Cu.import("resource://gre/modules/FileUtils.jsm", {}); +const mimeService = Cc["@mozilla.org/mime;1"].getService(Ci.nsIMIMEService); +const { Task } = require("devtools/shared/task"); + +const gDecoder = new TextDecoder(); +const gEncoder = new TextEncoder(); + +/** + * A Resource is a single file-like object that can be respresented + * as a file for ProjectEditor. + * + * The Resource class is not exported, and should not be instantiated + * Instead, you should use the FileResource class that extends it. + * + * This object emits the following events: + * - "children-changed": When a child has been added or removed. + * See setChildren. + * - "deleted": When the resource has been deleted. + */ +var Resource = Class({ + extends: EventTarget, + + refresh: function () { return promise.resolve(this); }, + destroy: function () { }, + delete: function () { }, + + setURI: function (uri) { + if (typeof (uri) === "string") { + uri = URL.URL(uri); + } + this.uri = uri; + }, + + /** + * Is there more than 1 child Resource? + */ + get hasChildren() { return this.children && this.children.size > 0; }, + + /** + * Is this Resource the root (top level for the store)? + */ + get isRoot() { + return !this.parent; + }, + + /** + * Sorted array of children for display + */ + get childrenSorted() { + if (!this.hasChildren) { + return []; + } + + return [...this.children].sort((a, b)=> { + // Put directories above files. + if (a.isDir !== b.isDir) { + return b.isDir; + } + return a.basename.toLowerCase() > b.basename.toLowerCase(); + }); + }, + + /** + * Set the children set of this Resource, and notify of any + * additions / removals that happened in the change. + */ + setChildren: function (newChildren) { + let oldChildren = this.children || new Set(); + let change = false; + + for (let child of oldChildren) { + if (!newChildren.has(child)) { + change = true; + child.parent = null; + this.store.notifyRemove(child); + } + } + + for (let child of newChildren) { + if (!oldChildren.has(child)) { + change = true; + child.parent = this; + this.store.notifyAdd(child); + } + } + + this.children = newChildren; + if (change) { + emit(this, "children-changed", this); + } + }, + + /** + * Add a resource to children set and notify of the change. + * + * @param Resource resource + */ + addChild: function (resource) { + this.children = this.children || new Set(); + + resource.parent = this; + this.children.add(resource); + this.store.notifyAdd(resource); + emit(this, "children-changed", this); + return resource; + }, + + /** + * Checks if current object has child with specific name. + * + * @param string name + */ + hasChild: function (name) { + for (let child of this.children) { + if (child.basename === name) { + return true; + } + } + return false; + }, + + /** + * Remove a resource to children set and notify of the change. + * + * @param Resource resource + */ + removeChild: function (resource) { + resource.parent = null; + this.children.remove(resource); + this.store.notifyRemove(resource); + emit(this, "children-changed", this); + return resource; + }, + + /** + * Return a set with children, children of children, etc - + * gathered recursively. + * + * @returns Set<Resource> + */ + allDescendants: function () { + let set = new Set(); + + function addChildren(item) { + if (!item.children) { + return; + } + + for (let child of item.children) { + set.add(child); + } + } + + addChildren(this); + for (let item of set) { + addChildren(item); + } + + return set; + }, +}); + +/** + * A FileResource is an implementation of Resource for a File System + * backing. This is exported, and should be used instead of Resource. + */ +var FileResource = Class({ + extends: Resource, + + /** + * @param Store store + * @param String path + * @param FileInfo info + * https://developer.mozilla.org/en-US/docs/JavaScript_OS.File/OS.File.Info + */ + initialize: function (store, path, info) { + this.store = store; + this.path = path; + + this.setURI(URL.URL(URL.fromFilename(path))); + this._lastReadModification = undefined; + + this.info = info; + this.parent = null; + }, + + toString: function () { + return "[FileResource:" + this.path + "]"; + }, + + destroy: function () { + if (this._refreshDeferred) { + this._refreshDeferred.reject(); + } + this._refreshDeferred = null; + }, + + /** + * Fetch and cache information about this particular file. + * https://developer.mozilla.org/en-US/docs/JavaScript_OS.File/OS.File_for_the_main_thread#OS.File.stat + * + * @returns Promise + * Resolves once the File.stat has finished. + */ + refresh: function () { + if (this._refreshDeferred) { + return this._refreshDeferred.promise; + } + this._refreshDeferred = promise.defer(); + OS.File.stat(this.path).then(info => { + this.info = info; + if (this._refreshDeferred) { + this._refreshDeferred.resolve(this); + this._refreshDeferred = null; + } + }); + return this._refreshDeferred.promise; + }, + + /** + * Return the trailing name component of this Resource + */ + get basename() { + return this.path.replace(/\/+$/, "").replace(/\\/g, "/").replace(/.*\//, ""); + }, + + /** + * A string to be used when displaying this Resource in views + */ + get displayName() { + return this.basename + (this.isDir ? "/" : ""); + }, + + /** + * Is this FileResource a directory? Rather than checking children + * here, we use this.info. So this could return a false negative + * if there was no info passed in on constructor and the first + * refresh hasn't yet finished. + */ + get isDir() { + if (!this.info) { return false; } + return this.info.isDir && !this.info.isSymLink; + }, + + /** + * Read the file as a string asynchronously. + * + * @returns Promise + * Resolves with the text of the file. + */ + load: function () { + return OS.File.read(this.path).then(bytes => { + return gDecoder.decode(bytes); + }); + }, + + /** + * Delete the file from the filesystem + * + * @returns Promise + * Resolves when the file is deleted + */ + delete: function () { + emit(this, "deleted", this); + if (this.isDir) { + return OS.File.removeDir(this.path); + } else { + return OS.File.remove(this.path); + } + }, + + /** + * Add a text file as a child of this FileResource. + * This instance must be a directory. + * + * @param string name + * The filename (path will be generated based on this.path). + * string initial + * The content to write to the new file. + * @returns Promise + * Resolves with the new FileResource once it has + * been written to disk. + * Rejected if this is not a directory. + */ + createChild: function (name, initial = "") { + if (!this.isDir) { + return promise.reject(new Error("Cannot add child to a regular file")); + } + + let newPath = OS.Path.join(this.path, name); + + let buffer = initial ? gEncoder.encode(initial) : ""; + return OS.File.writeAtomic(newPath, buffer, { + noOverwrite: true + }).then(() => { + return this.store.refresh(); + }).then(() => { + let resource = this.store.resources.get(newPath); + if (!resource) { + throw new Error("Error creating " + newPath); + } + return resource; + }); + }, + + /** + * Rename the file from the filesystem + * + * @returns Promise + * Resolves with the renamed FileResource. + */ + rename: function (oldName, newName) { + let oldPath = OS.Path.join(this.path, oldName); + let newPath = OS.Path.join(this.path, newName); + + return OS.File.move(oldPath, newPath).then(() => { + return this.store.refresh(); + }).then(() => { + let resource = this.store.resources.get(newPath); + if (!resource) { + throw new Error("Error creating " + newPath); + } + return resource; + }); + }, + + /** + * Write a string to this file. + * + * @param string content + * @returns Promise + * Resolves once it has been written to disk. + * Rejected if there is an error + */ + save: Task.async(function* (content) { + // XXX: writeAtomic was losing permissions after saving on OSX + // return OS.File.writeAtomic(this.path, buffer, { tmpPath: this.path + ".tmp" }); + let buffer = gEncoder.encode(content); + let path = this.path; + let file = yield OS.File.open(path, {truncate: true}); + yield file.write(buffer); + yield file.close(); + }), + + /** + * Attempts to get the content type from the file. + */ + get contentType() { + if (this._contentType) { + return this._contentType; + } + if (this.isDir) { + return "x-directory/normal"; + } + try { + this._contentType = mimeService.getTypeFromFile(new FileUtils.File(this.path)); + } catch (ex) { + if (ex.name !== "NS_ERROR_NOT_AVAILABLE" && + ex.name !== "NS_ERROR_FAILURE") { + console.error(ex, this.path); + } + this._contentType = null; + } + return this._contentType; + }, + + /** + * A string used when determining the type of Editor to open for this. + * See editors.js -> EditorTypeForResource. + */ + get contentCategory() { + const NetworkHelper = require("devtools/shared/webconsole/network-helper"); + let category = NetworkHelper.mimeCategoryMap[this.contentType]; + // Special treatment for manifest.webapp. + if (!category && this.basename === "manifest.webapp") { + return "json"; + } + return category || "txt"; + } +}); + +exports.FileResource = FileResource; |