Files
anonymous_github/public/script/tree.js
T

115 lines
6.0 KiB
JavaScript

import { h, reactive, ref, computed, watch, nextTick } from "vue";
import { useRoute } from "vue-router";
export function fileHierarchy(files) {
const root = { children: [] };
const directories = new Map([["", root]]);
function directory(path) {
if (directories.has(path)) return directories.get(path);
const index = path.lastIndexOf("/");
const parent = directory(index < 0 ? "" : path.slice(0, index));
const node = { name: path.slice(index + 1), children: [] };
directories.set(path, node);
parent.children.push(node);
return node;
}
for (const file of files || []) {
const path = file.path ? `${file.path}/${file.name}` : file.name;
if (file.size == null) directory(path);
else directory(file.path || "").children.push({ ...file });
}
return root.children;
}
export default {
name: "FileTree",
props: ["file", "parent", "searchQuery", "searchResults", "page"],
setup(props) {
const route = useRoute();
const host = ref(null);
const opens = reactive(Object.create(null));
let focusedPath = null;
const hierarchy = computed(() => fileHierarchy(props.file));
const selectedPath = () => "/" + (Array.isArray(route.params.path) ? route.params.path.join("/") : route.params.path || "");
watch(() => [route.params.repoId, route.params.path], () => {
let path = "";
selectedPath().split("/").filter(Boolean).forEach(part => { path += "/" + part; opens[path] = true; });
}, { immediate: true });
const matching = computed(() => {
if (!props.searchQuery || !props.searchResults) return null;
const files = new Set(), folders = new Set();
props.searchResults.forEach(file => {
files.add(file.path ? `${file.path}/${file.name}` : file.name);
let path = "";
(file.path || "").split("/").filter(Boolean).forEach(part => { path = path ? path + "/" + part : part; folders.add(path); });
});
return { files, folders };
});
const visibleLinks = () => [...host.value.querySelectorAll("li > a")];
const focus = link => {
host.value.querySelector(".tree-focused")?.classList.remove("tree-focused");
if (!link) return;
focusedPath = link.dataset.path;
link.classList.add("tree-focused");
link.scrollIntoView?.({ block: "nearest" });
host.value.focus();
};
function keyboard(event) {
const links = visibleLinks();
const current = host.value.querySelector(".tree-focused") || links[0];
const index = links.indexOf(current);
if (!["ArrowDown", "ArrowUp", "ArrowRight", "ArrowLeft", "Enter"].includes(event.key)) return;
event.preventDefault();
if (event.key === "ArrowDown") focus(links[(index + 1) % links.length]);
if (event.key === "ArrowUp") focus(links[(index - 1 + links.length) % links.length]);
if (!current) return;
const li = current.closest("li");
if (event.key === "ArrowRight" && li.classList.contains("folder")) {
if (!li.classList.contains("open")) current.click();
else focus(li.querySelector(":scope > ul > li > a"));
}
if (event.key === "ArrowLeft") {
if (li.classList.contains("folder") && li.classList.contains("open")) current.click();
else focus(li.parentElement.closest("li.folder")?.querySelector(":scope > a"));
}
if (event.key === "Enter") current.click();
}
function renderNodes(nodes, parent = "") {
const sorted = [...nodes].sort((a, b) => Number(!!b.children) - Number(!!a.children) || a.name.localeCompare(b.name));
return h("ul", sorted.flatMap(original => {
let node = original, name = node.name;
const originalPath = (parent + "/" + name).slice(1);
if (matching.value && !matching.value.files.has(originalPath) && !matching.value.folders.has(originalPath)) return [];
while (node.children?.length === 1) { node = node.children[0]; name += "/" + node.name; }
const path = parent + "/" + name;
const folder = !!node.children;
const open = matching.value ? opens[path] !== false : !!opens[path];
const truncated = folder && props.page?.options?.truncatedFolders?.includes(path.slice(1));
const count = props.page?.fileCounts?.[path.slice(1)] || 0;
const icon = h("span", { class: folder ? "tree-icon-folder" : "far fa-file tree-icon-file", "aria-hidden": "true" },
folder ? h("svg", { viewBox: "0 0 14 12", focusable: "false" }, h("path", { d: "M1.5 10.5v-9h4l1.5 2h5.5v7z" })) : null);
const label = h("span", { class: "tree-name" }, name);
const onClick = event => {
focus(event.currentTarget);
if (!folder) return;
event.preventDefault();
opens[path] = !open;
if (opens[path] && !node.children.length) props.page.getFiles(path.slice(1));
nextTick(() => focus(visibleLinks().find(link => link.dataset.path === focusedPath)));
};
const link = h("a", {
"data-path": path,
href: folder ? undefined : `/r/${encodeURIComponent(route.params.repoId)}${encodePathForUrl(path)}`,
class: { "tree-focused": focusedPath === path },
onClick,
}, [folder ? h("span", { class: "tree-toggle" }) : parent ? h("span", { class: "tree-spacer" }) : null, icon, label,
truncated ? h("span", { class: "truncated-warning", title: props.page.fmt.translate("WARNINGS.folder_truncated") }, h("i", { class: "fas fa-exclamation-triangle" })) : null,
folder && count ? h("span", { class: "tree-count" }, count) : null]);
return h("li", { key: path, class: { file: true, folder, open, active: selectedPath() === path, truncated }, title: folder ? "" : `Size: ${humanFileSize(node.size || 0)}` }, [link, folder && open ? renderNodes(node.children, path) : null]);
}));
}
return () => h("tree", { ref: host, tabindex: 0, onKeydown: keyboard },
!props.file?.length ? "Empty repository" : matching.value?.files.size === 0 ? h("div", { class: "tree-search-empty" }, "No files found") : renderNodes(hierarchy.value));
},
};