Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 77 additions & 12 deletions apps/gittensory-extension/content.js
Original file line number Diff line number Diff line change
@@ -1,27 +1,50 @@
const match = location.pathname.match(/^\/([^/]+)\/([^/]+)\/pull\/(\d+)/);
const target = matchPullRequestTarget(location.pathname);

if (match) {
if (target) {
mountOverlay(target);
}

function matchPullRequestTarget(pathname) {
const match = String(pathname ?? "").match(/^\/([^/]+)\/([^/]+)\/pull\/(\d+)(?:\/|$)/);
if (!match) return null;
const [, owner, repo, pullNumber] = match;
mountOverlay({ owner, repo, pullNumber: Number(pullNumber) });
return { owner, repo, pullNumber: Number(pullNumber) };
}

function mountOverlay(target) {
if (document.querySelector("[data-gittensory-pr-context]")) return;
const container = document.createElement("aside");
container.className = "gittensory-overlay";
const host = findPullRequestSidebar();
container.className = `gittensory-overlay ${host ? "gittensory-overlay--sidebar" : "gittensory-overlay--floating"}`;
container.dataset.gittensoryPrContext = "true";
container.innerHTML = `
<div class="gittensory-overlay__header">
<span class="gittensory-overlay__mark">G</span>
<span>Gittensory</span>
<span class="gittensory-overlay__privacy">Private</span>
<button type="button" class="gittensory-overlay__refresh" aria-label="Refresh Gittensory context">Refresh</button>
</div>
<div class="gittensory-overlay__body">Loading private context...</div>
`;
document.body.appendChild(container);
if (host) {
host.prepend(container);
} else {
document.body.appendChild(container);
}
const refresh = container.querySelector(".gittensory-overlay__refresh");
refresh?.addEventListener("click", () => load(container, target));
void load(container, target);
}

function findPullRequestSidebar() {
return (
document.querySelector("#partial-discussion-sidebar") ||
document.querySelector("[data-testid='pr-sidebar']") ||
document.querySelector(".Layout-sidebar") ||
document.querySelector(".discussion-sidebar")
);
}

async function load(container, target) {
const body = container.querySelector(".gittensory-overlay__body");
if (!body) return;
Expand All @@ -31,11 +54,43 @@ async function load(container, target) {
body.innerHTML = `<div class="gittensory-overlay__error">${escapeHtml(response?.error || "Context unavailable")}</div>`;
return;
}
const panels = Array.isArray(response.payload?.panels) ? response.payload.panels : [];
body.innerHTML = panels
body.innerHTML = renderPullContext(response.payload);
renderActions(body, response.payload?.actions);
}

function renderPullContext(payload) {
const sections = Array.isArray(payload?.sections) ? payload.sections : [];
if (sections.length > 0) {
return sections.map(renderSection).join("");
}
return renderLegacyPanels(payload);
}

function renderSection(section) {
const rows = Array.isArray(section?.rows) ? section.rows : [];
const items = Array.isArray(section?.items) ? section.items : [];
const actions = Array.isArray(section?.actions) ? section.actions : [];
const tone = ["good", "warn", "neutral", "private"].includes(section?.tone) ? section.tone : "neutral";
return `
<section class="gittensory-overlay__panel gittensory-overlay__panel--${tone}">
<div class="gittensory-overlay__panel-head">
<strong>${escapeHtml(section?.label || "Panel")}</strong>
<span>${escapeHtml(section?.badge || "live")}</span>
</div>
${rows.length > 0 ? `<dl>${rows.map((row) => `<div><dt>${escapeHtml(row.label || "")}</dt><dd>${escapeHtml(row.value || "")}</dd></div>`).join("")}</dl>` : ""}
${items.length > 0 ? `<ul class="gittensory-overlay__list">${items.map((item) => `<li>${escapeHtml(item)}</li>`).join("")}</ul>` : ""}
${actions.length > 0 ? `<ol class="gittensory-overlay__actions">${actions.map((action) => `<li>${escapeHtml(action)}</li>`).join("")}</ol>` : ""}
</section>
`;
}

function renderLegacyPanels(payload) {
const panels = Array.isArray(payload?.panels) ? payload.panels : [];
if (panels.length === 0) return `<div class="gittensory-overlay__empty">No cached private context is available for this pull request.</div>`;
return panels
.map(
(panel) => `
<section class="gittensory-overlay__panel">
<section class="gittensory-overlay__panel gittensory-overlay__panel--neutral">
<div class="gittensory-overlay__panel-head">
<strong>${escapeHtml(panel.label || "Panel")}</strong>
<span>${escapeHtml(panel.badge || "live")}</span>
Expand All @@ -49,7 +104,6 @@ async function load(container, target) {
`,
)
.join("");
renderActions(body, response.payload?.actions);
}

function escapeHtml(value) {
Expand All @@ -73,15 +127,15 @@ function renderActions(body, actions) {
const list = Array.isArray(actions) ? actions : [];
if (list.length === 0) return;
const container = document.createElement("section");
container.className = "gittensory-overlay__panel";
container.className = "gittensory-overlay__panel gittensory-overlay__panel--private";
container.innerHTML = `
<div class="gittensory-overlay__panel-head">
<strong>Actions</strong>
<span>extension</span>
</div>
<div class="gittensory-overlay__actions"></div>
<div class="gittensory-overlay__action-buttons"></div>
`;
const actionsNode = container.querySelector(".gittensory-overlay__actions");
const actionsNode = container.querySelector(".gittensory-overlay__action-buttons");
if (!actionsNode) return;
for (const action of list) {
if (action?.id === "copy_public_safe_packet" && typeof action?.markdown === "string") {
Expand Down Expand Up @@ -119,3 +173,14 @@ function renderActions(body, actions) {
}
body.appendChild(container);
}

if (globalThis.__GITTENSORY_EXTENSION_TEST__) {
globalThis.__gittensoryContentInternals = {
matchPullRequestTarget,
renderPullContext,
renderSection,
renderLegacyPanels,
renderActions,
escapeHtml,
};
}
113 changes: 108 additions & 5 deletions apps/gittensory-extension/styles.css
Original file line number Diff line number Diff line change
@@ -1,17 +1,26 @@
.gittensory-overlay {
border: 1px solid rgba(126, 231, 188, 0.45);
border-radius: 8px;
background: #0f1117;
color: #f4f7f5;
font: 13px/1.45 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}

.gittensory-overlay--sidebar {
width: 100%;
margin-bottom: 16px;
overflow: hidden;
}

.gittensory-overlay--floating {
position: fixed;
right: 16px;
bottom: 16px;
z-index: 2147483647;
width: min(360px, calc(100vw - 32px));
max-height: min(560px, calc(100vh - 32px));
overflow: auto;
border: 1px solid rgba(126, 231, 188, 0.45);
border-radius: 8px;
background: #0f1117;
color: #f4f7f5;
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.45);
font: 13px/1.45 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}

.gittensory-overlay__header {
Expand All @@ -23,6 +32,17 @@
font-weight: 650;
}

.gittensory-overlay__privacy {
border: 1px solid rgba(126, 231, 188, 0.35);
border-radius: 999px;
color: #7ee7bc;
font-size: 10px;
font-weight: 700;
line-height: 1;
padding: 3px 6px;
text-transform: uppercase;
}

.gittensory-overlay__mark {
display: inline-flex;
width: 22px;
Expand Down Expand Up @@ -56,6 +76,18 @@
padding: 10px;
}

.gittensory-overlay__panel--good {
border-color: rgba(126, 231, 188, 0.35);
}

.gittensory-overlay__panel--warn {
border-color: rgba(255, 189, 105, 0.45);
}

.gittensory-overlay__panel--private {
border-color: rgba(145, 181, 255, 0.38);
}

.gittensory-overlay__panel + .gittensory-overlay__panel {
margin-top: 8px;
}
Expand All @@ -77,6 +109,16 @@
text-transform: uppercase;
}

.gittensory-overlay__panel--warn .gittensory-overlay__panel-head span {
border-color: rgba(255, 189, 105, 0.45);
color: #ffbd69;
}

.gittensory-overlay__panel--private .gittensory-overlay__panel-head span {
border-color: rgba(145, 181, 255, 0.45);
color: #91b5ff;
}

.gittensory-overlay dl {
margin: 0;
}
Expand All @@ -96,8 +138,69 @@
margin: 0;
color: rgba(244, 247, 245, 0.9);
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
text-align: right;
overflow-wrap: anywhere;
}

.gittensory-overlay__list,
.gittensory-overlay__actions {
margin: 8px 0 0;
padding-left: 18px;
}

.gittensory-overlay__list li,
.gittensory-overlay__actions li {
color: rgba(244, 247, 245, 0.78);
margin-top: 4px;
}

.gittensory-overlay__actions li {
color: rgba(244, 247, 245, 0.9);
}

.gittensory-overlay__action-buttons {
display: grid;
gap: 8px;
}

.gittensory-overlay__action-buttons button,
.gittensory-overlay__action-buttons details {
border: 1px solid rgba(145, 181, 255, 0.32);
border-radius: 6px;
background: rgba(145, 181, 255, 0.08);
color: #f4f7f5;
font: inherit;
}

.gittensory-overlay__action-buttons button {
cursor: pointer;
padding: 7px 9px;
text-align: left;
}

.gittensory-overlay__action-buttons details {
padding: 7px 9px;
}

.gittensory-overlay__action-buttons summary {
cursor: pointer;
font-weight: 650;
}

.gittensory-overlay__action-buttons ul {
margin: 6px 0 0;
padding-left: 18px;
}

.gittensory-overlay__action-buttons li {
color: rgba(244, 247, 245, 0.82);
margin-top: 4px;
}

.gittensory-overlay__error {
color: #ffb4a8;
}

.gittensory-overlay__empty {
color: rgba(244, 247, 245, 0.72);
}
33 changes: 31 additions & 2 deletions scripts/write-ui-openapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,12 @@ const checkOnly = process.argv.includes("--check");
const spec = buildOpenApiSpec();
spec.servers = [{ url: "https://gittensory-api.aethereal.dev", description: "Production" }];

const next = `${JSON.stringify(spec, null, 2)}\n`;
const current = await readFile(target, "utf8").catch(() => "");
const currentSpec = parseCurrentSpec(current);
const orderedSpec = currentSpec ? preserveExistingObjectOrder(spec, currentSpec) : spec;
const next = `${JSON.stringify(orderedSpec, null, 2)}\n`;

if (checkOnly) {
const current = await readFile(target, "utf8").catch(() => "");
if (current !== next) {
console.error("apps/gittensory-ui/public/openapi.json is stale; run npm run ui:openapi.");
process.exit(1);
Expand All @@ -24,3 +26,30 @@ if (checkOnly) {
await writeFile(target, next);
console.log("wrote apps/gittensory-ui/public/openapi.json");
}

function parseCurrentSpec(currentText: string): Record<string, unknown> | null {
try {
return JSON.parse(currentText) as Record<string, unknown>;
} catch {
return null;
}
}

function preserveExistingObjectOrder<T>(next: T, current: unknown): T {
if (Array.isArray(next)) return next.map((item, index) => preserveExistingObjectOrder(item, Array.isArray(current) ? current[index] : undefined)) as T;
if (!isPlainObject(next)) return next;

const currentObject = isPlainObject(current) ? current : {};
const ordered: Record<string, unknown> = {};
for (const key of Object.keys(currentObject)) {
if (key in next) ordered[key] = preserveExistingObjectOrder(next[key], currentObject[key]);
}
for (const key of Object.keys(next)) {
if (!(key in ordered)) ordered[key] = preserveExistingObjectOrder(next[key], currentObject[key]);
}
return ordered as T;
}

function isPlainObject(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
Loading