774 lines
25 KiB
TypeScript
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> = {
|
|
"&": "&",
|
|
"<": "<",
|
|
">": ">",
|
|
"\"": """,
|
|
"'": "'"
|
|
};
|
|
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();
|