diff --git a/AGENTS.md b/AGENTS.md index 5aaae03..d0475be 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,7 +44,7 @@ Use `.env.local` for local secrets. Keep `.env.local` and any real token-bearing - Runtime API calls are centralized in `src/api.ts`. - Side panel state is centralized in the vanilla Zustand store at `src/store.ts`. - The extension opens as a Chrome side panel. It is not a draggable in-page window. -- The side panel has icon menu sections for Add URL, URLs, Gitea repos, Workspaces, Agents, and Add Issue. +- The side panel has icon menu sections for Add URL, URLs, Gitea repos, Workspaces, Agents, Open Issues, and Add Issue. - Add URL is enabled only when the active tab's base URL is not already in the URLs list. The user must pick either a workspace or a Gitea repo; the app infers the linked counterpart from local context. - 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. - The Agents view lists available agent files and must allow adding, editing, and deleting persisted agents from `chrome.storage.local`. At least one agent must remain available. @@ -54,8 +54,10 @@ Use `.env.local` for local secrets. Keep `.env.local` and any real token-bearing - Newly created issues must be created with the `Change Request` label. - When creating an issue, upload the selected rendered agent file as an issue asset along with screenshots and files. - If the Ready checkbox is checked, create the issue first, upload the rendered agent file plus 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 the original description, issue attachments, and comment chain, followed by 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. -- Existing issue rows must allow applying the `ready` label and deleting the issue. Deletion must require user confirmation. +- Open Issues owns issue browsing and follow-up. It must list recent issues ordered by creation time, default to limiting the table to the active URL's linked repo, and provide a checkbox to show issues across all loaded repos. +- Selecting an Open Issues row must show the original description, issue attachments, and comment chain, followed by 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. +- Existing issue rows must allow applying the `ready` label and deleting the issue. Existing issue attachments must be removable. Deletion must require user confirmation. +- Text-entry updates in Add Issue, Open Issues comments, and Agents must not re-render the focused form control or blur the user while they type. - 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 `` host permission so screenshots can be captured from the current Chrome tab even after the side panel has stayed open across tab changes. diff --git a/README.md b/README.md index 4c83fef..de40968 100644 --- a/README.md +++ b/README.md @@ -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, Agents, 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. The Agents view manages persisted agent files, starting with `GLOBAL AGENT` from `AGENTS.md`; agents can use variables such as `{{git}}`, `{{workspace}}`, and `{{url}}` in their markdown content. Add Issue is enabled when the active tab base URL is already linked to a Gitea repo; it shows the active URL being attached, creates an issue with the selected agent and `Change Request` label, uploads the rendered agent file plus accumulated screenshots and files as issue assets, and can apply the `ready` label after uploads. Captured screenshots can be expanded, removed, or cropped with a plain draggable/resizable crop box. The same view lists existing repo issues; selecting one shows the original description, attachments, comment chain, and a comment form with screenshots and file attachments. Rows can be marked Ready or deleted. +The app opens in the Chrome side panel. The top icon menu includes Add URL, URLs, Gitea repos, Workspaces, Agents, Open Issues, 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. The Agents view manages persisted agent files, starting with `GLOBAL AGENT` from `AGENTS.md`; agents can use variables such as `{{git}}`, `{{workspace}}`, and `{{url}}` in their markdown content. Add Issue is enabled when the active tab base URL is already linked to a Gitea repo; it shows the active URL being attached, creates an issue with the selected agent and `Change Request` label, uploads the rendered agent file plus accumulated screenshots and files as issue assets, and can apply the `ready` label after uploads. Captured screenshots can be expanded, removed, or cropped with a plain draggable/resizable crop box. Open Issues lists recent issues by creation time, defaults to the current repo, can expand to all loaded repos, and lets a selected issue show its description, attachments, comment chain, and a comment form with screenshots and file attachments. Rows can be marked Ready or deleted, and existing issue attachments can be deleted. 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. diff --git a/docs/extension-management.md b/docs/extension-management.md index 16ecdc8..f6dfdf4 100644 --- a/docs/extension-management.md +++ b/docs/extension-management.md @@ -49,9 +49,18 @@ Required variables: - Created issues must upload the selected rendered agent file as a Gitea issue asset. Supported template variables in agent content are `{{git}}`, `{{repo}}`, `{{workspace}}`, `{{url}}`, `{{baseUrl}}`, `{{profile}}`, and `{{issue}}`. - Newly created issues must include the `Change Request` label. - When Ready is checked, create the issue, upload the rendered agent file plus 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 shows the original description, issue attachments, comment chain, and a comment form for text, additional screenshots, and file attachments. + +## Open Issues + +- Open Issues owns existing issue browsing and follow-up comments. +- The primary Open Issues panel lists recent issues ordered by creation time. +- The `Only current repo` checkbox defaults to checked and limits the table to the active URL's linked Gitea repo. +- Turning off `Only current repo` loads recent issues from all repos already loaded in the extension. +- Selecting an issue shows the original description, issue attachments, comment chain, and 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. +- Existing issue attachments can be deleted from the selected issue. - Existing issue rows can apply the `ready` label or delete the issue. Deletion must require confirmation. +- Typing in Add Issue, Open Issues comments, or Agents editor fields must not blur the focused control. ## URL Management diff --git a/index.html b/index.html index d7e1a42..3f4103f 100644 --- a/index.html +++ b/index.html @@ -30,6 +30,10 @@ Agents + ` ) @@ -514,11 +519,11 @@ function renderScreenshotPreview(): string { `; } -function renderIssueRows(issues: GiteaIssueSummary[], selectedIssueNumber: number | null): string { +function renderIssueRows(issues: OpenIssue[], selectedIssueNumber: number | null, selectedRepoFullName: string): string { const state = appStore.getState(); if (!issues.length) { - return `
No issues found for this repo.
`; + return `
No issues found.
`; } return ` @@ -526,15 +531,16 @@ function renderIssueRows(issues: GiteaIssueSummary[], selectedIssueNumber: numbe ${issues .map( (issue) => ` -
- - - + +
` ) @@ -621,21 +627,45 @@ function renderSelectedIssueDetail(): string { `; } -function renderTargetIssues(): string { +function renderOpenIssues(): string { const state = appStore.getState(); - const selectedIssue = state.targetIssues.find((issue) => issue.number === state.selectedIssueNumber) ?? null; return ` -
-
- Issues +
+
+
- ${state.isLoadingTargetIssues && !state.targetIssues.length ? `
Loading issues...
` : renderIssueRows(state.targetIssues, state.selectedIssueNumber)} - ${selectedIssue ? `${renderSelectedIssueDetail()}${renderIssueCommentForm(selectedIssue)}` : ""} -
+ ${ + state.openIssuesLimitToCurrentRepo && !getActiveKnownUrl()?.giteaRepo + ? `
Open a known URL linked to a Gitea repo, or turn off the current repo filter.
` + : state.isLoadingTargetIssues && !state.openIssues.length + ? `
Loading issues...
` + : renderIssueRows(state.openIssues, state.selectedIssueNumber, state.selectedIssueRepoFullName) + } + + ${ + state.selectedIssueNumber + ? `${renderSelectedIssueDetail()}${ + state.openIssues.find( + (issue) => + issue.number === state.selectedIssueNumber && issue.repoFullName === state.selectedIssueRepoFullName + ) + ? renderIssueCommentForm( + state.openIssues.find( + (issue) => + issue.number === state.selectedIssueNumber && issue.repoFullName === state.selectedIssueRepoFullName + ) as OpenIssue + ) + : "" + }` + : "" + } `; } @@ -709,7 +739,6 @@ function renderAddIssue(): string { - ${renderTargetIssues()} `; } @@ -810,6 +839,10 @@ function bindViewActions(): void { void appStore.getState().saveCurrentUrlLink(); }); + viewContent?.querySelector("#limit-open-issues")?.addEventListener("change", (event) => { + appStore.getState().setOpenIssuesLimit((event.target as HTMLInputElement).checked); + }); + viewContent?.querySelector("#issue-subject")?.addEventListener("input", (event) => { appStore.getState().setIssueDraft({ subject: (event.target as HTMLInputElement).value @@ -875,8 +908,9 @@ function bindViewActions(): void { viewContent?.querySelectorAll("[data-select-issue]").forEach((button) => { button.addEventListener("click", () => { const issueNumber = Number(button.dataset.selectIssue); + const repoFullName = button.dataset.issueRepo; if (Number.isFinite(issueNumber)) { - appStore.getState().selectIssue(issueNumber); + appStore.getState().selectIssue(issueNumber, repoFullName); } }); }); @@ -884,8 +918,9 @@ function bindViewActions(): void { viewContent?.querySelectorAll("[data-ready-issue]").forEach((button) => { button.addEventListener("click", () => { const issueNumber = Number(button.dataset.readyIssue); + const repoFullName = button.dataset.issueRepo; if (Number.isFinite(issueNumber)) { - void appStore.getState().markIssueReady(issueNumber); + void appStore.getState().markIssueReady(issueNumber, repoFullName); } }); }); @@ -893,20 +928,30 @@ function bindViewActions(): void { viewContent?.querySelectorAll("[data-delete-issue]").forEach((button) => { button.addEventListener("click", () => { const issueNumber = Number(button.dataset.deleteIssue); + const repoFullName = button.dataset.issueRepo; if (Number.isFinite(issueNumber) && window.confirm(`Delete issue #${issueNumber}?`)) { - void appStore.getState().deleteIssue(issueNumber); + void appStore.getState().deleteIssue(issueNumber, repoFullName); } }); }); viewContent?.querySelector("#refresh-target-issues")?.addEventListener("click", () => { - void appStore.getState().loadTargetIssues(true); + void appStore.getState().loadOpenIssues(true); }); viewContent?.querySelector("#refresh-issue-detail")?.addEventListener("click", () => { void appStore.getState().loadSelectedIssueDetail(true); }); + viewContent?.querySelectorAll("[data-delete-issue-attachment]").forEach((button) => { + button.addEventListener("click", () => { + const attachmentId = Number(button.dataset.deleteIssueAttachment); + if (Number.isFinite(attachmentId) && window.confirm("Delete this attachment from the issue?")) { + void appStore.getState().deleteSelectedIssueAttachment(attachmentId); + } + }); + }); + viewContent?.querySelector("#issue-comment-content")?.addEventListener("input", (event) => { appStore.getState().setIssueCommentDraft({ content: (event.target as HTMLTextAreaElement).value @@ -1047,6 +1092,7 @@ function renderView(): void { "add-issue": "Add issue", "add-url": "Add URL", agents: "Agents", + "open-issues": "Open Issues", urls: "URLs", repos: "Gitea repos", workspaces: "Workspaces" @@ -1056,6 +1102,7 @@ function renderView(): void { "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.", agents: "Agent files available for issue handoff.", + "open-issues": "Recent issues ordered by creation time.", 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"}.` @@ -1078,6 +1125,8 @@ function renderView(): void { viewContent.innerHTML = renderWorkspaces(context?.workspaces ?? []); } else if (state.activeView === "agents") { viewContent.innerHTML = renderAgents(state.agents); + } else if (state.activeView === "open-issues") { + viewContent.innerHTML = renderOpenIssues(); } else if (state.activeView === "add-issue") { viewContent.innerHTML = renderAddIssue(); } else if (state.activeView === "add-url") { @@ -1116,25 +1165,59 @@ function renderChrome(): void { }); } +function shouldRenderView(): boolean { + const activeElement = document.activeElement; + + if (!activeElement || !viewContent?.contains(activeElement)) { + return true; + } + + return !activeElement.matches( + "#issue-subject, #issue-content, #issue-agent, #issue-ready, #issue-comment-content, #agent-name, #agent-file, #agent-description, #agent-content" + ); +} + +function syncFocusedFormControls(): void { + const state = appStore.getState(); + const createIssue = viewContent?.querySelector("#create-issue"); + const submitComment = viewContent?.querySelector("#submit-issue-comment"); + + if (createIssue) { + createIssue.disabled = !state.issueDraft.subject.trim() || state.isCreatingIssue; + } + + if (submitComment) { + submitComment.disabled = + (!state.issueCommentDraft.content.trim() && + !state.issueCommentDraft.screenshots.length && + !state.issueCommentDraft.files.length) || + state.isCreatingIssueComment; + } +} + function render(): void { renderChrome(); - renderView(); - ensureTargetIssuesLoaded(); + if (shouldRenderView()) { + renderView(); + } else { + syncFocusedFormControls(); + } + ensureOpenIssuesLoaded(); ensureSelectedIssueDetailLoaded(); } -function ensureTargetIssuesLoaded(): void { +function ensureOpenIssuesLoaded(): void { const state = appStore.getState(); - const knownUrl = getActiveKnownUrl(); - const fullName = knownUrl?.giteaRepo?.fullName ?? ""; + const scopeKey = state.openIssuesLimitToCurrentRepo + ? `current:${getActiveKnownUrl()?.giteaRepo?.fullName ?? "none"}` + : "all"; if ( - state.activeView === "add-issue" && - fullName && - state.issueListRepoFullName !== fullName && + state.activeView === "open-issues" && + state.openIssuesScopeKey !== scopeKey && !state.isLoadingTargetIssues ) { - void appStore.getState().loadTargetIssues(); + void appStore.getState().loadOpenIssues(); } } @@ -1142,7 +1225,7 @@ function ensureSelectedIssueDetailLoaded(): void { const state = appStore.getState(); if ( - state.activeView === "add-issue" && + state.activeView === "open-issues" && state.selectedIssueNumber && state.selectedIssueDetailNumber !== state.selectedIssueNumber && !state.isLoadingIssueDetail diff --git a/src/store.ts b/src/store.ts index 4de42cb..0af7c66 100644 --- a/src/store.ts +++ b/src/store.ts @@ -4,6 +4,7 @@ import { createGiteaIssueComment, createGiteaIssue, deleteGiteaIssue, + deleteGiteaIssueAttachment, ensureGiteaLabel, getCurrentGiteaUser, getGiteaIssueDetail, @@ -21,7 +22,7 @@ import { loadLocalContext, type LocalContext } from "./localContext"; import type { KnownUrl, LocalGiteaRepoLink, LocalWorkspace } from "./localContext"; export type StatusTone = "neutral" | "success" | "error"; -export type AppView = "urls" | "repos" | "workspaces" | "agents" | "add-url" | "add-issue"; +export type AppView = "urls" | "repos" | "workspaces" | "agents" | "open-issues" | "add-url" | "add-issue"; export type AddUrlMode = "workspace" | "repo"; export type ScreenshotTarget = "issue" | "comment"; @@ -91,6 +92,14 @@ export interface ScreenshotAttachment { blob: Blob; } +export interface OpenIssue extends GiteaIssueSummary { + repoFullName: string; + repoWebUrl: string; + owner: string; + repo: string; + knownUrl: KnownUrl | null; +} + interface IssueDraft { subject: string; content: string; @@ -137,8 +146,12 @@ interface AppState { editingAgentId: string | null; screenshotPreview: ScreenshotPreview | null; targetIssues: GiteaIssueSummary[]; + openIssues: OpenIssue[]; + openIssuesLimitToCurrentRepo: boolean; + openIssuesScopeKey: string; issueListRepoFullName: string; selectedIssueNumber: number | null; + selectedIssueRepoFullName: string; selectedIssueDetail: GiteaIssueDetail | null; selectedIssueDetailNumber: number | null; isLoadingData: boolean; @@ -173,11 +186,14 @@ interface AppState { setScreenshotPreviewCrop: (crop: Partial) => void; applyScreenshotCrop: () => Promise; removePreviewScreenshot: () => void; - selectIssue: (issueNumber: number) => void; + setOpenIssuesLimit: (limitToCurrentRepo: boolean) => void; + selectIssue: (issueNumber: number, repoFullName?: string) => void; + loadOpenIssues: (force?: boolean) => Promise; loadTargetIssues: (force?: boolean) => Promise; loadSelectedIssueDetail: (force?: boolean) => Promise; - markIssueReady: (issueNumber: number) => Promise; - deleteIssue: (issueNumber: number) => Promise; + markIssueReady: (issueNumber: number, repoFullName?: string) => Promise; + deleteIssue: (issueNumber: number, repoFullName?: string) => Promise; + deleteSelectedIssueAttachment: (attachmentId: number) => Promise; captureIssueCommentScreenshot: () => Promise; createIssueComment: () => Promise; captureScreenshot: () => Promise; @@ -308,7 +324,15 @@ function parseRepoFullName(fullName: string): { owner: string; repo: string } | return { owner, repo }; } -function getTargetRepo(state: AppState): { knownUrl: KnownUrl; owner: string; repo: string; fullName: string } | null { +interface RepoTarget { + knownUrl: KnownUrl | null; + owner: string; + repo: string; + fullName: string; + webUrl: string; +} + +function getTargetRepo(state: AppState): RepoTarget | null { const knownUrl = findKnownUrlForBase(state); const parsed = knownUrl?.giteaRepo ? parseRepoFullName(knownUrl.giteaRepo.fullName) : null; @@ -320,7 +344,57 @@ function getTargetRepo(state: AppState): { knownUrl: KnownUrl; owner: string; re knownUrl, owner: parsed.owner, repo: parsed.repo, - fullName: knownUrl.giteaRepo.fullName + fullName: knownUrl.giteaRepo.fullName, + webUrl: knownUrl.giteaRepo.webUrl + }; +} + +function getKnownUrlForRepo(state: AppState, repoFullName: string): KnownUrl | null { + return allKnownUrls(state).find((knownUrl) => knownUrl.giteaRepo?.fullName === repoFullName) ?? null; +} + +function getRepoTargetByFullName(state: AppState, repoFullName: string): RepoTarget | null { + const parsed = parseRepoFullName(repoFullName); + if (!parsed) { + return null; + } + + const knownUrl = getKnownUrlForRepo(state, repoFullName); + const repoSummary = state.repos.find((repo) => repo.fullName === repoFullName); + + return { + knownUrl, + owner: parsed.owner, + repo: parsed.repo, + fullName: repoFullName, + webUrl: knownUrl?.giteaRepo?.webUrl ?? repoSummary?.webUrl ?? `${giteaConfig.baseUrl.replace(/\/+$/, "")}/${repoFullName}` + }; +} + +function getIssueTarget(state: AppState, repoFullName = state.selectedIssueRepoFullName): RepoTarget | null { + if (repoFullName) { + return getRepoTargetByFullName(state, repoFullName); + } + + return getTargetRepo(state); +} + +function getOpenIssuesScopeKey(state: AppState): string { + if (!state.openIssuesLimitToCurrentRepo) { + return "all"; + } + + return `current:${getTargetRepo(state)?.fullName ?? "none"}`; +} + +function toOpenIssue(issue: GiteaIssueSummary, target: RepoTarget): OpenIssue { + return { + ...issue, + repoFullName: target.fullName, + repoWebUrl: target.webUrl, + owner: target.owner, + repo: target.repo, + knownUrl: target.knownUrl }; } @@ -578,13 +652,13 @@ function buildAttachmentMarkdown(attachments: GiteaIssueAttachment[]): string { return lines.length ? `Attachments:\n${lines.join("\n")}` : ""; } -function buildIssueCommentBody(state: AppState, knownUrl: KnownUrl, attachments: GiteaIssueAttachment[]): string { +function buildIssueCommentBody(state: AppState, knownUrl: KnownUrl | null, attachments: GiteaIssueAttachment[]): string { const parts = [ state.issueCommentDraft.content.trim(), buildAttachmentMarkdown(attachments), "---", - `Source URL: ${state.activeTabUrl || knownUrl.url}`, - `Base URL: ${state.activeBaseUrl || getBaseUrl(knownUrl.url)}`, + `Source URL: ${state.activeTabUrl || knownUrl?.url || "Not linked to a known URL"}`, + `Base URL: ${state.activeBaseUrl || (knownUrl ? getBaseUrl(knownUrl.url) : "Not linked")}`, `Chrome profile: ${CHROME_PROFILE_NAME}` ]; @@ -623,8 +697,12 @@ export const appStore = createStore()((set, get) => ({ editingAgentId: AVAILABLE_AGENTS[0].id, screenshotPreview: null, targetIssues: [], + openIssues: [], + openIssuesLimitToCurrentRepo: true, + openIssuesScopeKey: "", issueListRepoFullName: "", selectedIssueNumber: null, + selectedIssueRepoFullName: "", selectedIssueDetail: null, selectedIssueDetailNumber: null, isLoadingData: false, @@ -880,15 +958,33 @@ export const appStore = createStore()((set, get) => ({ set({ screenshotPreview: null }); }, - selectIssue: (issueNumber) => + setOpenIssuesLimit: (limitToCurrentRepo) => + set({ + openIssuesLimitToCurrentRepo: limitToCurrentRepo, + openIssuesScopeKey: "", + openIssues: [], + selectedIssueNumber: null, + selectedIssueRepoFullName: "", + selectedIssueDetail: null, + selectedIssueDetailNumber: null, + issueCommentDraft: { + content: "", + screenshots: [], + files: [] + } + }), + selectIssue: (issueNumber, repoFullName) => set((state) => ({ selectedIssueNumber: issueNumber, + selectedIssueRepoFullName: repoFullName ?? state.selectedIssueRepoFullName ?? getTargetRepo(state)?.fullName ?? "", selectedIssueDetail: - state.selectedIssueDetailNumber === issueNumber + state.selectedIssueDetailNumber === issueNumber && + (!repoFullName || state.selectedIssueRepoFullName === repoFullName) ? state.selectedIssueDetail : null, selectedIssueDetailNumber: - state.selectedIssueDetailNumber === issueNumber + state.selectedIssueDetailNumber === issueNumber && + (!repoFullName || state.selectedIssueRepoFullName === repoFullName) ? state.selectedIssueDetailNumber : null, issueCommentDraft: { @@ -897,6 +993,100 @@ export const appStore = createStore()((set, get) => ({ files: [] } })), + loadOpenIssues: async (force = false) => { + const state = appStore.getState(); + const scopeKey = getOpenIssuesScopeKey(state); + + if (!force && state.openIssuesScopeKey === scopeKey && state.openIssues.length > 0) { + return; + } + + set({ + isLoadingTargetIssues: true, + openIssuesScopeKey: scopeKey + }); + + try { + const targets = state.openIssuesLimitToCurrentRepo + ? [getTargetRepo(state)].filter((target): target is RepoTarget => Boolean(target)) + : state.repos + .map((repo) => getRepoTargetByFullName(state, repo.fullName)) + .filter((target): target is RepoTarget => Boolean(target)); + + if (!targets.length) { + set({ + openIssues: [], + selectedIssueNumber: null, + selectedIssueRepoFullName: "", + selectedIssueDetail: null, + selectedIssueDetailNumber: null + }); + return; + } + + const issueGroups = await Promise.all( + targets.map(async (target) => + (await getGiteaIssues(target.owner, target.repo, { limit: 25, sort: "created", order: "desc" })).map((issue) => + toOpenIssue(issue, target) + ) + ) + ); + const openIssues = issueGroups + .flat() + .sort((a, b) => { + const aTime = a.createdAt ? Date.parse(a.createdAt) : 0; + const bTime = b.createdAt ? Date.parse(b.createdAt) : 0; + return bTime - aTime || a.repoFullName.localeCompare(b.repoFullName) || b.number - a.number; + }) + .slice(0, state.openIssuesLimitToCurrentRepo ? 25 : 100); + + set((current) => ({ + openIssues, + openIssuesScopeKey: scopeKey, + selectedIssueNumber: + current.selectedIssueNumber && + openIssues.some( + (issue) => + issue.number === current.selectedIssueNumber && + issue.repoFullName === current.selectedIssueRepoFullName + ) + ? current.selectedIssueNumber + : null, + selectedIssueRepoFullName: + current.selectedIssueNumber && + openIssues.some( + (issue) => + issue.number === current.selectedIssueNumber && + issue.repoFullName === current.selectedIssueRepoFullName + ) + ? current.selectedIssueRepoFullName + : "", + selectedIssueDetail: + current.selectedIssueNumber && + openIssues.some( + (issue) => + issue.number === current.selectedIssueNumber && + issue.repoFullName === current.selectedIssueRepoFullName + ) + ? current.selectedIssueDetail + : null, + selectedIssueDetailNumber: + current.selectedIssueNumber && + openIssues.some( + (issue) => + issue.number === current.selectedIssueNumber && + issue.repoFullName === current.selectedIssueRepoFullName + ) + ? current.selectedIssueDetailNumber + : null + })); + } catch (error) { + const message = error instanceof Error ? error.message : "Could not load open Gitea issues."; + setStatus(set, message, "error"); + } finally { + set({ isLoadingTargetIssues: false }); + } + }, loadTargetIssues: async (force = false) => { const state = appStore.getState(); const target = getTargetRepo(state); @@ -948,7 +1138,7 @@ export const appStore = createStore()((set, get) => ({ }, loadSelectedIssueDetail: async (force = false) => { const state = appStore.getState(); - const target = getTargetRepo(state); + const target = getIssueTarget(state); const issueNumber = state.selectedIssueNumber; if (!target || !issueNumber) { @@ -981,8 +1171,8 @@ export const appStore = createStore()((set, get) => ({ set({ isLoadingIssueDetail: false }); } }, - markIssueReady: async (issueNumber) => { - const target = getTargetRepo(appStore.getState()); + markIssueReady: async (issueNumber, repoFullName) => { + const target = getIssueTarget(appStore.getState(), repoFullName); if (!target) { setStatus(set, "Open a known URL with a linked Gitea repo before updating an issue.", "error"); @@ -995,6 +1185,7 @@ export const appStore = createStore()((set, get) => ({ try { const readyLabel = await ensureGiteaLabel(target.owner, target.repo, "ready"); await addGiteaIssueLabels(target.owner, target.repo, issueNumber, [readyLabel.id]); + await appStore.getState().loadOpenIssues(true); await appStore.getState().loadTargetIssues(true); await appStore.getState().loadSelectedIssueDetail(true); setStatus(set, `Applied ready label to issue #${issueNumber}.`, "success"); @@ -1005,8 +1196,8 @@ export const appStore = createStore()((set, get) => ({ set({ isUpdatingIssue: false }); } }, - deleteIssue: async (issueNumber) => { - const target = getTargetRepo(appStore.getState()); + deleteIssue: async (issueNumber, repoFullName) => { + const target = getIssueTarget(appStore.getState(), repoFullName); if (!target) { setStatus(set, "Open a known URL with a linked Gitea repo before deleting an issue.", "error"); @@ -1020,11 +1211,27 @@ export const appStore = createStore()((set, get) => ({ await deleteGiteaIssue(target.owner, target.repo, issueNumber); set((state) => ({ targetIssues: state.targetIssues.filter((issue) => issue.number !== issueNumber), - selectedIssueNumber: state.selectedIssueNumber === issueNumber ? null : state.selectedIssueNumber, - selectedIssueDetail: state.selectedIssueNumber === issueNumber ? null : state.selectedIssueDetail, - selectedIssueDetailNumber: state.selectedIssueNumber === issueNumber ? null : state.selectedIssueDetailNumber, + openIssues: state.openIssues.filter( + (issue) => issue.number !== issueNumber || issue.repoFullName !== target.fullName + ), + selectedIssueNumber: + state.selectedIssueNumber === issueNumber && state.selectedIssueRepoFullName === target.fullName + ? null + : state.selectedIssueNumber, + selectedIssueRepoFullName: + state.selectedIssueNumber === issueNumber && state.selectedIssueRepoFullName === target.fullName + ? "" + : state.selectedIssueRepoFullName, + selectedIssueDetail: + state.selectedIssueNumber === issueNumber && state.selectedIssueRepoFullName === target.fullName + ? null + : state.selectedIssueDetail, + selectedIssueDetailNumber: + state.selectedIssueNumber === issueNumber && state.selectedIssueRepoFullName === target.fullName + ? null + : state.selectedIssueDetailNumber, issueCommentDraft: - state.selectedIssueNumber === issueNumber + state.selectedIssueNumber === issueNumber && state.selectedIssueRepoFullName === target.fullName ? { content: "", screenshots: [], @@ -1032,6 +1239,7 @@ export const appStore = createStore()((set, get) => ({ } : state.issueCommentDraft })); + await appStore.getState().loadOpenIssues(true); await appStore.getState().loadTargetIssues(true); setStatus(set, `Deleted issue #${issueNumber}.`, "success"); } catch (error) { @@ -1041,6 +1249,30 @@ export const appStore = createStore()((set, get) => ({ set({ isUpdatingIssue: false }); } }, + deleteSelectedIssueAttachment: async (attachmentId) => { + const state = appStore.getState(); + const target = getIssueTarget(state); + const issueNumber = state.selectedIssueNumber; + + if (!target || !issueNumber) { + setStatus(set, "Select an issue before deleting an attachment.", "error"); + return; + } + + set({ isUpdatingIssue: true }); + setStatus(set, `Deleting attachment from issue #${issueNumber}...`, "neutral", { autoClear: false }); + + try { + await deleteGiteaIssueAttachment(target.owner, target.repo, issueNumber, attachmentId); + await appStore.getState().loadSelectedIssueDetail(true); + setStatus(set, "Deleted attachment.", "success"); + } catch (error) { + const message = error instanceof Error ? error.message : "Could not delete the attachment."; + setStatus(set, message, "error"); + } finally { + set({ isUpdatingIssue: false }); + } + }, captureScreenshot: async () => { set({ isCapturingScreenshot: true }); setStatus(set, "Capturing screenshot...", "neutral", { autoClear: false }); @@ -1087,7 +1319,7 @@ export const appStore = createStore()((set, get) => ({ }, createIssueComment: async () => { const state = appStore.getState(); - const target = getTargetRepo(state); + const target = getIssueTarget(state); const selectedIssueNumber = state.selectedIssueNumber; const hasAttachments = state.issueCommentDraft.screenshots.length > 0 || state.issueCommentDraft.files.length > 0; @@ -1138,6 +1370,7 @@ export const appStore = createStore()((set, get) => ({ files: [] } }); + await appStore.getState().loadOpenIssues(true); await appStore.getState().loadTargetIssues(true); await appStore.getState().loadSelectedIssueDetail(true); setStatus(set, `Commented on issue #${selectedIssueNumber}.`, "success"); @@ -1158,6 +1391,11 @@ export const appStore = createStore()((set, get) => ({ return; } + if (!target.knownUrl) { + setStatus(set, "Open a known URL before creating an issue.", "error"); + return; + } + if (!subject) { setStatus(set, "Add an issue subject before creating it.", "error"); return; @@ -1209,8 +1447,11 @@ export const appStore = createStore()((set, get) => ({ screenshots: [], files: [] }, - selectedIssueNumber: issue.number + selectedIssueNumber: issue.number, + selectedIssueRepoFullName: target.fullName, + activeView: "open-issues" }); + await appStore.getState().loadOpenIssues(true); await appStore.getState().loadTargetIssues(true); await appStore.getState().loadSelectedIssueDetail(true); setStatus(set, `Created issue #${issue.number} in ${target.fullName}.`, "success"); @@ -1241,16 +1482,28 @@ export const appStore = createStore()((set, get) => ({ activeBaseUrl !== currentBaseUrl ? "" : get().issueListRepoFullName, + openIssues: + activeBaseUrl !== currentBaseUrl && get().openIssuesLimitToCurrentRepo + ? [] + : get().openIssues, + openIssuesScopeKey: + activeBaseUrl !== currentBaseUrl && get().openIssuesLimitToCurrentRepo + ? "" + : get().openIssuesScopeKey, selectedIssueNumber: - activeBaseUrl !== currentBaseUrl + activeBaseUrl !== currentBaseUrl && get().openIssuesLimitToCurrentRepo ? null : get().selectedIssueNumber, + selectedIssueRepoFullName: + activeBaseUrl !== currentBaseUrl && get().openIssuesLimitToCurrentRepo + ? "" + : get().selectedIssueRepoFullName, selectedIssueDetail: - activeBaseUrl !== currentBaseUrl + activeBaseUrl !== currentBaseUrl && get().openIssuesLimitToCurrentRepo ? null : get().selectedIssueDetail, selectedIssueDetailNumber: - activeBaseUrl !== currentBaseUrl + activeBaseUrl !== currentBaseUrl && get().openIssuesLimitToCurrentRepo ? null : get().selectedIssueDetailNumber }); diff --git a/src/styles.css b/src/styles.css index fdd1506..e265407 100644 --- a/src/styles.css +++ b/src/styles.css @@ -62,7 +62,7 @@ h1 { .toolbar { display: grid; - grid-template-columns: repeat(6, minmax(0, 1fr)); + grid-template-columns: repeat(7, minmax(0, 1fr)); gap: 1px; border-bottom: 1px solid #d9e2ec; background: #d9e2ec; @@ -521,12 +521,17 @@ input[readonly] { text-align: left; } +.open-issue-select-button { + grid-template-columns: 40px minmax(72px, 1fr) minmax(78px, 0.7fr) 46px 66px; +} + .issue-row[data-selected="true"] { background: #e6f6ff; } .issue-number, .issue-state, +.issue-repo, .issue-meta { overflow: hidden; color: #52616f; @@ -584,6 +589,9 @@ input[readonly] { } .issue-attachment-list li { + display: flex; + align-items: center; + gap: 5px; min-width: 0; } @@ -632,6 +640,13 @@ input[readonly] { background: #f8fafc; } +.issue-filter-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + .target-label, .attachment-heading > span { color: #52616f; @@ -864,6 +879,7 @@ input[readonly] { cursor: ew-resize; } +.checkbox-row, .form-stack .checkbox-row { display: flex; align-items: center; @@ -873,6 +889,7 @@ input[readonly] { font-size: 12px; } +.checkbox-row input, .form-stack .checkbox-row input { flex: 0 0 auto; width: auto;