mirror of
https://github.com/tdurieux/anonymous_github.git
synced 2026-11-14 08:17:26 +01:00
The UI was uneven: the home page looked finished, but app pages used three different display typefaces, the anonymize flow changed frame between steps, and the dashboard, explorer and admin screens each had their own controls, status colours and edge-case handling. Make every screen follow one system: shared page header, grid, buttons, filters and status colours; a repository picker and step trail on anonymize; a "What reviewers will see" preview of the actual reviewer page; consistent file headers and viewers for every file type; truthful admin charts and tables. Fold the accumulated override blocks back into style.css (22% smaller, no visual change). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
175 lines
9.3 KiB
JavaScript
175 lines
9.3 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;
|
|
}
|
|
|
|
// Full names with a break opportunity (<wbr>) after ".", "-" and "_", so long
|
|
// names wrap at a separator rather than mid-word. Redaction slots stay whole.
|
|
function breakableNodes(text) {
|
|
const parts = redactedNodes(text);
|
|
const out = [];
|
|
for (const part of Array.isArray(parts) ? parts : [parts]) {
|
|
if (typeof part !== "string") { out.push(part); continue; }
|
|
const pieces = part.split(/(?<=[._-])/);
|
|
pieces.forEach((piece, i) => { out.push(piece); if (i < pieces.length - 1) out.push(h("wbr")); });
|
|
}
|
|
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 [];
|
|
// GitHub-style "a/b" rows join folder chains only, and only once the
|
|
// folder's listing is loaded: a folder that so far holds just the open
|
|
// file stays a real, expandable folder in its sorted place.
|
|
while (node.children?.length === 1 && node.children[0].children && props.page?.loadedFolders?.[(parent + "/" + name).slice(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);
|
|
// Names show in full: long ones wrap onto a second line, preferably after
|
|
// ".", "-" or "_" (a <wbr> after each), with CSS overflow-wrap as the fallback.
|
|
// Only pathological names (over 80 characters) still truncate in the middle so
|
|
// the end and extension stay visible; a redaction token is never split.
|
|
let splitName = false, head = name, tail = "", middle = "";
|
|
if (name.length > 80) {
|
|
let tailAt = name.length - 12;
|
|
for (let i = name.length - 18; i <= name.length - 6; i++) if (i > 0 && "-_. ".includes(name[i])) { tailAt = i; break; }
|
|
splitName = !/XXXX-\d+/.test(name.slice(Math.max(0, tailAt - 12)));
|
|
head = name.slice(0, tailAt).trimEnd(); tail = name.slice(tailAt).trimStart();
|
|
if (splitName) {
|
|
const budget = Math.max(8, 30 - tail.length - 1);
|
|
if (head.length > budget && !/XXXX-\d+/.test(head)) {
|
|
let at = -1;
|
|
for (let i = Math.min(budget, head.length); i >= 4; i--) if (".-_".includes(head[i])) { at = i; break; }
|
|
const full = name.slice(0, name.length - tail.length);
|
|
head = (at > 0 ? head.slice(0, at) : head.slice(0, budget)).trimEnd();
|
|
middle = full.slice(head.length);
|
|
}
|
|
}
|
|
}
|
|
const label = h("span", { class: { "tree-name": true, "tree-name-split": splitName, "tree-name-wrap": !splitName }, title: name.replace(/\bXXXX-(\d+)\b/g, "[redacted term $1]") },
|
|
splitName ? [h("span", { class: { "tree-name-head": true, "is-cut": !!middle } }, redactedNodes(head)), middle ? h("span", { class: "tree-name-middle" }, middle) : null, h("span", { class: "tree-name-tail" }, tail)] : breakableNodes(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 ? (node.children.length || !props.page?.loadedFolders?.[path.slice(1)] ? renderNodes(node.children, path) : h("ul", h("li", { class: "tree-empty" }, "Empty folder"))) : 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));
|
|
},
|
|
};
|