mirror of
https://github.com/tdurieux/anonymous_github.git
synced 2026-11-14 08:17:26 +01:00
Viewer: full-height app bar with a Help menu, search + ZIP sidebar, one toolbar for README and code, reading mode with a scroll-tracking outline, docked find for Markdown and code, and redaction slots (a blank stone slot holding the term number) in prose, headings, code, the tree and the path. Landing and FAQ: live redaction demo, serif display headings, teal accent. Author screens: My work table with copy/open/edit actions, a usage meter, a single-container New anonymization flow with a sticky submit bar, themed form controls, and consistent GitHub connection cards. Admin: underline tabs, severity-first errors chart and list, error tiles that filter (older pages load until matches appear), queue and overview health states, and shared toolbar heights and borders. Fixes: guarded localStorage access, failed README images render a placeholder, phone hover states no longer stick, and UI tests updated for the new copy and markup. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
137 lines
6.8 KiB
JavaScript
137 lines
6.8 KiB
JavaScript
import { h, reactive, ref, computed, watch, nextTick } from "vue";
|
|
import { useRoute } from "vue-router";
|
|
|
|
// Redacted segments (XXXX-N) render as a slot holding the term number; the rest stays readable.
|
|
function redactedNodes(text) {
|
|
const out = []; let last = 0; const re = /\bXXXX-(\d+)\b/g; let m;
|
|
while ((m = re.exec(text))) {
|
|
if (m.index > last) out.push(text.slice(last, m.index));
|
|
out.push(h("span", { class: "redact-slot", role: "img", "aria-label": "Redacted term " + m[1] }, [h("i", null, m[1])]));
|
|
last = m.index + m[0].length;
|
|
}
|
|
if (!out.length) return text;
|
|
if (last < text.length) out.push(text.slice(last));
|
|
return out;
|
|
}
|
|
|
|
// File sizes from the API are bytes; the global humanFileSize expects bits.
|
|
function formatBytes(bytes) {
|
|
if (bytes < 1024) return `${bytes} B`;
|
|
const units = ["KB", "MB", "GB"];
|
|
let value = bytes / 1024;
|
|
let u = 0;
|
|
while (value >= 1024 && u < units.length - 1) { value /= 1024; u++; }
|
|
return `${value.toFixed(1)} ${units[u]}`;
|
|
}
|
|
|
|
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 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", title: name.replace(/\bXXXX-(\d+)\b/g, "[redacted term $1]") }, redactedNodes(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" }) : h("span", { class: "tree-spacer" }), icon, label,
|
|
truncated ? h("span", { class: "truncated-warning", title: props.page.fmt.translate("WARNINGS.folder_truncated") }, h("i", { class: "fas fa-exclamation-triangle" })) : null,
|
|
]);
|
|
return h("li", { key: path, class: { file: true, folder, open, active: selectedPath() === path, truncated }, title: folder ? "" : `Size: ${formatBytes(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));
|
|
},
|
|
};
|