+73
-2
@@ -41,7 +41,14 @@ interface GiteaBranchResponse {
|
||||
|
||||
interface GiteaIssueResponse {
|
||||
number?: number;
|
||||
title?: string;
|
||||
state?: string;
|
||||
html_url?: string;
|
||||
updated_at?: string;
|
||||
user?: {
|
||||
login?: string;
|
||||
};
|
||||
comments?: number;
|
||||
}
|
||||
|
||||
interface GiteaLabelResponse {
|
||||
@@ -49,11 +56,31 @@ interface GiteaLabelResponse {
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface GiteaAttachmentResponse {
|
||||
name?: string;
|
||||
browser_download_url?: string;
|
||||
}
|
||||
|
||||
export interface CreatedGiteaIssue {
|
||||
number: number;
|
||||
webUrl: string;
|
||||
}
|
||||
|
||||
export interface GiteaIssueSummary {
|
||||
number: number;
|
||||
title: string;
|
||||
state: string;
|
||||
webUrl: string;
|
||||
updatedAt: string;
|
||||
author: string;
|
||||
comments: number;
|
||||
}
|
||||
|
||||
export interface GiteaIssueAttachment {
|
||||
name: string;
|
||||
downloadUrl: string;
|
||||
}
|
||||
|
||||
export interface GiteaRepoSummary {
|
||||
name: string;
|
||||
fullName: string;
|
||||
@@ -124,18 +151,62 @@ export async function createGiteaIssue(owner: string, repo: string, title: strin
|
||||
};
|
||||
}
|
||||
|
||||
export async function getGiteaIssues(owner: string, repo: string): Promise<GiteaIssueSummary[]> {
|
||||
const client = createGiteaClient();
|
||||
const response = await client.get<GiteaIssueResponse[]>(`/repos/${owner}/${repo}/issues`, {
|
||||
params: {
|
||||
limit: 25,
|
||||
page: 1,
|
||||
state: "all",
|
||||
type: "issues"
|
||||
}
|
||||
});
|
||||
|
||||
return response.data
|
||||
.filter((issue) => issue.number !== undefined)
|
||||
.map((issue) => ({
|
||||
number: issue.number ?? 0,
|
||||
title: issue.title || "Untitled issue",
|
||||
state: issue.state || "unknown",
|
||||
webUrl: issue.html_url || `${giteaConfig.baseUrl.replace(/\/+$/, "")}/${owner}/${repo}/issues/${issue.number}`,
|
||||
updatedAt: issue.updated_at || "",
|
||||
author: issue.user?.login || "unknown",
|
||||
comments: issue.comments ?? 0
|
||||
}));
|
||||
}
|
||||
|
||||
export async function uploadGiteaIssueAttachment(
|
||||
owner: string,
|
||||
repo: string,
|
||||
issueNumber: number,
|
||||
file: Blob,
|
||||
fileName: string
|
||||
): Promise<void> {
|
||||
): Promise<GiteaIssueAttachment> {
|
||||
const client = createGiteaClient();
|
||||
const formData = new FormData();
|
||||
formData.append("attachment", file, fileName);
|
||||
|
||||
await client.post(`/repos/${owner}/${repo}/issues/${issueNumber}/assets`, formData);
|
||||
const response = await client.post<GiteaAttachmentResponse>(
|
||||
`/repos/${owner}/${repo}/issues/${issueNumber}/assets`,
|
||||
formData
|
||||
);
|
||||
|
||||
return {
|
||||
name: response.data.name || fileName,
|
||||
downloadUrl: response.data.browser_download_url || ""
|
||||
};
|
||||
}
|
||||
|
||||
export async function createGiteaIssueComment(
|
||||
owner: string,
|
||||
repo: string,
|
||||
issueNumber: number,
|
||||
body: string
|
||||
): Promise<void> {
|
||||
const client = createGiteaClient();
|
||||
await client.post(`/repos/${owner}/${repo}/issues/${issueNumber}/comments`, {
|
||||
body
|
||||
});
|
||||
}
|
||||
|
||||
async function getGiteaLabels(owner: string, repo: string): Promise<GiteaLabelResponse[]> {
|
||||
|
||||
+164
-14
@@ -8,7 +8,7 @@ import {
|
||||
type AppView,
|
||||
type ScreenshotAttachment
|
||||
} from "./store";
|
||||
import type { GiteaRepoSummary } from "./api";
|
||||
import type { GiteaIssueSummary, GiteaRepoSummary } from "./api";
|
||||
import type { KnownUrl, LocalContext, LocalWorkspace } from "./localContext";
|
||||
|
||||
const status = document.querySelector<HTMLElement>("#status");
|
||||
@@ -43,9 +43,9 @@ function escapeHtml(value: string): string {
|
||||
});
|
||||
}
|
||||
|
||||
function formatDate(value: string): string {
|
||||
function formatDate(value: string, emptyText = "No date"): string {
|
||||
if (!value) {
|
||||
return "No commit date";
|
||||
return emptyText;
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
@@ -122,7 +122,7 @@ function renderRepos(repos: GiteaRepoSummary[]): string {
|
||||
<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)}</span>
|
||||
<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>
|
||||
@@ -294,11 +294,13 @@ function renderAttachmentList(items: string[], emptyText: string): string {
|
||||
`;
|
||||
}
|
||||
|
||||
function renderScreenshotList(screenshots: ScreenshotAttachment[]): string {
|
||||
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
|
||||
@@ -307,7 +309,7 @@ function renderScreenshotList(screenshots: ScreenshotAttachment[]): string {
|
||||
<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" data-delete-screenshot="${screenshot.id}">Delete</button>
|
||||
<button class="danger-button compact-button" type="button" ${attribute}="${screenshot.id}">Delete</button>
|
||||
</figure>
|
||||
`
|
||||
)
|
||||
@@ -316,6 +318,93 @@ function renderScreenshotList(screenshots: ScreenshotAttachment[]): string {
|
||||
`;
|
||||
}
|
||||
|
||||
function renderIssueRows(issues: GiteaIssueSummary[], selectedIssueNumber: number | null): string {
|
||||
if (!issues.length) {
|
||||
return `<div class="empty-state">No issues found for this repo.</div>`;
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="issue-list">
|
||||
${issues
|
||||
.map(
|
||||
(issue) => `
|
||||
<button class="issue-row" type="button" data-select-issue="${issue.number}" data-selected="${issue.number === selectedIssueNumber ? "true" : "false"}">
|
||||
<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>
|
||||
`
|
||||
)
|
||||
.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();
|
||||
@@ -357,7 +446,7 @@ function renderAddIssue(): string {
|
||||
${state.isCapturingScreenshot ? "Taking..." : "Take snapshot"}
|
||||
</button>
|
||||
</div>
|
||||
${renderScreenshotList(draft.screenshots)}
|
||||
${renderScreenshotList(draft.screenshots, "issue")}
|
||||
</section>
|
||||
|
||||
<label>
|
||||
@@ -371,7 +460,7 @@ function renderAddIssue(): string {
|
||||
|
||||
<label class="checkbox-row">
|
||||
<input id="issue-ready" type="checkbox" ${draft.ready ? "checked" : ""} />
|
||||
<span>Apply ready label after creation and file uploads</span>
|
||||
<span>Ready</span>
|
||||
</label>
|
||||
|
||||
<button id="create-issue" class="primary-button" type="button" ${draft.subject.trim() && !state.isCreatingIssue ? "" : "disabled"}>
|
||||
@@ -379,6 +468,7 @@ function renderAddIssue(): string {
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
${renderTargetIssues()}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -464,6 +554,46 @@ function bindViewActions(): void {
|
||||
});
|
||||
});
|
||||
|
||||
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?.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();
|
||||
});
|
||||
@@ -492,12 +622,16 @@ function renderView(): void {
|
||||
workspaces: `Authoritative workspaces from ${context?.workspaceRoot || "workspace_Father"}.`
|
||||
};
|
||||
|
||||
viewHeader.innerHTML = `
|
||||
<div>
|
||||
<h2>${titles[state.activeView]}</h2>
|
||||
<p>${escapeHtml(descriptions[state.activeView])}</p>
|
||||
</div>
|
||||
`;
|
||||
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);
|
||||
@@ -543,6 +677,22 @@ function renderChrome(): void {
|
||||
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) => {
|
||||
|
||||
+258
-16
@@ -1,11 +1,15 @@
|
||||
import { createStore } from "zustand/vanilla";
|
||||
import {
|
||||
addGiteaIssueLabels,
|
||||
createGiteaIssueComment,
|
||||
createGiteaIssue,
|
||||
ensureGiteaLabel,
|
||||
getCurrentGiteaUser,
|
||||
getGiteaIssues,
|
||||
getReposOrderedByLastCommit,
|
||||
giteaConfig,
|
||||
type GiteaIssueAttachment,
|
||||
type GiteaIssueSummary,
|
||||
type GiteaRepoSummary,
|
||||
type GiteaUser,
|
||||
uploadGiteaIssueAttachment
|
||||
@@ -39,6 +43,12 @@ interface IssueDraft {
|
||||
files: File[];
|
||||
}
|
||||
|
||||
interface IssueCommentDraft {
|
||||
content: string;
|
||||
screenshots: ScreenshotAttachment[];
|
||||
files: File[];
|
||||
}
|
||||
|
||||
interface AppState {
|
||||
activeView: AppView;
|
||||
config: typeof giteaConfig;
|
||||
@@ -51,10 +61,17 @@ interface AppState {
|
||||
activeBaseUrl: string;
|
||||
addUrlDraft: AddUrlDraft;
|
||||
issueDraft: IssueDraft;
|
||||
issueCommentDraft: IssueCommentDraft;
|
||||
targetIssues: GiteaIssueSummary[];
|
||||
issueListRepoFullName: string;
|
||||
selectedIssueNumber: number | null;
|
||||
isLoadingData: boolean;
|
||||
isLoadingTargetIssues: boolean;
|
||||
isTestingConnection: boolean;
|
||||
isCapturingScreenshot: boolean;
|
||||
isCapturingIssueCommentScreenshot: boolean;
|
||||
isCreatingIssue: boolean;
|
||||
isCreatingIssueComment: boolean;
|
||||
statusMessage: string;
|
||||
statusTone: StatusTone;
|
||||
clearStatus: () => void;
|
||||
@@ -63,6 +80,13 @@ interface AppState {
|
||||
setIssueDraft: (draft: Partial<Omit<IssueDraft, "screenshots" | "files">>) => void;
|
||||
setIssueFiles: (files: File[]) => void;
|
||||
deleteIssueScreenshot: (id: string) => void;
|
||||
setIssueCommentDraft: (draft: Partial<Omit<IssueCommentDraft, "screenshots" | "files">>) => void;
|
||||
setIssueCommentFiles: (files: File[]) => void;
|
||||
deleteIssueCommentScreenshot: (id: string) => void;
|
||||
selectIssue: (issueNumber: number) => void;
|
||||
loadTargetIssues: (force?: boolean) => Promise<void>;
|
||||
captureIssueCommentScreenshot: () => Promise<void>;
|
||||
createIssueComment: () => Promise<void>;
|
||||
captureScreenshot: () => Promise<void>;
|
||||
createIssue: () => Promise<void>;
|
||||
refreshActiveTab: () => Promise<void>;
|
||||
@@ -190,6 +214,22 @@ function parseRepoFullName(fullName: string): { owner: string; repo: string } |
|
||||
return { owner, repo };
|
||||
}
|
||||
|
||||
function getTargetRepo(state: AppState): { knownUrl: KnownUrl; owner: string; repo: string; fullName: string } | null {
|
||||
const knownUrl = findKnownUrlForBase(state);
|
||||
const parsed = knownUrl?.giteaRepo ? parseRepoFullName(knownUrl.giteaRepo.fullName) : null;
|
||||
|
||||
if (!knownUrl || !knownUrl.giteaRepo || !parsed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
knownUrl,
|
||||
owner: parsed.owner,
|
||||
repo: parsed.repo,
|
||||
fullName: knownUrl.giteaRepo.fullName
|
||||
};
|
||||
}
|
||||
|
||||
function inferUrlLink(context: LocalContext, baseUrl: string, draft: AddUrlDraft): KnownUrl | null {
|
||||
if (!draft.mode || !draft.selectedValue) {
|
||||
return null;
|
||||
@@ -302,6 +342,15 @@ function captureVisibleTab(): Promise<{ dataUrl: string; blob: Blob }> {
|
||||
});
|
||||
}
|
||||
|
||||
function createScreenshotAttachment(dataUrl: string, blob: Blob): ScreenshotAttachment {
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
name: `silma-screenshot-${new Date().toISOString().replace(/[:.]/g, "-")}.png`,
|
||||
dataUrl,
|
||||
blob
|
||||
};
|
||||
}
|
||||
|
||||
function buildIssueBody(state: AppState, knownUrl: KnownUrl): string {
|
||||
const parts = [
|
||||
state.issueDraft.content.trim(),
|
||||
@@ -316,6 +365,27 @@ function buildIssueBody(state: AppState, knownUrl: KnownUrl): string {
|
||||
return parts.filter(Boolean).join("\n\n");
|
||||
}
|
||||
|
||||
function buildAttachmentMarkdown(attachments: GiteaIssueAttachment[]): string {
|
||||
const lines = attachments
|
||||
.filter((attachment) => attachment.downloadUrl)
|
||||
.map((attachment) => `- [${attachment.name}](${attachment.downloadUrl})`);
|
||||
|
||||
return lines.length ? `Attachments:\n${lines.join("\n")}` : "";
|
||||
}
|
||||
|
||||
function buildIssueCommentBody(state: AppState, knownUrl: KnownUrl, attachments: GiteaIssueAttachment[]): string {
|
||||
const parts = [
|
||||
state.issueCommentDraft.content.trim(),
|
||||
buildAttachmentMarkdown(attachments),
|
||||
"---",
|
||||
`Source URL: ${state.activeTabUrl || knownUrl.url}`,
|
||||
`Base URL: ${state.activeBaseUrl || getBaseUrl(knownUrl.url)}`,
|
||||
`Chrome profile: ${CHROME_PROFILE_NAME}`
|
||||
];
|
||||
|
||||
return parts.filter(Boolean).join("\n\n");
|
||||
}
|
||||
|
||||
export const appStore = createStore<AppState>()((set, get) => ({
|
||||
activeView: "repos",
|
||||
config: giteaConfig,
|
||||
@@ -337,10 +407,21 @@ export const appStore = createStore<AppState>()((set, get) => ({
|
||||
screenshots: [],
|
||||
files: []
|
||||
},
|
||||
issueCommentDraft: {
|
||||
content: "",
|
||||
screenshots: [],
|
||||
files: []
|
||||
},
|
||||
targetIssues: [],
|
||||
issueListRepoFullName: "",
|
||||
selectedIssueNumber: null,
|
||||
isLoadingData: false,
|
||||
isLoadingTargetIssues: false,
|
||||
isTestingConnection: false,
|
||||
isCapturingScreenshot: false,
|
||||
isCapturingIssueCommentScreenshot: false,
|
||||
isCreatingIssue: false,
|
||||
isCreatingIssueComment: false,
|
||||
statusMessage: "",
|
||||
statusTone: "neutral",
|
||||
clearStatus: () => {
|
||||
@@ -379,18 +460,82 @@ export const appStore = createStore<AppState>()((set, get) => ({
|
||||
screenshots: state.issueDraft.screenshots.filter((screenshot) => screenshot.id !== id)
|
||||
}
|
||||
})),
|
||||
setIssueCommentDraft: (draft) =>
|
||||
set((state) => ({
|
||||
issueCommentDraft: {
|
||||
...state.issueCommentDraft,
|
||||
...draft
|
||||
}
|
||||
})),
|
||||
setIssueCommentFiles: (files) =>
|
||||
set((state) => ({
|
||||
issueCommentDraft: {
|
||||
...state.issueCommentDraft,
|
||||
files
|
||||
}
|
||||
})),
|
||||
deleteIssueCommentScreenshot: (id) =>
|
||||
set((state) => ({
|
||||
issueCommentDraft: {
|
||||
...state.issueCommentDraft,
|
||||
screenshots: state.issueCommentDraft.screenshots.filter((screenshot) => screenshot.id !== id)
|
||||
}
|
||||
})),
|
||||
selectIssue: (issueNumber) =>
|
||||
set({
|
||||
selectedIssueNumber: issueNumber,
|
||||
issueCommentDraft: {
|
||||
content: "",
|
||||
screenshots: [],
|
||||
files: []
|
||||
}
|
||||
}),
|
||||
loadTargetIssues: async (force = false) => {
|
||||
const state = appStore.getState();
|
||||
const target = getTargetRepo(state);
|
||||
|
||||
if (!target) {
|
||||
set({
|
||||
targetIssues: [],
|
||||
issueListRepoFullName: "",
|
||||
selectedIssueNumber: null
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!force && state.issueListRepoFullName === target.fullName && state.targetIssues.length > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
set({
|
||||
isLoadingTargetIssues: true,
|
||||
issueListRepoFullName: target.fullName
|
||||
});
|
||||
|
||||
try {
|
||||
const targetIssues = await getGiteaIssues(target.owner, target.repo);
|
||||
set((current) => ({
|
||||
targetIssues,
|
||||
issueListRepoFullName: target.fullName,
|
||||
selectedIssueNumber:
|
||||
current.selectedIssueNumber && targetIssues.some((issue) => issue.number === current.selectedIssueNumber)
|
||||
? current.selectedIssueNumber
|
||||
: null
|
||||
}));
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Could not load Gitea issues.";
|
||||
setStatus(set, message, "error");
|
||||
} finally {
|
||||
set({ isLoadingTargetIssues: false });
|
||||
}
|
||||
},
|
||||
captureScreenshot: async () => {
|
||||
set({ isCapturingScreenshot: true });
|
||||
setStatus(set, "Capturing screenshot...", "neutral", { autoClear: false });
|
||||
|
||||
try {
|
||||
const { dataUrl, blob } = await captureVisibleTab();
|
||||
const screenshot: ScreenshotAttachment = {
|
||||
id: crypto.randomUUID(),
|
||||
name: `silma-screenshot-${new Date().toISOString().replace(/[:.]/g, "-")}.png`,
|
||||
dataUrl,
|
||||
blob
|
||||
};
|
||||
const screenshot = createScreenshotAttachment(dataUrl, blob);
|
||||
|
||||
set((state) => ({
|
||||
issueDraft: {
|
||||
@@ -406,13 +551,96 @@ export const appStore = createStore<AppState>()((set, get) => ({
|
||||
set({ isCapturingScreenshot: false });
|
||||
}
|
||||
},
|
||||
captureIssueCommentScreenshot: async () => {
|
||||
set({ isCapturingIssueCommentScreenshot: true });
|
||||
setStatus(set, "Capturing screenshot...", "neutral", { autoClear: false });
|
||||
|
||||
try {
|
||||
const { dataUrl, blob } = await captureVisibleTab();
|
||||
const screenshot = createScreenshotAttachment(dataUrl, blob);
|
||||
|
||||
set((state) => ({
|
||||
issueCommentDraft: {
|
||||
...state.issueCommentDraft,
|
||||
screenshots: [...state.issueCommentDraft.screenshots, screenshot]
|
||||
}
|
||||
}));
|
||||
setStatus(set, `Captured ${screenshot.name}.`, "success");
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Could not capture a screenshot.";
|
||||
setStatus(set, message, "error");
|
||||
} finally {
|
||||
set({ isCapturingIssueCommentScreenshot: false });
|
||||
}
|
||||
},
|
||||
createIssueComment: async () => {
|
||||
const state = appStore.getState();
|
||||
const target = getTargetRepo(state);
|
||||
const selectedIssueNumber = state.selectedIssueNumber;
|
||||
const hasAttachments =
|
||||
state.issueCommentDraft.screenshots.length > 0 || state.issueCommentDraft.files.length > 0;
|
||||
|
||||
if (!target || !selectedIssueNumber) {
|
||||
setStatus(set, "Select an issue before commenting.", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!state.issueCommentDraft.content.trim() && !hasAttachments) {
|
||||
setStatus(set, "Add a comment or attachment before submitting.", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
set({ isCreatingIssueComment: true });
|
||||
setStatus(set, `Adding comment to issue #${selectedIssueNumber}...`, "neutral", { autoClear: false });
|
||||
|
||||
try {
|
||||
const uploads = [
|
||||
...state.issueCommentDraft.screenshots.map((screenshot) => ({
|
||||
name: screenshot.name,
|
||||
blob: screenshot.blob
|
||||
})),
|
||||
...state.issueCommentDraft.files.map((file) => ({
|
||||
name: file.name,
|
||||
blob: file
|
||||
}))
|
||||
];
|
||||
const attachments: GiteaIssueAttachment[] = [];
|
||||
|
||||
for (const upload of uploads) {
|
||||
attachments.push(
|
||||
await uploadGiteaIssueAttachment(target.owner, target.repo, selectedIssueNumber, upload.blob, upload.name)
|
||||
);
|
||||
}
|
||||
|
||||
await createGiteaIssueComment(
|
||||
target.owner,
|
||||
target.repo,
|
||||
selectedIssueNumber,
|
||||
buildIssueCommentBody(state, target.knownUrl, attachments)
|
||||
);
|
||||
|
||||
set({
|
||||
issueCommentDraft: {
|
||||
content: "",
|
||||
screenshots: [],
|
||||
files: []
|
||||
}
|
||||
});
|
||||
await appStore.getState().loadTargetIssues(true);
|
||||
setStatus(set, `Commented on issue #${selectedIssueNumber}.`, "success");
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Could not comment on the Gitea issue.";
|
||||
setStatus(set, message, "error");
|
||||
} finally {
|
||||
set({ isCreatingIssueComment: false });
|
||||
}
|
||||
},
|
||||
createIssue: async () => {
|
||||
const state = appStore.getState();
|
||||
const knownUrl = findKnownUrlForBase(state);
|
||||
const repo = knownUrl?.giteaRepo ? parseRepoFullName(knownUrl.giteaRepo.fullName) : null;
|
||||
const target = getTargetRepo(state);
|
||||
const subject = state.issueDraft.subject.trim();
|
||||
|
||||
if (!knownUrl || !repo) {
|
||||
if (!target) {
|
||||
setStatus(set, "Open a known URL with a linked Gitea repo before creating an issue.", "error");
|
||||
return;
|
||||
}
|
||||
@@ -426,7 +654,7 @@ export const appStore = createStore<AppState>()((set, get) => ({
|
||||
setStatus(set, "Creating Gitea issue...", "neutral", { autoClear: false });
|
||||
|
||||
try {
|
||||
const issue = await createGiteaIssue(repo.owner, repo.repo, subject, buildIssueBody(state, knownUrl));
|
||||
const issue = await createGiteaIssue(target.owner, target.repo, subject, buildIssueBody(state, target.knownUrl));
|
||||
const uploads = [
|
||||
...state.issueDraft.screenshots.map((screenshot) => ({
|
||||
name: screenshot.name,
|
||||
@@ -439,12 +667,12 @@ export const appStore = createStore<AppState>()((set, get) => ({
|
||||
];
|
||||
|
||||
for (const upload of uploads) {
|
||||
await uploadGiteaIssueAttachment(repo.owner, repo.repo, issue.number, upload.blob, upload.name);
|
||||
await uploadGiteaIssueAttachment(target.owner, target.repo, issue.number, upload.blob, upload.name);
|
||||
}
|
||||
|
||||
if (state.issueDraft.ready) {
|
||||
const readyLabel = await ensureGiteaLabel(repo.owner, repo.repo, "ready");
|
||||
await addGiteaIssueLabels(repo.owner, repo.repo, issue.number, [readyLabel.id]);
|
||||
const readyLabel = await ensureGiteaLabel(target.owner, target.repo, "ready");
|
||||
await addGiteaIssueLabels(target.owner, target.repo, issue.number, [readyLabel.id]);
|
||||
}
|
||||
|
||||
set({
|
||||
@@ -454,9 +682,11 @@ export const appStore = createStore<AppState>()((set, get) => ({
|
||||
ready: false,
|
||||
screenshots: [],
|
||||
files: []
|
||||
}
|
||||
},
|
||||
selectedIssueNumber: issue.number
|
||||
});
|
||||
setStatus(set, `Created issue #${issue.number} in ${knownUrl.giteaRepo?.fullName}.`, "success");
|
||||
await appStore.getState().loadTargetIssues(true);
|
||||
setStatus(set, `Created issue #${issue.number} in ${target.fullName}.`, "success");
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Could not create the Gitea issue.";
|
||||
setStatus(set, message, "error");
|
||||
@@ -475,7 +705,19 @@ export const appStore = createStore<AppState>()((set, get) => ({
|
||||
addUrlDraft:
|
||||
activeBaseUrl !== currentBaseUrl
|
||||
? { mode: null, selectedValue: "" }
|
||||
: get().addUrlDraft
|
||||
: get().addUrlDraft,
|
||||
targetIssues:
|
||||
activeBaseUrl !== currentBaseUrl
|
||||
? []
|
||||
: get().targetIssues,
|
||||
issueListRepoFullName:
|
||||
activeBaseUrl !== currentBaseUrl
|
||||
? ""
|
||||
: get().issueListRepoFullName,
|
||||
selectedIssueNumber:
|
||||
activeBaseUrl !== currentBaseUrl
|
||||
? null
|
||||
: get().selectedIssueNumber
|
||||
});
|
||||
},
|
||||
loadData: async () => {
|
||||
|
||||
@@ -126,6 +126,10 @@ h1 {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.view-header[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.view-header h2 {
|
||||
color: #102a43;
|
||||
font-size: 15px;
|
||||
@@ -449,6 +453,76 @@ input[readonly] {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.issue-section {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.section-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
.section-heading > span {
|
||||
color: #52616f;
|
||||
font-size: 10px;
|
||||
font-weight: 850;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.issue-list {
|
||||
display: grid;
|
||||
gap: 0;
|
||||
border-top: 1px solid #d9e2ec;
|
||||
}
|
||||
|
||||
.issue-row {
|
||||
display: grid;
|
||||
grid-template-columns: 40px minmax(0, 1fr) 48px 66px;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
width: 100%;
|
||||
border: 0;
|
||||
border-bottom: 1px solid #d9e2ec;
|
||||
padding: 4px 6px;
|
||||
background: #ffffff;
|
||||
color: #243b53;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.issue-row[data-selected="true"] {
|
||||
background: #e6f6ff;
|
||||
}
|
||||
|
||||
.issue-number,
|
||||
.issue-state,
|
||||
.issue-meta {
|
||||
overflow: hidden;
|
||||
color: #52616f;
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.issue-title {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: #102a43;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.issue-comment-card {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.target-summary {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
|
||||
Reference in New Issue
Block a user