@@ -49,6 +49,8 @@ Use `.env.local` for local secrets. Keep `.env.local` and any real token-bearing
|
||||
- URLs can be deleted from the URLs list. User-added URLs are removed from storage; generated known URLs are hidden through the stored deleted-base list.
|
||||
- Add Issue is enabled only when the active tab's base URL is already in the URLs list and has a linked Gitea repo. The issue form must show the target repo, capture visible-tab screenshots, accept Gitea-friendly file attachments, and include `Chrome profile: Silma` in created issue bodies.
|
||||
- If the Ready checkbox is checked, create the issue first, upload screenshots/files as issue assets second, and apply the `ready` label last.
|
||||
- The Add Issue view also lists existing issues for the linked Gitea repo. Selecting an issue must show a comment form that can add text, take additional screenshots, attach files, upload those files as issue assets, and submit a Gitea comment with attachment links.
|
||||
- Keep the Add Issue view dense; do not show the redundant view header title/description inside this view, and keep the Ready checkbox label short.
|
||||
- The extension requests `activeTab` and `<all_urls>` host permission so screenshots can be captured from the current Chrome tab even after the side panel has stayed open across tab changes.
|
||||
|
||||
## Chrome Extension Management
|
||||
|
||||
@@ -26,7 +26,7 @@ The deployable extension output is `dist/`. Load `dist/` in Chrome as an unpacke
|
||||
|
||||
Static assets, including the extension icon assets, live in `public/` and are copied into `dist/` during the build.
|
||||
|
||||
The app opens in the Chrome side panel. The top icon menu includes Add URL, URLs, Gitea repos, Workspaces, and Add Issue. Add URL is enabled only for an active tab base URL that is not already in the URLs list. URLs can be deleted from the list. Add Issue is enabled when the active tab base URL is already linked to a Gitea repo; it creates an issue, uploads screenshots and files as issue assets, and can apply the `ready` label after uploads.
|
||||
The app opens in the Chrome side panel. The top icon menu includes Add URL, URLs, Gitea repos, Workspaces, and Add Issue. Add URL is enabled only for an active tab base URL that is not already in the URLs list. URLs can be deleted from the list. Add Issue is enabled when the active tab base URL is already linked to a Gitea repo; it creates an issue, uploads screenshots and files as issue assets, and can apply the `ready` label after uploads. The same view lists existing repo issues; selecting one opens a comment form with screenshots and file attachments.
|
||||
|
||||
Builds generate `public/local-context.json` from `/Users/Tabitha/.openclaw/workspace_Father`. That snapshot links known workspaces to local Gitea repos and is copied into `dist/` so the built extension is self-contained.
|
||||
|
||||
|
||||
@@ -39,10 +39,13 @@ Required variables:
|
||||
|
||||
- Add Issue is enabled only when the active tab's base URL is already in the URLs list and that URL is linked to a Gitea repo.
|
||||
- The Add Issue view must show the Gitea repo that will receive the issue.
|
||||
- The Add Issue view intentionally omits the redundant view header title/description and keeps the Ready checkbox label short.
|
||||
- Screenshot capture uses Chrome's visible-tab capture API and requires the extension's `activeTab` permission plus `<all_urls>` host permission so capture still works after the side panel remains open across tab changes.
|
||||
- Captured screenshots should appear immediately in Add Issue as thumbnail rows.
|
||||
- Created issue bodies must include `Chrome profile: Silma`.
|
||||
- When Ready is checked, create the issue, upload screenshots/files as Gitea issue assets, then apply the `ready` label last.
|
||||
- The Add Issue view lists existing issues for the linked Gitea repo. Selecting an issue opens a comment form for text, additional screenshots, and file attachments.
|
||||
- Issue comments should upload screenshots/files as issue assets first, then create a comment containing links to those attachments.
|
||||
|
||||
## URL Management
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ Important files:
|
||||
|
||||
Build with `npm run build`. The deployable output directory is `dist/`; load that folder in Chrome as an unpacked extension or point deployment tooling at it. Do not edit `dist/` directly.
|
||||
|
||||
UI behavior: the app renders as a Chrome side panel, not a draggable window. The menu items are Add URL, URLs, Gitea Repos, Workspaces, and Add Issue. Add URL is enabled only when the active tab base URL is not already known; the user must choose either a workspace or a Gitea repo, and the app infers the linked counterpart. URLs can be deleted from the list; user-added URLs are removed from storage, while generated known URLs are hidden through stored deleted bases. Add Issue is enabled only when the active tab base URL is known and linked to a Gitea repo. It shows the target repo, captures visible-tab screenshots with thumbnails, accepts file attachments, creates the Gitea issue, uploads assets, then applies the `ready` label if requested. Created issue bodies include `Chrome profile: Silma`. Gitea repos are ordered by latest default-branch commit. Workspace and URL views show established links between local workspaces, known URLs, and Gitea repos.
|
||||
UI behavior: the app renders as a Chrome side panel, not a draggable window. The menu items are Add URL, URLs, Gitea Repos, Workspaces, and Add Issue. Add URL is enabled only when the active tab base URL is not already known; the user must choose either a workspace or a Gitea repo, and the app infers the linked counterpart. URLs can be deleted from the list; user-added URLs are removed from storage, while generated known URLs are hidden through stored deleted bases. Add Issue is enabled only when the active tab base URL is known and linked to a Gitea repo. It shows the target repo, captures visible-tab screenshots with thumbnails, accepts file attachments, creates the Gitea issue, uploads assets, then applies the `ready` label if requested. Created issue bodies include `Chrome profile: Silma`. The Add Issue view also lists existing issues for the linked repo; selecting an issue opens a comment form with text, screenshots, and file attachments. Gitea repos are ordered by latest default-branch commit. Workspace and URL views show established links between local workspaces, known URLs, and Gitea repos.
|
||||
|
||||
Extension management rule: use the Codex Chrome extension with the Chrome profile named `Silma`. Do not use `Profile 1`.
|
||||
|
||||
|
||||
+2
-1
@@ -50,7 +50,8 @@
|
||||
"repo_ordering": "Gitea repos are ordered by latest default-branch commit timestamp.",
|
||||
"add_url_behavior": "The user must pick either a workspace or a Gitea repo. The app infers the linked counterpart from local context and stores the URL link in chrome.storage.local.",
|
||||
"delete_url_behavior": "URLs can be deleted from the list. User-added URLs are removed from chrome.storage.local; generated known URLs are hidden by storing their base URL in silmaAide.deletedUrlBases.",
|
||||
"add_issue_behavior": "The form shows the target repo for the active known URL, captures visible-tab screenshots with thumbnails, accepts file attachments, creates a Gitea issue, uploads screenshots/files as issue assets, then applies the ready label after uploads when requested. Issue bodies include Chrome profile: Silma.",
|
||||
"add_issue_behavior": "The form shows the target repo for the active known URL, captures visible-tab screenshots with thumbnails, accepts file attachments, creates a Gitea issue, uploads screenshots/files as issue assets, then applies the ready label after uploads when requested. Issue bodies include Chrome profile: Silma. The view intentionally omits the redundant Add Issue header copy and keeps the Ready checkbox label short.",
|
||||
"issue_comment_behavior": "The Add Issue view lists existing issues for the active URL's linked Gitea repo. Selecting an issue opens a comment form that accepts text, screenshots, and file attachments, uploads attachments as issue assets, and submits a Gitea issue comment with attachment links.",
|
||||
"known_urls": [
|
||||
{
|
||||
"url": "http://100.66.226.22:23030/",
|
||||
|
||||
+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