diff --git a/makefile b/makefile index 856ce18..6da96d4 100644 --- a/makefile +++ b/makefile @@ -31,7 +31,7 @@ uninstall: install-local: mkdir -p $(HOME)/.local/share/cockpit cp -rpf navigator $(HOME)/.local/share/cockpit - sed -i "s#\"/usr/share/\(cockpit/navigator/scripts/.*\)\"#\"$(HOME)/.local/share/\1\"#g" $(HOME)/.local/share/cockpit/navigator/navigator.js + find $(HOME)/.local/share/cockpit/navigator -name '*.js' -exec sed -i "s#\"/usr/share/\(cockpit/navigator/scripts/.*\)\"#\"$(HOME)/.local/share/\1\"#g" {} \; uninstall-local: rm -rf $(HOME)/.local/share/cockpit/navigator diff --git a/navigator/components/FileUpload.js b/navigator/components/FileUpload.js new file mode 100644 index 0000000..891eb21 --- /dev/null +++ b/navigator/components/FileUpload.js @@ -0,0 +1,166 @@ +import {NavWindow} from "./NavWindow.js"; +import {format_time_remaining} from "../functions.js"; + +export class FileUpload { + /** + * + * @param {File|Blob} file + * @param {NavWindow} nav_window_ref + */ + constructor(file, nav_window_ref) { + try { + this.chunk_size = (parseInt(cockpit.info.version) > 238)? 1048576 : 65536; + } catch(e) { + console.log(e); + this.chunk_size = 65536; + } + this.filename = file.name; + this.nav_window_ref = nav_window_ref; + this.path = nav_window_ref.pwd().path_str() + "/" + file.name; + this.reader = new FileReader(); + this.chunks = this.slice_file(file); + this.chunk_index = 0; + this.timestamp = Date.now(); + } + + check_if_exists() { + return new Promise((resolve, reject) => { + var proc = cockpit.spawn(["/usr/share/cockpit/navigator/scripts/fail-if-exists.py", this.path], {superuser: "try"}); + proc.done((data) => {resolve(false)}); + proc.fail((e, data) => {resolve(true)}); + }); + } + + make_html_element() { + var notification = document.createElement("div"); + notification.classList.add("nav-notification"); + + var header = document.createElement("div"); + header.classList.add("nav-notification-header"); + notification.appendChild(header); + header.innerText = "Uploading " + this.filename; + + var info = document.createElement("div"); + info.classList.add("flex-row", "space-between"); + notification.appendChild(info); + + var rate = document.createElement("div"); + rate.classList.add("monospace-sm"); + info.appendChild(rate); + rate.innerText = "-"; + this.rate = rate; + + var eta = document.createElement("div"); + eta.classList.add("monospace-sm"); + info.appendChild(eta); + eta.innerText = "-"; + this.eta = eta; + + var progress = document.createElement("progress"); + progress.max = this.num_chunks; + notification.appendChild(progress); + this.progress = progress; + + this.html_elements = [progress, eta, rate, info, header, notification]; + document.getElementById("nav-notifications").appendChild(notification); + } + + remove_html_element() { + for (let elem of this.html_elements) { + if (elem.parentElement) + elem.parentElement.removeChild(elem); + } + } + + /** + * + * @param {File|Blob} file + * @returns {Array} + */ + slice_file(file) { + var offset = 0; + var next_offset; + var chunks = []; + this.num_chunks = Math.ceil(file.size / this.chunk_size); + for (let i = 0; i < this.num_chunks; i++) { + next_offset = Math.min(this.chunk_size * (i + 1), file.size); + chunks.push(file.slice(offset, next_offset)); + offset = next_offset; + } + return chunks; + } + + async upload() { + if (await this.check_if_exists()) { + if (!window.confirm(this.filename + ": File exists. Replace?")) + return; + } + this.make_html_element(); + this.proc = cockpit.spawn(["/usr/share/cockpit/navigator/scripts/write-chunks.py", this.path], {err: "out", superuser: "try"}); + this.proc.fail((e, data) => { + this.reader.onload = () => {} + this.done(); + window.alert(data); + }) + this.proc.done((data) => { + this.nav_window_ref.refresh(); + }) + this.reader.onload = (function(uploader_ref) { + return async function(evt) { + uploader_ref.write_to_file(evt, uploader_ref.chunk_index * uploader_ref.chunk_size); + uploader_ref.chunk_index++; + uploader_ref.progress.value = uploader_ref.chunk_index; + if (uploader_ref.chunk_index < uploader_ref.num_chunks) + uploader_ref.reader.readAsArrayBuffer(uploader_ref.chunks[uploader_ref.chunk_index]); + else { + uploader_ref.done(); + } + }; + })(this); + this.reader.readAsArrayBuffer(this.chunks[0]); + } + + arrayBufferToBase64(buffer) { + let binary = ''; + let bytes = new Uint8Array(buffer); + let len = bytes.byteLength; + for (let i = 0; i < len; i++) { + binary += String.fromCharCode(bytes[i]); + } + return window.btoa(binary); + } + + /** + * + * @param {Event} evt + */ + write_to_file(evt) { + var chunk_b64 = this.arrayBufferToBase64(evt.target.result); + const seek = this.chunk_index * this.chunk_size; + var obj = { + seek: seek, + chunk: chunk_b64 + }; + this.proc.input(JSON.stringify(obj) + "\n", true); + this.update_xfr_rate(); + } + + done() { + this.proc.input(); // close stdin + this.remove_html_element(); + } + + update_xfr_rate() { + var now = Date.now(); + var elapsed = (now - this.timestamp) / 1000; + this.timestamp = now; + var rate = this.chunk_size / elapsed; + this.rate.innerText = cockpit.format_bytes_per_sec(rate); + // keep exponential moving average of chunk time for eta + this.chunk_time = (this.chunk_time) + ? (0.125 * elapsed + (0.875 * this.chunk_time)) + : elapsed; + var eta = (this.num_chunks - this.chunk_index) * this.chunk_time; + this.eta.innerText = format_time_remaining(eta); + } +} diff --git a/navigator/components/ModalPrompt.js b/navigator/components/ModalPrompt.js new file mode 100644 index 0000000..0d5ef1c --- /dev/null +++ b/navigator/components/ModalPrompt.js @@ -0,0 +1,4 @@ +export class ModalPrompt { + +} + diff --git a/navigator/components/NavContextMenu.js b/navigator/components/NavContextMenu.js new file mode 100644 index 0000000..f91c2f2 --- /dev/null +++ b/navigator/components/NavContextMenu.js @@ -0,0 +1,187 @@ +import {NavEntry} from "./NavEntry.js"; +import {NavFile, NavFileLink} from "./NavFile.js"; +import {NavDir, NavDirLink} from "./NavDir.js"; +import {NavDownloader} from "./NavDownloader.js"; + +export class NavContextMenu { + /** + * + * @param {string} id + */ + constructor(id, nav_window_ref) { + this.dom_element = document.getElementById(id); + this.nav_window_ref = nav_window_ref; + this.menu_options = {}; + document.documentElement.addEventListener("click", (event) => { + if (event.target !== this.dom_element) + this.hide(); + }); + + var functions = [ + ["new_dir", '
'], + ["new_file", '
'], + ["new_link", '
'], + ["cut", '
'], + ["copy", '
'], + ["paste", '
'], + ["rename", '
'], + ["delete", '
'], + ["download", '
'], + ["properties", '
'] + ]; + for (let func of functions) { + var elem = document.createElement("div"); + var name_list = func[0].split("_"); + name_list.forEach((word, index) => {name_list[index] = word.charAt(0).toUpperCase() + word.slice(1)}); + elem.innerHTML = func[1] + name_list.join(" "); + elem.addEventListener("click", (e) => {this[func[0]].bind(this).apply()}); + elem.classList.add("nav-context-menu-item") + elem.id = "nav-context-menu-" + func[0]; + this.dom_element.appendChild(elem); + this.menu_options[func[0]] = elem; + } + } + + new_dir() { + this.nav_window_ref.mkdir(); + } + + new_file() { + this.nav_window_ref.touch(); + } + + new_link() { + var default_target = ""; + if (this.nav_window_ref.selected_entries.size <= 1 && this.target !== this.nav_window_ref.pwd()) + default_target = this.target.filename(); + this.nav_window_ref.ln(default_target); + } + + cut() { + this.nav_window_ref.cut(); + } + + copy() { + this.nav_window_ref.copy(); + } + + paste() { + this.nav_window_ref.paste(); + } + + async rename() { + this.hide(); + var new_name = window.prompt("New Name: ", this.target.filename()); + if (new_name === null) + return; + if (new_name.includes("/")) { + window.alert("File name can't contain `/`."); + return; + } + try { + await this.target.mv(new_name); + } catch(e) { + window.alert(e); + return; + } + this.nav_window_ref.refresh(); + } + + zip_for_download() { + return new Promise((resolve, reject) => { + var cmd = [ + "/usr/share/cockpit/navigator/scripts/zip-for-download.py", + this.nav_window_ref.pwd().path_str() + ]; + for (let entry of this.nav_window_ref.selected_entries) { + cmd.push(entry.path_str()); + } + var proc = cockpit.spawn(cmd, {superuser: "try", err: "out"}); + proc.fail((e, data) => { + reject(JSON.parse(data)); + }); + proc.done((data) => { + resolve(JSON.parse(data)); + }); + }); + } + + async download() { + var download_target = ""; + if (this.nav_window_ref.selected_entries.size === 1 && !(this.nav_window_ref.selected_entry() instanceof NavDir)) { + download_target = this.nav_window_ref.selected_entry(); + } else { + this.nav_window_ref.start_load(); + var result; + try { + result = await this.zip_for_download(); + } catch(e) { + this.nav_window_ref.stop_load(); + window.alert(e.message); + return; + } + this.nav_window_ref.stop_load(); + download_target = new NavFile(result["archive-path"], result["stat"], this.nav_window_ref); + } + var download = new NavDownloader(download_target); + download.download(); + } + + delete() { + this.nav_window_ref.delete_selected(); + } + + properties() { + this.nav_window_ref.show_edit_selected(); + } + + /** + * + * @param {Event} event + * @param {NavEntry} target + */ + show(event, target) { + if (!this.nav_window_ref.none_selected()) { + if (event.shiftKey || event.ctrlKey) + this.nav_window_ref.set_selected(target, event.shiftKey, event.ctrlKey); + } else { + this.nav_window_ref.set_selected(target, false, false); + } + for (let option of Object.keys(this.menu_options)) { + this.menu_options[option].style.display = "flex"; // show all + } + // selectively hide options based on context + if (this.nav_window_ref.none_selected()) { + this.menu_options["copy"].style.display = "none"; + this.menu_options["cut"].style.display = "none"; + this.menu_options["delete"].style.display = "none"; + this.menu_options["download"].style.display = "none"; + } + if (this.nav_window_ref.selected_entries.size > 1) { + this.menu_options["rename"].style.display = "none"; + } else { + if (target instanceof NavDirLink || target instanceof NavFileLink) + this.menu_options["download"].style.display = "none"; + } + if (!this.nav_window_ref.clip_board.length) + this.menu_options["paste"].style.display = "none"; + this.target = target; + this.dom_element.style.display = "inline"; + this.dom_element.style.left = event.clientX + "px"; + var height = this.dom_element.getBoundingClientRect().height; + var max_height = window.innerHeight; + if (event.clientY > max_height - height) { + this.dom_element.style.top = event.clientY - height + "px"; + } else { + this.dom_element.style.top = event.clientY + "px"; + } + } + + hide() { + this.dom_element.style.display = "none"; + } + + hide_paste() { + this.menu_options["paste"].style.display = "none"; + } +} diff --git a/navigator/components/NavDir.js b/navigator/components/NavDir.js new file mode 100644 index 0000000..42c4bd8 --- /dev/null +++ b/navigator/components/NavDir.js @@ -0,0 +1,242 @@ +import {NavEntry} from "./NavEntry.js"; +import {NavFile, NavFileLink} from "./NavFile.js"; +import {NavWindow} from "./NavWindow.js"; +import {property_entry_html} from "../functions.js"; + +export class NavDir extends NavEntry { + /** + * + * @param {string|string[]} path + * @param {object} stat + * @param {NavWindow} nav_window_ref + */ + constructor(path, stat, nav_window_ref) { + super(path, stat, nav_window_ref); + this.nav_type = "dir"; + this.dom_element.nav_item_icon.classList.add("fas", "fa-folder"); + this.double_click = false; + } + + /** + * + * @param {Event} e + */ + handleEvent(e) { + switch (e.type) { + case "click": + if (this.double_click) + this.nav_window_ref.cd(this); + else { + // single click + this.double_click = true; + if (this.timeout) + clearTimeout(this.timeout); + this.timeout = setTimeout(() => { + this.double_click = false; + }, 500); + } + break; + } + super.handleEvent(e); + } + + /** + * + * @param {NavWindow} nav_window_ref + * @returns {Promise} + */ + get_children(nav_window_ref) { + return new Promise(async (resolve, reject) => { + var children = []; + var proc = cockpit.spawn( + ["/usr/share/cockpit/navigator/scripts/ls.py", this.path_str()], + {err:"out", superuser: "try"} + ); + proc.fail((e, data) => { + reject(data); + }); + var data; + try { + data = await proc; + } catch(e) { + reject(e); + return; + } + var response = JSON.parse(data); + this.stat = response["."]["stat"]; + var entries = response["children"]; + var filename, path, stat; + entries.forEach((entry) => { + filename = entry["filename"]; + path = (this.path.length >= 1 && this.path[0]) ? [...this.path, filename] : [filename]; + stat = entry["stat"]; + switch(stat["mode-str"].charAt(0)) { + case 'd': + children.push(new NavDir(path, stat, nav_window_ref)); + break; + case 'l': + if(entry["isdir"]) + children.push(new NavDirLink(path, stat, nav_window_ref, entry["link-target"])); + else + children.push(new NavFileLink(path, stat, nav_window_ref, entry["link-target"])); + break; + default: + children.push(new NavFile(path, stat, nav_window_ref)); + break; + } + }); + resolve(children); + }); + } + + /** + * + * @returns {Promise} + */ + rm() { + return new Promise(async (resolve, reject) => { + var proc = cockpit.spawn( + ["rmdir", this.path_str()], + {superuser: "try", err: "out"} + ); + proc.done((data) => { + resolve(); + }); + proc.fail((e, data) => { + if (/^rmdir: failed to remove .*: Directory not empty\n?$/.test(data)) { + if (window.confirm("WARNING: '" + this.path_str() + "' is not empty. Delete recursively? This can NOT be undone.")) { + this.rm_recursive(resolve, reject); + } + } else { + reject(data); + } + }); + }); + } + + /** + * + * @param {Function} resolve + * @param {Function} reject + */ + rm_recursive(resolve, reject) { + var proc = cockpit.spawn( + ["rm", "-rf", this.path_str()], + {superuser: "try", err: "out"} + ); + proc.done((data) => { + resolve(); + }); + proc.fail((e, data) => { + reject(data); + }); + } + + /** + * + * @returns {Object} + */ + async cephfs_dir_stats() { + try { + var proc = await cockpit.spawn( + ["/usr/share/cockpit/navigator/scripts/cephfs-dir-stats.py", "-j", this.path_str()], + {err: "ignore"} + ); + return JSON.parse(proc)[0]; + } catch { + return null; + } + } + + /** + * + * @param {string} extra_properties + */ + async show_properties(extra_properties = "") { + if(!this.hasOwnProperty("ceph_stats")) + this.ceph_stats = await this.cephfs_dir_stats(); + // See if a JSON object exists for folder we are currently looking at + if (this.ceph_stats !== null) { + extra_properties += + '
'; + extra_properties += property_entry_html( + "Files", + this.ceph_stats.hasOwnProperty("files") ? this.ceph_stats.files : "N/A" + ); + extra_properties += property_entry_html( + "Directories", + this.ceph_stats.hasOwnProperty("subdirs") ? this.ceph_stats.subdirs : "N/A" + ); + extra_properties += property_entry_html( + "Recursive files", + this.ceph_stats.hasOwnProperty("rfiles") ? this.ceph_stats.rfiles : "N/A" + ); + extra_properties += property_entry_html( + "Recursive directories", + this.ceph_stats.hasOwnProperty("rsubdirs") ? this.ceph_stats.rsubdirs : "N/A" + ); + extra_properties += property_entry_html( + "Total size", + this.ceph_stats.hasOwnProperty("rbytes") ? this.ceph_stats.rbytes : "N/A" + ); + extra_properties += property_entry_html( + "Layout pool", + this.ceph_stats.hasOwnProperty("layout.pool") ? this.ceph_stats["layout.pool"] : "N/A" + ); + extra_properties += property_entry_html( + "Max files", + this.ceph_stats.hasOwnProperty("max_files") ? this.ceph_stats.max_files : "N/A" + ); + extra_properties += property_entry_html( + "Max bytes", + this.ceph_stats.hasOwnProperty("max_bytes") ? this.ceph_stats.max_bytes : "N/A" + ); + } + super.show_properties(extra_properties); + } +} + +export class NavDirLink extends NavDir{ + /** + * + * @param {string|string[]} path + * @param {object} stat + * @param {NavWindow} nav_window_ref + * @param {string} link_target + */ + constructor(path, stat, nav_window_ref, link_target) { + super(path, stat, nav_window_ref); + var link_icon = this.dom_element.nav_item_icon.link_icon = document.createElement("i"); + link_icon.classList.add("fas", "fa-link", "nav-item-symlink-symbol-dir"); + this.dom_element.nav_item_icon.appendChild(link_icon); + this.double_click = false; + this.link_target = link_target; + this.dom_element.nav_item_title.style.fontStyle = "italic"; + if (nav_window_ref.item_display === "list") + this.dom_element.nav_item_title.innerHTML += " → " + this.link_target; + } + + /** + * + * @returns {Promise} + */ + rm() { + return new Promise((resolve, reject) => { + var proc = cockpit.spawn( + ["rm", "-f", this.path_str()], + {superuser: "try", err: "out"} + ); + proc.done((data) => { + resolve(); + }) + proc.fail((e, data) => { + reject(data); + }); + }); + } + + show_properties() { + var extra_properties = property_entry_html("Link Target", this.link_target); + super.show_properties(extra_properties); + } +} diff --git a/navigator/components/NavDownloader.js b/navigator/components/NavDownloader.js new file mode 100644 index 0000000..dd6e84d --- /dev/null +++ b/navigator/components/NavDownloader.js @@ -0,0 +1,39 @@ +import {NavFile} from "./NavFile.js"; + +export class NavDownloader { + /** + * + * @param {NavFile} file + */ + constructor(file) { + this.path = file.path_str(); + this.filename = file.filename(); + this.read_size = file.stat["size"]; + } + + async download() { + let query = window.btoa(JSON.stringify({ + payload: 'fsread1', + binary: 'raw', + path: this.path, + superuser: true, + max_read_size: this.read_size, + external: { + 'content-disposition': 'attachment; filename="' + this.filename + '"', + 'content-type': 'application/x-xz, application/octet-stream' + }, + })); + let prefix = (new URL(cockpit.transport.uri('channel/' + cockpit.transport.csrf_token))).pathname; + var a = document.createElement("a"); + a.href = prefix + "?" + query; + a.style.display = "none"; + a.download = this.filename; + document.body.appendChild(a); + var event = new MouseEvent('click', { + 'view': window, + 'bubbles': false, + 'cancelable': true + }); + a.dispatchEvent(event); + } +} diff --git a/navigator/components/NavDragDrop.js b/navigator/components/NavDragDrop.js new file mode 100644 index 0000000..85b0686 --- /dev/null +++ b/navigator/components/NavDragDrop.js @@ -0,0 +1,59 @@ +import {FileUpload} from "./FileUpload.js"; +import {NavWindow} from "./NavWindow.js"; + +export class NavDragDrop { + /** + * + * @param {HTMLDivElement} drop_area + * @param {NavWindow} nav_window_ref + */ + constructor(drop_area, nav_window_ref) { + drop_area.addEventListener("dragenter", this); + drop_area.addEventListener("dragover", this); + drop_area.addEventListener("dragleave", this); + drop_area.addEventListener("drop", this); + this.drop_area = drop_area; + this.nav_window_ref = nav_window_ref; + } + + handleEvent(e) { + e.preventDefault(); + switch(e.type){ + case "dragenter": + this.drop_area.classList.add("drag-enter"); + break; + case "dragover": + break; + case "dragleave": + this.drop_area.classList.remove("drag-enter"); + break; + case "drop": + if (e.dataTransfer.items) { + for (let item of e.dataTransfer.items) { + if (item.kind === 'file') { + var file = item.getAsFile(); + if (file.type === "") { + window.alert(file.name + ": Cannot upload folders."); + continue; + } + var uploader = new FileUpload(file, this.nav_window_ref); + uploader.upload(); + } + } + } else { + for (let file of ev.dataTransfer.files) { + if (file.type === "") + continue; + var uploader = new FileUpload(file, this.nav_window_ref); + uploader.upload(); + } + } + this.drop_area.classList.remove("drag-enter"); + break; + default: + this.drop_area.classList.remove("drag-enter"); + break; + } + e.stopPropagation(); + } +} diff --git a/navigator/components/NavEntry.js b/navigator/components/NavEntry.js new file mode 100644 index 0000000..f0b5f4e --- /dev/null +++ b/navigator/components/NavEntry.js @@ -0,0 +1,248 @@ +import {NavWindow} from "./NavWindow.js"; +import {format_bytes, property_entry_html, format_time} from "../functions.js"; + +export class NavEntry { + /** + * + * @param {string} path + * @param {object} stat + * @param {NavWindow} nav_window_ref + */ + constructor(path, stat, nav_window_ref) { + this.nav_window_ref = nav_window_ref; + if (typeof path == "string") + this.path = path.split("/").splice(1); + else + this.path = (path.length) ? path : [""]; + this.dom_element = document.createElement("div"); + this.dom_element.classList.add("nav-item"); + let icon = this.dom_element.nav_item_icon = document.createElement("i"); + icon.classList.add("nav-item-icon"); + let title = this.dom_element.nav_item_title = document.createElement("div"); + title.classList.add("nav-item-title", "no-select"); + title.innerText = this.filename(); + this.dom_element.appendChild(icon); + this.dom_element.appendChild(title); + this.stat = stat; + if (stat && stat["inaccessible"]) { + this.dom_element.style.cursor = "not-allowed"; + } else { + this.dom_element.addEventListener("click", this); + this.dom_element.addEventListener("contextmenu", this); + } + this.is_hidden_file = this.filename().startsWith('.'); + if (this.is_hidden_file) + icon.style.opacity = 0.5; + this.dom_element.title = this.filename(); + if (nav_window_ref && nav_window_ref.item_display === "list") { + let mode = document.createElement("div"); + let owner = document.createElement("div"); + let group = document.createElement("div"); + let size = document.createElement("div"); + mode.title = mode.innerText = this.stat["mode-str"]; + owner.title = owner.innerText = this.stat["owner"]; + group.title = group.innerText = this.stat["group"]; + size.title = size.innerText = format_bytes(this.stat["size"]); + mode.classList.add("nav-item-title", "no-select"); + owner.classList.add("nav-item-title", "no-select"); + group.classList.add("nav-item-title", "no-select"); + size.classList.add("nav-item-title", "no-select"); + this.dom_element.appendChild(mode); + this.dom_element.appendChild(owner); + this.dom_element.appendChild(group); + this.dom_element.appendChild(size); + } + } + + /** + * + * @param {Event} e + */ + handleEvent(e) { + switch (e.type) { + case "click": + this.nav_window_ref.set_selected(this, e.shiftKey, e.ctrlKey); + this.context_menu_ref.hide(); + e.stopPropagation(); + break; + case "contextmenu": + this.context_menu_ref.show(e, this); + e.preventDefault(); + e.stopPropagation(); + break; + } + } + + destroy() { + while (this.dom_element.firstChild) { + this.dom_element.removeChild(this.dom_element.firstChild); + } + if (this.dom_element.parentElement) + this.dom_element.parentElement.removeChild(this.dom_element); + } + + /** + * + * @returns {string} + */ + filename() { + var name = this.path[this.path.length - 1]; + if (!name) + name = "/"; + return name; + } + + /** + * + * @returns {string} + */ + path_str() { + return "/" + this.path.join("/"); + } + + /** + * + * @returns {string} + */ + parent_dir() { + return this.path.slice(0, this.path.length - 1); + } + + show() { + this.dom_element.style.display = "flex"; + } + + hide() { + this.dom_element.style.display = "none"; + } + + /** + * + * @returns {object} + */ + get_properties() { + return this.stat; + } + + /** + * + * @returns {number} + */ + get_permissions() { + return this.stat["mode"] & 0o777; + } + + /** + * + * @param {number} new_perms + * @returns {Promise} + */ + chmod(new_perms) { + return new Promise((resolve, reject) => { + var proc = cockpit.spawn( + ["chmod", (new_perms & 0o777).toString(8), this.path_str()], + {superuser: "try", err: "out"} + ); + proc.done((data) => { + resolve(); + }); + proc.fail((e, data) => { + reject(data); + }); + }); + } + + /** + * + * @param {string} new_owner + * @param {string} new_group + * @returns {Promise} + */ + chown(new_owner, new_group) { + return new Promise((resolve, reject) => { + if (!new_owner && !new_group) { + resolve(); + return; + } + var cmd = ""; + var arg = ""; + if (new_group && !new_owner) { + cmd = "chgrp"; + arg = new_group; + } else { + cmd = "chown"; + arg = new_owner; + if (new_group) + arg += ":" + new_group; + } + var proc = cockpit.spawn( + [cmd, arg, this.path_str()], + {superuser: "try", err: "out"} + ); + proc.done((data) => { + resolve(); + }); + proc.fail((e, data) => { + reject(data); + }); + }); + } + + /** + * + * @param {string} new_path + * @returns {Promise} + */ + mv(new_path) { + return new Promise((resolve, reject) => { + var proc = cockpit.spawn( + ["mv", "-n", this.path_str(), [this.nav_window_ref.pwd().path_str(), new_path].join("/")], + {superuser: "try", err: "out"} + ); + proc.done((data) => { + resolve(); + }); + proc.fail((e, data) => { + reject(data); + }); + }); + } + + /** + * + * @param {string} extra_properties + */ + show_properties(extra_properties = "") { + var selected_name_fields = document.getElementsByClassName("nav-info-column-filename"); + for (let elem of selected_name_fields) { + elem.innerHTML = this.filename(); + elem.title = this.filename(); + } + var html = ""; + html += property_entry_html("Mode", this.stat["mode-str"]); + html += property_entry_html("Owner", this.stat["owner"] + " (" + this.stat["uid"] + ")"); + html += property_entry_html("Group", this.stat["group"] + " (" + this.stat["gid"] + ")"); + html += property_entry_html("Size", format_bytes(this.stat["size"])); + html += property_entry_html("Accessed", format_time(this.stat["atime"])); + html += property_entry_html("Modified", format_time(this.stat["mtime"])); + html += property_entry_html("Created", format_time(this.stat["ctime"])); + html += extra_properties; + document.getElementById("nav-info-column-properties").innerHTML = html; + } + + populate_edit_fields() { + document.getElementById("nav-edit-filename").innerText = this.filename(); + var mode_bits = [ + "other-exec", "other-write", "other-read", + "group-exec", "group-write", "group-read", + "owner-exec", "owner-write", "owner-read" + ]; + for (let i = 0; i < mode_bits.length; i++) { + var bit_check = 1 << i; + var result = this.stat["mode"] & bit_check; + document.getElementById(mode_bits[i]).checked = (result != 0); + } + document.getElementById("nav-edit-owner").value = this.stat["owner"]; + document.getElementById("nav-edit-group").value = this.stat["group"]; + } +} diff --git a/navigator/components/NavFile.js b/navigator/components/NavFile.js new file mode 100644 index 0000000..a8ef137 --- /dev/null +++ b/navigator/components/NavFile.js @@ -0,0 +1,216 @@ +import {NavEntry} from "./NavEntry.js"; +import {NavDownloader} from "./NavDownloader.js"; +import {NavWindow} from "./NavWindow.js"; +import {property_entry_html} from "../functions.js"; + +export class NavFile extends NavEntry { + /** + * + * @param {string|string[]} path + * @param {object} stat + * @param {NavWindow} nav_window_ref + */ + constructor(path, stat, nav_window_ref) { + super(path, stat, nav_window_ref); + this.nav_type = "file"; + this.dom_element.nav_item_icon.classList.add("fas", "fa-file"); + this.double_click = false; + } + + /** + * + * @param {Event} e + */ + handleEvent(e) { + switch(e.type){ + case "click": + if (this.double_click) + this.open(); + else { // single click + this.double_click = true; + if(this.timeout) + clearTimeout(this.timeout) + this.timeout = setTimeout(() => { + this.double_click = false; + }, 500); + } + break; + case "keydown": + if (e.keyCode === 83 && e.ctrlKey === true) { + e.preventDefault(); + e.stopPropagation(); + this.write_to_file(); + } + break; + } + super.handleEvent(e); + } + + /** + * + * @returns {Promise} + */ + rm() { + return new Promise((resolve, reject) => { + var proc = cockpit.spawn( + ["rm", "-f", this.path_str()], + {superuser: "try", err: "out"} + ); + proc.done((data) => { + resolve(); + }); + proc.fail((e, data) => { + reject(data); + }); + }); + } + + async open() { + var proc_output = await cockpit.spawn(["file", "--mime-type", this.path_str()], {superuser: "try"}); + var fields = proc_output.split(/:(?=[^:]+$)/); // ensure it's the last : with lookahead + var type = fields[1].trim(); + + if ((/^text/.test(type) || /^inode\/x-empty$/.test(type) || this.stat["size"] === 0)) { + this.show_edit_file_contents(); + } else { + console.log("Unknown mimetype: " + type); + if (window.confirm("Can't open " + this.filename() + " for editing. Download?")) { + var download = new NavDownloader(this); + download.download(); + } + } + } + + async show_edit_file_contents() { + window.removeEventListener("keydown", this.nav_window_ref); + this.nav_window_ref.disable_buttons_for_editing(); + var contents = ""; + try { + contents = await cockpit.file(this.path_str(), {superuser: "try"}).read(); + } catch (e) { + this.nav_window_ref.enable_buttons(); + window.alert(e.message); + return; + } + var text_area = document.getElementById("nav-edit-contents-textarea"); + text_area.value = contents; + text_area.addEventListener("keydown", this); + document.getElementById("nav-cancel-edit-contents-btn").onclick = this.hide_edit_file_contents.bind(this); + document.getElementById("nav-continue-edit-contents-btn").onclick = this.write_to_file.bind(this); + document.getElementById("nav-edit-contents-header").innerText = "Editing " + this.path_str(); + document.getElementById("nav-contents-view").style.display = "none"; + document.getElementById("nav-edit-contents-view").style.display = "flex"; + } + + async write_to_file() { + var new_contents = document.getElementById("nav-edit-contents-textarea").value; + try { + if (new_contents.length) + await cockpit.file(this.path_str(), {superuser: "try"}).replace(new_contents); + else + await cockpit.script("echo -n > $1", [this.path_str()], {superuser: "try"}); + } catch (e) { + window.alert(e.message); + } + this.nav_window_ref.refresh(); + this.hide_edit_file_contents(); + } + + hide_edit_file_contents() { + window.addEventListener("keydown", this.nav_window_ref); + document.getElementById("nav-edit-contents-textarea").removeEventListener("keydown", this); + document.getElementById("nav-edit-contents-view").style.display = "none"; + document.getElementById("nav-contents-view").style.display = "flex"; + this.nav_window_ref.enable_buttons(); + } +} + +export class NavFileLink extends NavFile{ + /** + * + * @param {string} path + * @param {object} stat + * @param {NavWindow} nav_window_ref + * @param {string} link_target + */ + constructor(path, stat, nav_window_ref, link_target) { + super(path, stat, nav_window_ref); + var link_icon = this.dom_element.nav_item_icon.link_icon = document.createElement("i"); + link_icon.classList.add("fas", "fa-link", "nav-item-symlink-symbol-file"); + this.dom_element.nav_item_icon.appendChild(link_icon); + this.double_click = false; + this.link_target = link_target; + this.dom_element.nav_item_title.style.fontStyle = "italic"; + if (nav_window_ref.item_display === "list") + this.dom_element.nav_item_title.innerHTML += " → " + this.link_target; + } + + show_properties() { + var extra_properties = property_entry_html("Link Target", this.link_target); + super.show_properties(extra_properties); + } + + /** + * + * @returns {string} + */ + get_link_target_path() { + var target = ""; + if (this.link_target.charAt(0) === '/') + target = this.link_target; + else + target = this.parent_dir().join("/") + "/" + this.link_target; + if (target.charAt(0) !== '/') + target = '/' + target; + return target; + } + + async open() { + var target_path = this.get_link_target_path(); + var proc_output = await cockpit.spawn(["file", "--mime-type", target_path], {superuser: "try"}); + var fields = proc_output.split(/:(?=[^:]+$)/); // ensure it's the last : with lookahead + var type = fields[1].trim(); + + if ((/^text/.test(type) || /^inode\/x-empty$/.test(type) || this.stat["size"] === 0)) { + this.show_edit_file_contents(); + } else { + console.log("Unknown mimetype: " + type); + window.alert("Can't open " + this.filename() + " for editing."); + } + } + + async show_edit_file_contents() { + window.removeEventListener("keydown", this.nav_window_ref); + this.nav_window_ref.disable_buttons_for_editing(); + document.getElementById("pwd").disabled = true; + var target_path = this.get_link_target_path(); + var contents = ""; + try { + contents = await cockpit.file(target_path, {superuser: "try"}).read(); + } catch(e) { + this.nav_window_ref.enable_buttons(); + window.alert(e.message); + return; + } + var text_area = document.getElementById("nav-edit-contents-textarea"); + text_area.value = contents; + text_area.addEventListener("keydown", this); + document.getElementById("nav-cancel-edit-contents-btn").onclick = this.hide_edit_file_contents.bind(this); + document.getElementById("nav-continue-edit-contents-btn").onclick = this.write_to_file.bind(this); + document.getElementById("nav-edit-contents-header").innerHTML = "Editing " + this.path_str() + ' ' + this.get_link_target_path(); + document.getElementById("nav-contents-view").style.display = "none"; + document.getElementById("nav-edit-contents-view").style.display = "flex"; + } + + async write_to_file() { + var target_path = this.get_link_target_path(); + var new_contents = document.getElementById("nav-edit-contents-textarea").value; + try { + await cockpit.file(target_path, {superuser: "try"}).replace(new_contents); + } catch (e) { + window.alert(e.message); + } + this.nav_window_ref.refresh(); + this.hide_edit_file_contents(); + } +} diff --git a/navigator/components/NavWindow.js b/navigator/components/NavWindow.js new file mode 100644 index 0000000..9992291 --- /dev/null +++ b/navigator/components/NavWindow.js @@ -0,0 +1,742 @@ +import {NavEntry} from "./NavEntry.js"; +import {NavDir} from "./NavDir.js"; +import {NavContextMenu} from "./NavContextMenu.js"; +import {NavDragDrop} from "./NavDragDrop.js"; +import {SortFunctions} from "./SortFunctions.js"; +import {format_bytes} from "../functions.js"; + +export class NavWindow { + constructor() { + this.item_display = "grid"; + this.path_stack = (localStorage.getItem('navigator-path') ?? '/').split('/'); + this.path_stack = this.path_stack.map((_, index) => new NavDir([...this.path_stack.slice(0, index + 1)].filter(part => part != ''), this)); + + this.path_stack_index = this.path_stack.length - 1; + this.selected_entries = new Set([this.pwd()]); + this.entries = []; + this.window = document.getElementById("nav-contents-view"); + this.window.addEventListener("click", this); + this.window.addEventListener("contextmenu", this); + window.addEventListener("keydown", this); + this.last_selected_index = -1; + + this.context_menu = new NavContextMenu("nav-context-menu", this); + + this.clip_board = []; + + this.uploader = new NavDragDrop(this.window, this); + + this.sort_function = new SortFunctions(); + } + + /** + * + * @param {Event} e + */ + handleEvent(e) { + switch (e.type) { + case "click": + this.clear_selected(); + this.show_selected_properties(); + break; + case "contextmenu": + this.context_menu.show(e, this.pwd()); + e.preventDefault(); + break; + case "keydown": + if (e.keyCode === 46) { + this.delete_selected(); + } else if (e.keyCode === 65 && e.ctrlKey) { + this.select_all(); + e.preventDefault(); + } else if (e.keyCode === 67 && e.ctrlKey) { + this.copy(); + } else if (e.keyCode === 86 && e.ctrlKey) { + this.paste(); + } else if (e.keyCode === 88 && e.ctrlKey) { + this.cut(); + } + break; + default: + break; + } + } + + async refresh() { + localStorage.setItem('navigator-path', `/${this.path_stack[this.path_stack_index].path.join('/')}`); + + var num_dirs = 0; + var num_files = 0; + var bytes_sum = 0; + this.show_hidden = document.getElementById("nav-show-hidden").checked; + this.start_load(); + try { + var files = await this.pwd().get_children(this); + } catch(e) { + this.up(); + window.alert(e); + return; + } + while (this.entries.length) { + var entry = this.entries.pop(); + entry.destroy(); + } + files.sort((first, second) => { + if (first.nav_type === second.nav_type) { + return this.item_display === "list" + ? this.sort_function.get_func()(first, second) + : this.sort_function.name_asc(first, second); // default to sort by name in grid view + } + if (first.nav_type === "dir") + return -1; + return 1; + }); + files.forEach((file) => { + if (file.nav_type === "dir") + num_dirs++; + else { + num_files++; + bytes_sum += file.stat["size"]; + } + if(!file.is_hidden_file || this.show_hidden) { + this.window.appendChild(file.dom_element); + this.entries.push(file); + } + file.context_menu_ref = this.context_menu; + }); + document.getElementById("pwd").value = this.pwd().path_str(); + this.set_selected(this.pwd(), false, false); + this.show_selected_properties(); + document.getElementById("nav-num-dirs").innerText = num_dirs.toString(); + document.getElementById("nav-num-files").innerText = num_files.toString(); + document.getElementById("nav-num-bytes").innerText = format_bytes(bytes_sum); + this.stop_load(); + this.set_nav_button_state(); + } + + set_nav_button_state() { + document.getElementById("nav-back-btn").disabled = (this.path_stack_index === 1); + document.getElementById("nav-forward-btn").disabled = (this.path_stack_index === this.path_stack.length - 1); + document.getElementById("nav-up-dir-btn").disabled = (this.pwd().path_str() === "/"); + } + + /** + * + * @returns {NavDir} + */ + pwd() { + return this.path_stack[this.path_stack_index]; + } + + /** + * + * @param {NavDir} new_dir + */ + cd(new_dir) { + this.path_stack.length = this.path_stack_index + 1; + this.path_stack.push(new_dir); + this.path_stack_index = this.path_stack.length - 1; + this.refresh(); + } + + back() { + this.path_stack_index = Math.max(this.path_stack_index - 1, 0); + this.refresh(); + } + + forward() { + this.path_stack_index = Math.min(this.path_stack_index + 1, this.path_stack.length - 1); + this.refresh(); + } + + up() { + if(this.pwd().path_str() !== '/') + this.cd(new NavDir(this.pwd().parent_dir())); + } + + /** + * + * @param {NavEntry} entry + * @param {Boolean} select_range + * @param {Boolean} append + */ + set_selected(entry, select_range, append) { + this.hide_edit_selected(); + for (let i of this.selected_entries) { + i.dom_element.classList.remove("nav-item-selected"); + if (i.nav_type === "dir") { + i.dom_element.nav_item_icon.classList.remove("fa-folder-open"); + i.dom_element.nav_item_icon.classList.add("fa-folder"); + } + } + var to_be_selected = []; + if (append && this.selected_entries.has(entry)) { + this.selected_entries.delete(entry); + if (this.selected_entries.size === 0) { + this.clear_selected(); + } + } else if (select_range && this.last_selected_index !== -1) { + var start = this.last_selected_index; + var end = this.entries.indexOf(entry); + if (end < start) + [start, end] = [end, start]; + if (end === -1) + return; + to_be_selected = this.entries.slice(start, end + 1).filter(entry => !entry.stat["inaccessible"]); + } else { + if (!append) + this.selected_entries.clear(); + to_be_selected = [entry]; + } + for (let i of to_be_selected) { + this.selected_entries.add(i); + } + for (let i of this.selected_entries) { + i.dom_element.classList.add("nav-item-selected"); + if (i.nav_type === "dir") { + i.dom_element.nav_item_icon.classList.remove("fa-folder"); + i.dom_element.nav_item_icon.classList.add("fa-folder-open"); + } + } + if (this.selected_entries.size > 1){ + var name_fields = document.getElementsByClassName("nav-info-column-filename"); + for (let name_field of name_fields) { + name_field.innerText = this.selected_entries.size.toString() + " selected" + name_field.title = name_field.innerText; + } + document.getElementById("nav-info-column-properties").innerHTML = ""; + } else { + this.show_selected_properties(); + } + this.last_selected_index = this.entries.indexOf(entry); + } + + clear_selected() { + this.set_selected(this.pwd(), false, false); + } + + selected_entry() { + return [...this.selected_entries][this.selected_entries.size - 1]; + } + + none_selected() { + return this.selected_entries.size === 1 && this.selected_entry() === this.pwd(); + } + + show_selected_properties() { + this.selected_entry().show_properties(); + } + + show_edit_selected() { + var dangerous_dirs = [ + "/", + "/usr", + "/bin", + "/sbin", + "/lib", + "/lib32", + "/lib64", + "/usr/bin", + "/usr/include", + "/usr/lib", + "/usr/lib32", + "/usr/lib64", + "/usr/sbin", + ]; + var dangerous_selected = []; + for (let i of this.selected_entries) { + var path = i.path_str(); + if (dangerous_dirs.includes(path)) { + dangerous_selected.push(path); + } + } + if (dangerous_selected.length > 0) { + var dangerous_selected_str = ""; + if (dangerous_selected.length > 2) { + var last = dangerous_selected.pop(); + dangerous_selected_str = dangerous_selected.join(", "); + dangerous_selected_str += ", and " + last; + } else if (dangerous_selected.length === 2) { + dangerous_selected_str = dangerous_selected.join(" and "); + } else { + dangerous_selected_str = dangerous_selected[0]; + } + if (!window.confirm( + "Warning: editing " + + dangerous_selected_str + + " can be dangerous. Are you sure?" + )) { + return; + } + } else if (this.selected_entries.size > 1) { + if (!window.confirm( + "Warning: are you sure you want to edit permissions for " + + this.selected_entries.size + + " files?" + )) { + return; + } + } + if (this.selected_entries.size === 1) { + this.selected_entry().populate_edit_fields(); + document.getElementById("selected-files-list-header").innerText = ""; + document.getElementById("selected-files-list").innerText = ""; + } else { + for (let field of ["owner", "group"]) { + document.getElementById("nav-edit-" + field).value = ""; + } + var filename = document.getElementById("nav-edit-filename"); + filename.value = "N/A"; + filename.disabled = true; + for (let checkbox of document.getElementsByClassName("mode-checkbox")) { + checkbox.checked = false; + } + var targets = []; + for (let target of this.selected_entries) { + targets.push(target.filename()); + } + var targets_str = targets.join(", "); + document.getElementById("selected-files-list-header").innerText = "Applying edits to:"; + document.getElementById("selected-files-list").innerText = targets_str; + } + this.update_permissions_preview(); + this.changed_mode = false; + document.getElementById("nav-mode-preview").innerText = "unchanged"; + document.getElementById("nav-edit-properties").style.display = "flex"; + document.getElementById("nav-show-properties").style.display = "none"; + } + + hide_edit_selected() { + document.getElementById("nav-show-properties").style.display = "flex"; + document.getElementById("nav-edit-properties").style.display = "none"; + } + + /** + * + * @returns {number} + */ + get_new_permissions() { + var category_list = ["other", "group", "owner"]; + var action_list = ["exec", "write", "read"]; + var result = 0; + var bit = 0; + for (let category of category_list) { + for (let action of action_list) { + if (document.getElementById(category + "-" + action).checked) + result |= 1 << bit; + bit++; + } + } + return result; + } + + update_permissions_preview() { + var new_perms = this.get_new_permissions(); + var text = format_permissions(new_perms); + text += " (" + (new_perms & 0o777).toString(8) + ")"; + document.getElementById("nav-mode-preview").innerText = text; + this.changed_mode = true; + } + + async apply_edit_selected() { + var new_owner = document.getElementById("nav-edit-owner").value; + var new_group = document.getElementById("nav-edit-group").value; + var new_perms = this.get_new_permissions(); + + for (let entry of this.selected_entries) { + if ( + new_owner !== entry.stat["owner"] || + new_group !== entry.stat["group"] + ) { + try { + await entry.chown(new_owner, new_group); + } catch(e) { + window.alert(e); + } + } + if (this.changed_mode && (new_perms & 0o777) !== (entry.stat["mode"] & 0o777)) { + try { + await entry.chmod(new_perms); + } catch(e) { + window.alert(e); + } + } + } + this.refresh(); + this.hide_edit_selected(); + } + + async delete_selected() { + var prompt = ""; + if (this.selected_entries.size > 1) { + prompt = "Deleting " + this.selected_entries.size + " files. This cannot be undone. Are you sure?"; + } else { + prompt = "Deleting `" + this.selected_entry().path_str() + "` cannot be undone. Are you sure?"; + } + if (!window.confirm(prompt)) { + return; + } + for (let target of this.selected_entries) { + try { + await target.rm(); + } catch(e) { + window.alert(e); + } + } + this.refresh(); + } + + async mkdir() { + var new_dir_name = window.prompt("Directory Name: "); + if (new_dir_name === null) + return; + if (new_dir_name.includes("/")) { + window.alert("Directory name can't contain `/`."); + return; + } + var promise = new Promise((resolve, reject) => { + var proc = cockpit.spawn( + ["mkdir", this.pwd().path_str() + "/" + new_dir_name], + {superuser: "try", err: "out"} + ); + proc.done((data) => { + resolve(); + }); + proc.fail((e, data) => { + reject(data); + }); + }); + try { + await promise; + } catch(e) { + window.alert(e); + } + this.refresh(); + } + + async touch() { + var new_file_name = window.prompt("File Name: "); + if (new_file_name === null) + return; + if (new_file_name.includes("/")) { + window.alert("File name can't contain `/`."); + return; + } + var promise = new Promise((resolve, reject) => { + var proc = cockpit.spawn( + ["/usr/share/cockpit/navigator/scripts/touch.py", this.pwd().path_str() + "/" + new_file_name], + {superuser: "try", err: "out"} + ); + proc.done((data) => { + resolve(); + }); + proc.fail((e, data) => { + reject(data); + }); + }); + try { + await promise; + } catch(e) { + window.alert(e); + } + this.refresh(); + } + + async ln(default_target = "") { + var link_target = window.prompt("Link Target: ", default_target); + if (link_target === null) + return; + var link_name = window.prompt("Link Name: "); + if (link_name === null) + return; + if (link_name.includes("/")) { + window.alert("Link name can't contain `/`."); + return; + } + var link_path = this.pwd().path_str() + "/" + link_name; + var promise = new Promise((resolve, reject) => { + var proc = cockpit.spawn( + ["ln", "-sn", link_target, link_path], + {superuser: "try", err: "out"} + ); + proc.done((data) => { + resolve(); + }); + proc.fail((e, data) => { + reject(data); + }); + }); + try { + await promise; + } catch(e) { + window.alert(e); + } + this.refresh(); + } + + cut() { + this.clip_board = [...this.selected_entries]; + this.copy_or_move = "move"; + this.paste_cwd = this.pwd().path_str(); + this.context_menu.menu_options["paste"].style.display = "flex"; + } + + copy() { + this.clip_board = [...this.selected_entries]; + this.copy_or_move = "copy"; + this.paste_cwd = this.pwd().path_str(); + this.context_menu.menu_options["paste"].style.display = "flex"; + } + + paste() { + this.paste_clipboard(); + this.context_menu.hide_paste(); + } + + async paste_clipboard() { + this.start_load(); + var cmd = ["/usr/share/cockpit/navigator/scripts/paste.py"]; + var dest = this.pwd().path_str(); + if (this.copy_or_move === "move") { + cmd.push("-m"); + } + cmd.push(this.paste_cwd); + for (let item of this.clip_board) { + cmd.push(item.path_str()); + } + cmd.push(dest); + this.clip_board.length = 0; // clear clipboard + var promise = new Promise((resolve, reject) => { + var proc = cockpit.spawn( + cmd, + {superuser: "try", err: "ignore"} + ); + proc.stream((data) => { + var payload = JSON.parse(data); + if (payload["wants-response"]) { + var user_response = window.confirm(payload["message"]); + proc.input(JSON.stringify(user_response) + "\n", true); + } else { + window.alert(payload["message"]); + } + }); + proc.done((data) => { + resolve(); + }); + proc.fail((e, data) => { + reject("Paste failed."); + }); + }); + try { + await promise; + } catch(e) { + window.alert(e); + } + this.stop_load(); + this.refresh(); + } + + /** + * + * @param {Event} e + */ + nav_bar_event_handler(e) { + switch(e.key){ + case 'Enter': + this.nav_bar_cd(); + break; + default: + break; + } + } + + nav_bar_cd() { + var new_path = document.getElementById("pwd").value; + while (new_path.charAt(new_path.length - 1) === '/' && new_path.length > 1) + new_path = new_path.substr(0, new_path.length - 1); + this.cd(new NavDir(new_path)); + } + + async nav_bar_update_choices() { + var list = document.getElementById("possible-paths"); + var partial_path_str = document.getElementById("pwd").value; + var last_delim = partial_path_str.lastIndexOf('/'); + if(last_delim === -1) + return; + var parent_path_str = partial_path_str.slice(0, last_delim); + if(this.nav_bar_last_parent_path_str === parent_path_str) + return; + this.nav_bar_last_parent_path_str = parent_path_str; + var parent_dir = new NavDir(parent_path_str); + var objs; + try { + objs = await parent_dir.get_children(this); + } catch(e) { + return; + } + objs = objs.filter((child) => {return child.nav_type === "dir"}); + while(list.firstChild) + list.removeChild(list.firstChild); + objs.forEach((obj) => { + var option = document.createElement("option"); + option.value = obj.path_str(); + list.appendChild(option); + }); + } + + start_load() { + document.getElementById("nav-loader-container").style.display = "block"; + var buttons = document.getElementsByTagName("button"); + for (let button of buttons) { + button.disabled = true; + } + } + + stop_load() { + document.getElementById("nav-loader-container").style.display = "none"; + var buttons = document.getElementsByTagName("button"); + for (let button of buttons) { + button.disabled = false; + } + } + + /** + * + * @param {Event} e + */ + toggle_show_hidden(e) { + localStorage.setItem('show-hidden-files', e.target.checked); + + var icon = document.getElementById("nav-show-hidden-icon"); + if (e.target.checked) { + icon.classList.remove("fa-low-vision"); + icon.classList.add("fa-eye"); + } else { + icon.classList.remove("fa-eye"); + icon.classList.add("fa-low-vision"); + } + this.refresh(); + } + + /** + * + * @returns {Promise} + */ + get_system_users() { + return new Promise(async (resolve, reject) => { + var proc = cockpit.spawn(["getent", "passwd"], {err: "ignore", superuser: "try"}); + proc.fail((e, data) => { + reject(data); + }); + var list = document.getElementById("possible-owners"); + while(list.firstChild) { + list.removeChild(list.firstChild); + } + var passwd; + try { + passwd = await proc; + } catch(e) { + reject(e); + return; + } + var passwd_entries = passwd.split("\n"); + for (let entry of passwd_entries) { + var cols = entry.split(":"); + var username = cols[0]; + var option = document.createElement("option"); + option.value = username; + list.appendChild(option); + } + resolve(); + }); + } + + /** + * + * @returns {Promise} + */ + get_system_groups() { + return new Promise(async (resolve, reject) => { + var proc = cockpit.spawn(["getent", "group"], {err: "ignore", superuser: "try"}); + proc.fail((e, data) => { + reject(data); + }); + var list = document.getElementById("possible-groups"); + while(list.firstChild) { + list.removeChild(list.firstChild); + } + var group + try { + group = await proc; + } catch(e) { + reject(e); + return; + } + var group_entries = group.split("\n"); + for (let entry of group_entries) { + var cols = entry.split(":"); + var groupname = cols[0]; + var option = document.createElement("option"); + option.value = groupname; + list.appendChild(option); + } + resolve(); + }); + } + + disable_buttons_for_editing() { + for (let button of document.getElementsByTagName("button")) { + if (!button.classList.contains("editor-btn")) + button.disabled = true; + } + document.getElementById("pwd").disabled = true; + } + + enable_buttons() { + for (let button of document.getElementsByTagName("button")) { + button.disabled = false; + } + document.getElementById("pwd").disabled = false; + this.set_nav_button_state(); + } + + select_all() { + this.selected_entries.clear(); + for (let entry of this.entries) { + if (!entry.is_hidden_file || this.show_hidden) { + this.set_selected(entry, false, true); + } + } + } + + async switch_item_display() { + var button = document.getElementById("nav-item-display-icon"); + if (this.item_display === "grid") { + this.item_display = "list"; + await this.refresh(); + this.window.classList.remove("contents-view-grid"); + this.window.classList.add("contents-view-list"); + button.classList.remove("fa-list"); + button.classList.add("fa-th"); + } else { + this.item_display = "grid"; + await this.refresh(); + this.window.classList.remove("contents-view-list"); + this.window.classList.add("contents-view-grid"); + button.classList.remove("fa-th"); + button.classList.add("fa-list"); + } + + localStorage.setItem("item-display", this.item_display); + } + + search_filter(event) { + var search_name = event.target.value; + this.entries.forEach((entry) => { + if (entry.filename().toLowerCase().startsWith(search_name.toLowerCase())) + entry.show(); + else + entry.hide(); + }); + } +} diff --git a/navigator/components/SortFunctions.js b/navigator/components/SortFunctions.js new file mode 100644 index 0000000..96bd4ad --- /dev/null +++ b/navigator/components/SortFunctions.js @@ -0,0 +1,74 @@ +export class SortFunctions { + constructor() { + this.orders = { + name: "asc", + owner: "asc", + group: "asc", + size: "asc", + } + this.icons = {}; + for (let option of ["name", "owner", "group", "size"]) { + this.icons[option] = document.getElementById(`sort-${option}-icon`); + } + this.current_choice = "name"; + } + + get_func() { + return this[`${this.current_choice}_${this.orders[this.current_choice]}`]; + } + + set_func(option) { + if (this.current_choice === option) { + if (this.orders[this.current_choice] === "asc") { + this.orders[this.current_choice] = "desc"; + this.icons[this.current_choice].classList.remove("fa-chevron-up"); + this.icons[this.current_choice].classList.add("fa-chevron-down"); + } else { + this.orders[this.current_choice] = "asc"; + this.icons[this.current_choice].classList.remove("fa-chevron-down"); + this.icons[this.current_choice].classList.add("fa-chevron-up"); + } + } else { + this.icons[this.current_choice].classList.remove("fa-chevron-up", "fa-chevron-down"); + this.current_choice = option; + if (this.orders[this.current_choice] === "asc") { + this.icons[this.current_choice].classList.add("fa-chevron-up"); + } else { + this.icons[this.current_choice].classList.add("fa-chevron-down"); + } + } + + } + + name_asc(first, second) { + return first.filename().localeCompare(second.filename()); + } + + name_desc(first, second) { + return second.filename().localeCompare(first.filename()); + } + + owner_asc(first, second) { + return first.stat["owner"].localeCompare(second.stat["owner"]); + } + + owner_desc(first, second) { + return second.stat["owner"].localeCompare(first.stat["owner"]); + } + + group_asc(first, second) { + return first.stat["group"].localeCompare(second.stat["group"]); + } + + group_desc(first, second) { + return second.stat["group"].localeCompare(first.stat["group"]); + } + + size_asc(first, second) { + return first.stat["size"] - second.stat["size"]; + } + + size_desc(first, second) { + return second.stat["size"] - first.stat["size"]; + } +} diff --git a/navigator/functions.js b/navigator/functions.js new file mode 100644 index 0000000..cb37dc3 --- /dev/null +++ b/navigator/functions.js @@ -0,0 +1,79 @@ +/** + * + * @param {string} key + * @param {string} value + * @returns {string} + */ + export function property_entry_html(key, value) { + var html = ''; + return html; +} + +/** + * + * @param {number} bytes + * @returns {string} + */ +export function format_bytes(bytes) { + if (bytes === 0) + return "0 B"; + var units = [" B", " KiB", " MiB", " GiB", " TiB", " PiB"]; + var index = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1); + var pow = Math.pow(1024, index); + var formatted = bytes / pow; + return formatted.toFixed(2).toString() + units[index]; +} + +/** + * + * @param {number} timestamp + * @returns {string} + */ +export function format_time(timestamp) { + var date = new Date(timestamp * 1000); + return date.toLocaleString(); +} + +/** + * + * @param {number} seconds + * @returns {string} + */ +export function format_time_remaining(seconds_) { + var hours = Math.floor(seconds_ / 3600); + var seconds = seconds_ % 3600; + var minutes = Math.floor(seconds / 60); + seconds = Math.floor(seconds % 60); + var out = ""; + if (hours) { + out = String(hours).padStart(2, '0') + ":"; + } + if (minutes) { + out += String(minutes).padStart(2, '0') + ":"; + } + out += String(seconds).padStart(2, '0'); + return out; +} + +/** + * + * @param {number} mode + * @returns {string} + */ +export function format_permissions(mode) { + var bit_list = ["x", "w", "r"]; + var result = ""; + for (let bit = 8; bit >= 0; bit--) { + var test_bit = 1 << bit; + var test_result = mode & test_bit; + if (test_result != 0) { + result += bit_list[bit % bit_list.length]; + } else { + result += "-"; + } + } + return result; +} \ No newline at end of file diff --git a/navigator/navigator.html b/navigator/navigator.html index 5df2ad9..4d7a51d 100644 --- a/navigator/navigator.html +++ b/navigator/navigator.html @@ -20,6 +20,7 @@ Navigator + @@ -28,7 +29,7 @@ - +