/* -*- 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 */ 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;