gecko-dev/browser/devtools/projecteditor/lib/stores/resource.js

366 lines
9.5 KiB
JavaScript

/* -*- 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("projecteditor/helpers/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 } = Cu.import("resource://gre/modules/Task.jsm", {});
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;
},
/**
* Return the trailing name component of this.uri.
*/
get basename() { return this.uri.path.replace(/\/+$/, '').replace(/\\/g,'/').replace( /.*\//, '' ); },
/**
* 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;
},
/**
* 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;
},
/**
* 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;
});
},
/**
* 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: function(content) {
let buffer = gEncoder.encode(content);
let path = this.path;
// XXX: writeAtomic was losing permissions after saving on OSX
// return OS.File.writeAtomic(this.path, buffer, { tmpPath: this.path + ".tmp" });
return Task.spawn(function*() {
let pfh = yield OS.File.open(path, {truncate: true});
yield pfh.write(buffer);
yield pfh.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/toolkit/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;