Files
mp-silma-ai-aide/src/main.ts
T
2026-07-17 17:22:08 -05:00

774 lines
25 KiB
TypeScript

import { createIcons, CirclePlus, FolderGit2, GitBranch, Link, List, RefreshCw } from "lucide";
import "./styles.css";
import {
appStore,
CHROME_PROFILE_NAME,
findKnownUrlForBase,
type AddUrlMode,
type AppView,
type ScreenshotAttachment
} from "./store";
import type { GiteaIssueSummary, GiteaRepoSummary } from "./api";
import type { KnownUrl, LocalContext, LocalWorkspace } from "./localContext";
const status = document.querySelector<HTMLElement>("#status");
const refreshData = document.querySelector<HTMLButtonElement>("#refresh-data");
const addUrlButton = document.querySelector<HTMLButtonElement>("#add-url");
const addIssueButton = document.querySelector<HTMLButtonElement>("#add-issue");
const viewHeader = document.querySelector<HTMLElement>("#view-header");
const viewContent = document.querySelector<HTMLElement>("#view-content");
const viewButtons = Array.from(document.querySelectorAll<HTMLButtonElement>("[data-view]"));
createIcons({
icons: {
CirclePlus,
FolderGit2,
GitBranch,
Link,
List,
RefreshCw
}
});
function escapeHtml(value: string): string {
return value.replace(/[&<>"']/g, (char) => {
const replacements: Record<string, string> = {
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
"\"": "&quot;",
"'": "&#39;"
};
return replacements[char];
});
}
function formatDate(value: string, emptyText = "No date"): string {
if (!value) {
return emptyText;
}
return new Intl.DateTimeFormat(undefined, {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit"
}).format(new Date(value));
}
function formatSha(value: string): string {
return value ? value.slice(0, 8) : "unknown";
}
function allUrls(): KnownUrl[] {
const state = appStore.getState();
const deletedBases = new Set(state.deletedUrlBases);
const urls = [...(state.localContext?.knownUrls ?? []), ...state.userUrls].filter(
(knownUrl) => !deletedBases.has(baseUrlFor(knownUrl.url))
);
return urls.filter(
(knownUrl, index) => urls.findIndex((item) => baseUrlFor(item.url) === baseUrlFor(knownUrl.url)) === index
);
}
function baseUrlFor(value: string): string {
try {
const url = new URL(value);
if (url.protocol !== "http:" && url.protocol !== "https:") {
return "";
}
return `${url.origin}/`;
} catch {
return "";
}
}
function isActiveBaseUrlKnown(): boolean {
return Boolean(findKnownUrlForBase(appStore.getState()));
}
function getActiveKnownUrl(): KnownUrl | null {
return findKnownUrlForBase(appStore.getState());
}
function canAddIssue(): boolean {
return Boolean(getActiveKnownUrl()?.giteaRepo);
}
function getRepoWorkspaceLink(repo: GiteaRepoSummary): string {
const context = appStore.getState().localContext;
const worktree = context?.gitWorktrees.find((item) => item.giteaRepo?.fullName === repo.fullName);
if (!worktree) {
return `<span class="muted">No workspace link</span>`;
}
return `<span class="path-text">./${escapeHtml(worktree.relativePath)}</span>`;
}
function renderRepos(repos: GiteaRepoSummary[]): string {
if (!repos.length) {
return `<div class="empty-state">No Gitea repos loaded yet.</div>`;
}
return `
<div class="repo-list">
${repos
.map(
(repo) => `
<article class="repo-row">
<div class="repo-main">
<a class="item-title" href="${repo.webUrl}" target="_blank" rel="noreferrer">${escapeHtml(repo.fullName)}</a>
<span class="commit-message">${escapeHtml(repo.lastCommitMessage)}</span>
</div>
<span class="repo-meta">${formatDate(repo.lastCommitAt, "No commit date")}</span>
<span class="repo-meta">${formatSha(repo.lastCommitSha)}</span>
<span class="repo-link-cell">${getRepoWorkspaceLink(repo)}</span>
</article>
`
)
.join("")}
</div>
`;
}
function renderWorkspaces(workspaces: LocalWorkspace[]): string {
if (!workspaces.length) {
return `<div class="empty-state">No workspaces found in workspace_Father.</div>`;
}
return `
<div class="compact-list">
${workspaces
.map((workspace) => {
const links = workspace.linkedRepos.length
? workspace.linkedRepos
.map(
(repo) =>
`<a class="repo-link" href="${repo.webUrl}" target="_blank" rel="noreferrer">${escapeHtml(repo.fullName)}</a>`
)
.join("")
: `<span class="muted">No Gitea repo link established</span>`;
return `
<article class="workspace-row">
<div class="row-main">
<h2 class="item-title">${escapeHtml(workspace.name)}</h2>
<p class="path-text">${escapeHtml(workspace.path)}</p>
</div>
<div class="repo-link-list">${links}</div>
</article>
`;
})
.join("")}
</div>
`;
}
function renderUrls(urls: KnownUrl[]): string {
if (!urls.length) {
return `<div class="empty-state">No known URLs registered.</div>`;
}
return `
<div class="compact-list">
${urls
.map(
(knownUrl) => `
<article class="url-row">
<div class="row-main">
<h2 class="item-title">${escapeHtml(knownUrl.label)}</h2>
<a class="url-text" href="${knownUrl.url}" target="_blank" rel="noreferrer">${escapeHtml(knownUrl.url)}</a>
<span class="path-text">${escapeHtml(knownUrl.workspacePath)}</span>
${
knownUrl.giteaRepo
? `<a class="repo-link" href="${knownUrl.giteaRepo.webUrl}" target="_blank" rel="noreferrer">${escapeHtml(knownUrl.giteaRepo.fullName)}</a>`
: `<span class="muted">No Gitea repo link established</span>`
}
</div>
<div class="url-actions">
<button class="visit-button" type="button" data-open-url="${knownUrl.url}">Quick visit</button>
<button class="danger-button" type="button" data-delete-url="${knownUrl.url}">Delete</button>
</div>
</article>
`
)
.join("")}
</div>
`;
}
function selectedAttribute(value: string, selectedValue: string): string {
return value === selectedValue ? " selected" : "";
}
function workspaceOptions(context: LocalContext, selectedValue: string): string {
return context.workspaces
.map(
(workspace) =>
`<option value="${escapeHtml(workspace.name)}"${selectedAttribute(workspace.name, selectedValue)}>${escapeHtml(workspace.name)}</option>`
)
.join("");
}
function repoOptions(context: LocalContext, selectedValue: string): string {
const repos = context.gitWorktrees
.map((worktree) => worktree.giteaRepo)
.filter((repo): repo is NonNullable<typeof repo> => Boolean(repo))
.filter((repo, index, repos) => repos.findIndex((item) => item.fullName === repo.fullName) === index)
.sort((a, b) => a.fullName.localeCompare(b.fullName));
return repos
.map(
(repo) =>
`<option value="${escapeHtml(repo.fullName)}"${selectedAttribute(repo.fullName, selectedValue)}>${escapeHtml(repo.fullName)}</option>`
)
.join("");
}
function renderAddUrl(): string {
const state = appStore.getState();
const context = state.localContext;
if (!state.activeBaseUrl) {
return `<div class="empty-state">Open an http or https tab before adding a URL link.</div>`;
}
if (isActiveBaseUrlKnown()) {
return `<div class="empty-state">${escapeHtml(state.activeBaseUrl)} is already linked.</div>`;
}
if (!context) {
return `<div class="empty-state">Workspace context has not loaded yet.</div>`;
}
const mode = state.addUrlDraft.mode;
const selectedValue = state.addUrlDraft.selectedValue;
return `
<article class="item-card">
<div class="form-stack">
<label>
<span>Base URL</span>
<input type="text" value="${escapeHtml(state.activeBaseUrl)}" readonly />
</label>
<fieldset>
<legend>Link by</legend>
<label class="radio-row">
<input type="radio" name="add-url-mode" value="workspace" ${mode === "workspace" ? "checked" : ""} />
<span>Workspace</span>
</label>
<label class="radio-row">
<input type="radio" name="add-url-mode" value="repo" ${mode === "repo" ? "checked" : ""} />
<span>Gitea repo</span>
</label>
</fieldset>
<label>
<span>${mode === "repo" ? "Gitea repo" : "Workspace"}</span>
<select id="add-url-selection" ${mode ? "" : "disabled"}>
<option value="">Pick ${mode === "repo" ? "a repo" : "a workspace"}</option>
${mode === "repo" ? repoOptions(context, selectedValue) : workspaceOptions(context, selectedValue)}
</select>
</label>
<button id="save-url-link" class="primary-button" type="button" ${state.addUrlDraft.selectedValue ? "" : "disabled"}>
Link URL
</button>
</div>
</article>
`;
}
function renderAttachmentList(items: string[], emptyText: string): string {
if (!items.length) {
return `<span class="muted">${escapeHtml(emptyText)}</span>`;
}
return `
<ul class="attachment-list">
${items.map((item) => `<li>${escapeHtml(item)}</li>`).join("")}
</ul>
`;
}
function renderScreenshotList(screenshots: ScreenshotAttachment[], deleteAction: "issue" | "comment"): string {
if (!screenshots.length) {
return `<span class="muted">No screenshots captured.</span>`;
}
const attribute = deleteAction === "issue" ? "data-delete-screenshot" : "data-delete-comment-screenshot";
return `
<div class="screenshot-list">
${screenshots
.map(
(screenshot) => `
<figure class="screenshot-item">
<img src="${screenshot.dataUrl}" alt="${escapeHtml(screenshot.name)}" />
<figcaption>${escapeHtml(screenshot.name)}</figcaption>
<button class="danger-button compact-button" type="button" ${attribute}="${screenshot.id}">Delete</button>
</figure>
`
)
.join("")}
</div>
`;
}
function renderIssueRows(issues: GiteaIssueSummary[], selectedIssueNumber: number | null): string {
const state = appStore.getState();
if (!issues.length) {
return `<div class="empty-state">No issues found for this repo.</div>`;
}
return `
<div class="issue-list">
${issues
.map(
(issue) => `
<div class="issue-row" data-selected="${issue.number === selectedIssueNumber ? "true" : "false"}">
<button class="issue-select-button" type="button" data-select-issue="${issue.number}">
<span class="issue-number">#${issue.number}</span>
<span class="issue-title">${escapeHtml(issue.title)}</span>
<span class="issue-state">${escapeHtml(issue.state)}</span>
<span class="issue-meta">${formatDate(issue.updatedAt)}</span>
</button>
<button class="secondary-button compact-button" type="button" data-ready-issue="${issue.number}" ${state.isUpdatingIssue ? "disabled" : ""}>Ready</button>
<button class="danger-button compact-button" type="button" data-delete-issue="${issue.number}" ${state.isUpdatingIssue ? "disabled" : ""}>Delete</button>
</div>
`
)
.join("")}
</div>
`;
}
function renderIssueCommentForm(issue: GiteaIssueSummary): string {
const state = appStore.getState();
const draft = state.issueCommentDraft;
const canSubmit =
Boolean(draft.content.trim() || draft.screenshots.length || draft.files.length) && !state.isCreatingIssueComment;
return `
<article class="item-card issue-comment-card">
<div class="item-heading">
<a class="item-title" href="${issue.webUrl}" target="_blank" rel="noreferrer">#${issue.number} ${escapeHtml(issue.title)}</a>
<span class="issue-state">${escapeHtml(issue.state)}</span>
</div>
<div class="form-stack">
<label>
<span>Comment</span>
<textarea id="issue-comment-content" rows="6" placeholder="Add a comment">${escapeHtml(draft.content)}</textarea>
</label>
<section class="attachment-section">
<div class="attachment-heading">
<span>Screenshots</span>
<button id="capture-comment-screenshot" class="secondary-button" type="button" ${state.isCapturingIssueCommentScreenshot ? "disabled" : ""}>
${state.isCapturingIssueCommentScreenshot ? "Taking..." : "Take snapshot"}
</button>
</div>
${renderScreenshotList(draft.screenshots, "comment")}
</section>
<label>
<span>File attachments</span>
<input id="issue-comment-files" type="file" multiple />
</label>
${renderAttachmentList(
draft.files.map((file) => file.name),
"No files selected."
)}
<button id="submit-issue-comment" class="primary-button" type="button" ${canSubmit ? "" : "disabled"}>
${state.isCreatingIssueComment ? "Commenting..." : "Comment"}
</button>
</div>
</article>
`;
}
function renderTargetIssues(): string {
const state = appStore.getState();
const selectedIssue = state.targetIssues.find((issue) => issue.number === state.selectedIssueNumber) ?? null;
return `
<section class="issue-section">
<div class="section-heading">
<span>Issues</span>
<button id="refresh-target-issues" class="secondary-button compact-button" type="button" ${state.isLoadingTargetIssues ? "disabled" : ""}>
${state.isLoadingTargetIssues ? "Loading..." : "Refresh"}
</button>
</div>
${state.isLoadingTargetIssues && !state.targetIssues.length ? `<div class="empty-state">Loading issues...</div>` : renderIssueRows(state.targetIssues, state.selectedIssueNumber)}
${selectedIssue ? renderIssueCommentForm(selectedIssue) : ""}
</section>
`;
}
function renderAddIssue(): string {
const state = appStore.getState();
const knownUrl = getActiveKnownUrl();
const draft = state.issueDraft;
if (!knownUrl) {
return `<div class="empty-state">Open a known URL before creating an issue.</div>`;
}
if (!knownUrl.giteaRepo) {
return `<div class="empty-state">${escapeHtml(knownUrl.label)} is linked to a workspace but not a Gitea repo.</div>`;
}
return `
<article class="item-card issue-card">
<div class="target-summary">
<span>Target repo</span>
<a class="item-title" href="${knownUrl.giteaRepo.webUrl}" target="_blank" rel="noreferrer">${escapeHtml(knownUrl.giteaRepo.fullName)}</a>
<span class="path-text">${escapeHtml(knownUrl.workspacePath)}</span>
<span class="url-text">${escapeHtml(knownUrl.url)}</span>
<span class="muted">Chrome profile: ${escapeHtml(CHROME_PROFILE_NAME)}</span>
</div>
<div class="form-stack">
<label>
<span>Subject</span>
<input id="issue-subject" type="text" value="${escapeHtml(draft.subject)}" placeholder="Issue subject" />
</label>
<label>
<span>Content</span>
<textarea id="issue-content" rows="8" placeholder="Describe the issue">${escapeHtml(draft.content)}</textarea>
</label>
<section class="attachment-section">
<div class="attachment-heading">
<span>Screenshots</span>
<button id="capture-screenshot" class="secondary-button" type="button" ${state.isCapturingScreenshot ? "disabled" : ""}>
${state.isCapturingScreenshot ? "Taking..." : "Take snapshot"}
</button>
</div>
${renderScreenshotList(draft.screenshots, "issue")}
</section>
<label>
<span>File attachments</span>
<input id="issue-files" type="file" multiple />
</label>
${renderAttachmentList(
draft.files.map((file) => file.name),
"No files selected."
)}
<label class="checkbox-row">
<input id="issue-ready" type="checkbox" ${draft.ready ? "checked" : ""} />
<span>Ready</span>
</label>
<button id="create-issue" class="primary-button" type="button" ${draft.subject.trim() && !state.isCreatingIssue ? "" : "disabled"}>
${state.isCreatingIssue ? "Creating issue..." : "Create issue"}
</button>
</div>
</article>
${renderTargetIssues()}
`;
}
function openUrl(url: string): void {
if (globalThis.chrome?.tabs?.create) {
void globalThis.chrome.tabs.create({ url });
return;
}
window.open(url, "_blank", "noopener,noreferrer");
}
function bindViewActions(): void {
viewContent?.querySelectorAll<HTMLButtonElement>("[data-open-url]").forEach((button) => {
button.addEventListener("click", () => {
const url = button.dataset.openUrl;
if (url) {
openUrl(url);
}
});
});
viewContent?.querySelectorAll<HTMLButtonElement>("[data-delete-url]").forEach((button) => {
button.addEventListener("click", () => {
const url = button.dataset.deleteUrl;
if (url) {
void appStore.getState().deleteUrlLink(url);
}
});
});
viewContent?.querySelectorAll<HTMLInputElement>("input[name='add-url-mode']").forEach((input) => {
input.addEventListener("change", () => {
appStore.getState().setAddUrlDraft({
mode: input.value as AddUrlMode,
selectedValue: ""
});
});
});
viewContent?.querySelector<HTMLSelectElement>("#add-url-selection")?.addEventListener("change", (event) => {
appStore.getState().setAddUrlDraft({
selectedValue: (event.target as HTMLSelectElement).value
});
});
viewContent?.querySelector<HTMLButtonElement>("#save-url-link")?.addEventListener("click", () => {
void appStore.getState().saveCurrentUrlLink();
});
viewContent?.querySelector<HTMLInputElement>("#issue-subject")?.addEventListener("input", (event) => {
appStore.getState().setIssueDraft({
subject: (event.target as HTMLInputElement).value
});
});
viewContent?.querySelector<HTMLTextAreaElement>("#issue-content")?.addEventListener("input", (event) => {
appStore.getState().setIssueDraft({
content: (event.target as HTMLTextAreaElement).value
});
});
viewContent?.querySelector<HTMLInputElement>("#issue-ready")?.addEventListener("change", (event) => {
appStore.getState().setIssueDraft({
ready: (event.target as HTMLInputElement).checked
});
});
viewContent?.querySelector<HTMLInputElement>("#issue-files")?.addEventListener("change", (event) => {
appStore.getState().setIssueFiles(Array.from((event.target as HTMLInputElement).files ?? []));
});
viewContent?.querySelector<HTMLButtonElement>("#capture-screenshot")?.addEventListener("click", () => {
void appStore.getState().captureScreenshot();
});
viewContent?.querySelectorAll<HTMLButtonElement>("[data-delete-screenshot]").forEach((button) => {
button.addEventListener("click", () => {
const id = button.dataset.deleteScreenshot;
if (id) {
appStore.getState().deleteIssueScreenshot(id);
}
});
});
viewContent?.querySelectorAll<HTMLButtonElement>("[data-select-issue]").forEach((button) => {
button.addEventListener("click", () => {
const issueNumber = Number(button.dataset.selectIssue);
if (Number.isFinite(issueNumber)) {
appStore.getState().selectIssue(issueNumber);
}
});
});
viewContent?.querySelectorAll<HTMLButtonElement>("[data-ready-issue]").forEach((button) => {
button.addEventListener("click", () => {
const issueNumber = Number(button.dataset.readyIssue);
if (Number.isFinite(issueNumber)) {
void appStore.getState().markIssueReady(issueNumber);
}
});
});
viewContent?.querySelectorAll<HTMLButtonElement>("[data-delete-issue]").forEach((button) => {
button.addEventListener("click", () => {
const issueNumber = Number(button.dataset.deleteIssue);
if (Number.isFinite(issueNumber) && window.confirm(`Delete issue #${issueNumber}?`)) {
void appStore.getState().deleteIssue(issueNumber);
}
});
});
viewContent?.querySelector<HTMLButtonElement>("#refresh-target-issues")?.addEventListener("click", () => {
void appStore.getState().loadTargetIssues(true);
});
viewContent?.querySelector<HTMLTextAreaElement>("#issue-comment-content")?.addEventListener("input", (event) => {
appStore.getState().setIssueCommentDraft({
content: (event.target as HTMLTextAreaElement).value
});
});
viewContent?.querySelector<HTMLInputElement>("#issue-comment-files")?.addEventListener("change", (event) => {
appStore.getState().setIssueCommentFiles(Array.from((event.target as HTMLInputElement).files ?? []));
});
viewContent?.querySelector<HTMLButtonElement>("#capture-comment-screenshot")?.addEventListener("click", () => {
void appStore.getState().captureIssueCommentScreenshot();
});
viewContent?.querySelectorAll<HTMLButtonElement>("[data-delete-comment-screenshot]").forEach((button) => {
button.addEventListener("click", () => {
const id = button.dataset.deleteCommentScreenshot;
if (id) {
appStore.getState().deleteIssueCommentScreenshot(id);
}
});
});
viewContent?.querySelector<HTMLButtonElement>("#submit-issue-comment")?.addEventListener("click", () => {
void appStore.getState().createIssueComment();
});
viewContent?.querySelector<HTMLButtonElement>("#create-issue")?.addEventListener("click", () => {
void appStore.getState().createIssue();
});
}
function renderView(): void {
if (!viewHeader || !viewContent) {
return;
}
const state = appStore.getState();
const context = state.localContext;
const titles: Record<AppView, string> = {
"add-issue": "Add issue",
"add-url": "Add URL",
urls: "URLs",
repos: "Gitea repos",
workspaces: "Workspaces"
};
const descriptions: Record<AppView, string> = {
"add-issue": "Create a Gitea issue for the repo linked to the active tab.",
"add-url": "Link the active tab base URL to a workspace or Gitea repo.",
urls: "Known browser targets and their workspace/repo links.",
repos: "Repositories ordered by latest default-branch commit.",
workspaces: `Authoritative workspaces from ${context?.workspaceRoot || "workspace_Father"}.`
};
viewHeader.hidden = state.activeView === "add-issue";
viewHeader.innerHTML =
state.activeView === "add-issue"
? ""
: `
<div>
<h2>${titles[state.activeView]}</h2>
<p>${escapeHtml(descriptions[state.activeView])}</p>
</div>
`;
if (state.activeView === "repos") {
viewContent.innerHTML = renderRepos(state.repos);
} else if (state.activeView === "workspaces") {
viewContent.innerHTML = renderWorkspaces(context?.workspaces ?? []);
} else if (state.activeView === "add-issue") {
viewContent.innerHTML = renderAddIssue();
} else if (state.activeView === "add-url") {
viewContent.innerHTML = renderAddUrl();
} else {
viewContent.innerHTML = renderUrls(allUrls());
}
bindViewActions();
}
function renderChrome(): void {
const state = appStore.getState();
if (status) {
status.textContent = state.statusMessage;
status.dataset.tone = state.statusTone;
}
if (refreshData) {
refreshData.disabled = state.isLoadingData;
}
if (addUrlButton) {
addUrlButton.disabled = !state.activeBaseUrl || isActiveBaseUrlKnown();
}
if (addIssueButton) {
addIssueButton.disabled = !canAddIssue();
}
viewButtons.forEach((button) => {
const view = button.dataset.view;
button.dataset.active = view === state.activeView ? "true" : "false";
});
}
function render(): void {
renderChrome();
renderView();
ensureTargetIssuesLoaded();
}
function ensureTargetIssuesLoaded(): void {
const state = appStore.getState();
const knownUrl = getActiveKnownUrl();
const fullName = knownUrl?.giteaRepo?.fullName ?? "";
if (
state.activeView === "add-issue" &&
fullName &&
state.issueListRepoFullName !== fullName &&
!state.isLoadingTargetIssues
) {
void appStore.getState().loadTargetIssues();
}
}
viewButtons.forEach((button) => {
button.addEventListener("click", () => {
const view = button.dataset.view as AppView | undefined;
if (view) {
appStore.getState().setActiveView(view);
}
});
});
refreshData?.addEventListener("click", () => {
void appStore.getState().loadData();
});
function setupActiveTabTracking(): void {
let refreshTimer: number | undefined;
const scheduleRefresh = (): void => {
if (refreshTimer !== undefined) {
window.clearTimeout(refreshTimer);
}
refreshTimer = window.setTimeout(() => {
refreshTimer = undefined;
void appStore.getState().refreshActiveTab();
}, 80);
};
globalThis.chrome?.tabs?.onActivated?.addListener(scheduleRefresh);
globalThis.chrome?.tabs?.onUpdated?.addListener((_tabId, changeInfo) => {
if (changeInfo.url || changeInfo.status === "complete") {
scheduleRefresh();
}
});
globalThis.chrome?.windows?.onFocusChanged?.addListener((windowId) => {
if (windowId !== chrome.windows.WINDOW_ID_NONE) {
scheduleRefresh();
}
});
document.addEventListener("visibilitychange", () => {
if (!document.hidden) {
scheduleRefresh();
}
});
window.addEventListener("focus", scheduleRefresh);
}
setupActiveTabTracking();
appStore.subscribe(render);
render();
void appStore.getState().loadData();