Show selected issue details
Build / build (push) Successful in 11s

This commit is contained in:
2026-07-17 17:26:34 -05:00
parent 6cfe9a96e2
commit f1f4c0df3a
9 changed files with 375 additions and 12 deletions
+93 -1
View File
@@ -42,8 +42,10 @@ interface GiteaBranchResponse {
interface GiteaIssueResponse {
number?: number;
title?: string;
body?: string;
state?: string;
html_url?: string;
created_at?: string;
updated_at?: string;
user?: {
login?: string;
@@ -51,14 +53,29 @@ interface GiteaIssueResponse {
comments?: number;
}
interface GiteaIssueCommentResponse {
id?: number;
body?: string;
html_url?: string;
created_at?: string;
updated_at?: string;
user?: {
login?: string;
};
}
interface GiteaLabelResponse {
id: number;
name: string;
}
interface GiteaAttachmentResponse {
id?: number;
name?: string;
browser_download_url?: string;
download_url?: string;
size?: number;
created_at?: string;
}
export interface CreatedGiteaIssue {
@@ -69,16 +86,36 @@ export interface CreatedGiteaIssue {
export interface GiteaIssueSummary {
number: number;
title: string;
body: string;
state: string;
webUrl: string;
createdAt: string;
updatedAt: string;
author: string;
comments: number;
}
export interface GiteaIssueAttachment {
id: number;
name: string;
downloadUrl: string;
size: number;
createdAt: string;
}
export interface GiteaIssueComment {
id: number;
body: string;
webUrl: string;
createdAt: string;
updatedAt: string;
author: string;
}
export interface GiteaIssueDetail {
issue: GiteaIssueSummary;
comments: GiteaIssueComment[];
attachments: GiteaIssueAttachment[];
}
export interface GiteaRepoSummary {
@@ -174,14 +211,66 @@ export async function getGiteaIssues(owner: string, repo: string): Promise<Gitea
.map((issue) => ({
number: issue.number ?? 0,
title: issue.title || "Untitled issue",
body: issue.body || "",
state: issue.state || "unknown",
webUrl: issue.html_url || `${giteaConfig.baseUrl.replace(/\/+$/, "")}/${owner}/${repo}/issues/${issue.number}`,
createdAt: issue.created_at || "",
updatedAt: issue.updated_at || "",
author: issue.user?.login || "unknown",
comments: issue.comments ?? 0
}));
}
export async function getGiteaIssueDetail(owner: string, repo: string, issueNumber: number): Promise<GiteaIssueDetail> {
const client = createGiteaClient();
const [issueResponse, commentsResponse, attachmentsResponse] = await Promise.all([
client.get<GiteaIssueResponse>(`/repos/${owner}/${repo}/issues/${issueNumber}`),
client.get<GiteaIssueCommentResponse[]>(`/repos/${owner}/${repo}/issues/${issueNumber}/comments`, {
params: {
limit: 100,
page: 1
}
}),
client.get<GiteaAttachmentResponse[]>(`/repos/${owner}/${repo}/issues/${issueNumber}/assets`, {
params: {
limit: 100,
page: 1
}
})
]);
const issue = issueResponse.data;
return {
issue: {
number: issue.number ?? issueNumber,
title: issue.title || "Untitled issue",
body: issue.body || "",
state: issue.state || "unknown",
webUrl: issue.html_url || `${giteaConfig.baseUrl.replace(/\/+$/, "")}/${owner}/${repo}/issues/${issueNumber}`,
createdAt: issue.created_at || "",
updatedAt: issue.updated_at || "",
author: issue.user?.login || "unknown",
comments: issue.comments ?? commentsResponse.data.length
},
comments: commentsResponse.data.map((comment) => ({
id: comment.id ?? 0,
body: comment.body || "",
webUrl: comment.html_url || "",
createdAt: comment.created_at || "",
updatedAt: comment.updated_at || "",
author: comment.user?.login || "unknown"
})),
attachments: attachmentsResponse.data.map((attachment) => ({
id: attachment.id ?? 0,
name: attachment.name || "attachment",
downloadUrl: attachment.browser_download_url || attachment.download_url || "",
size: attachment.size ?? 0,
createdAt: attachment.created_at || ""
}))
};
}
export async function uploadGiteaIssueAttachment(
owner: string,
repo: string,
@@ -199,8 +288,11 @@ export async function uploadGiteaIssueAttachment(
);
return {
id: response.data.id ?? 0,
name: response.data.name || fileName,
downloadUrl: response.data.browser_download_url || ""
downloadUrl: response.data.browser_download_url || response.data.download_url || "",
size: response.data.size ?? file.size,
createdAt: response.data.created_at || ""
};
}
+125 -2
View File
@@ -8,7 +8,7 @@ import {
type AppView,
type ScreenshotAttachment
} from "./store";
import type { GiteaIssueSummary, GiteaRepoSummary } from "./api";
import type { GiteaIssueAttachment, GiteaIssueComment, GiteaIssueSummary, GiteaRepoSummary } from "./api";
import type { KnownUrl, LocalContext, LocalWorkspace } from "./localContext";
const status = document.querySelector<HTMLElement>("#status");
@@ -60,6 +60,22 @@ function formatSha(value: string): string {
return value ? value.slice(0, 8) : "unknown";
}
function formatBytes(value: number): string {
if (!value) {
return "";
}
if (value < 1024) {
return `${value} B`;
}
if (value < 1024 * 1024) {
return `${Math.round(value / 102.4) / 10} KB`;
}
return `${Math.round(value / 1024 / 102.4) / 10} MB`;
}
function allUrls(): KnownUrl[] {
const state = appStore.getState();
const deletedBases = new Set(state.deletedUrlBases);
@@ -294,6 +310,60 @@ function renderAttachmentList(items: string[], emptyText: string): string {
`;
}
function renderBodyText(body: string, emptyText: string): string {
const trimmed = body.trim();
if (!trimmed) {
return `<p class="muted">${escapeHtml(emptyText)}</p>`;
}
return `<pre class="issue-body-text">${escapeHtml(trimmed)}</pre>`;
}
function renderIssueAttachments(attachments: GiteaIssueAttachment[]): string {
if (!attachments.length) {
return `<p class="muted">No issue attachments.</p>`;
}
return `
<ul class="issue-attachment-list">
${attachments
.map(
(attachment) => `
<li>
<a href="${attachment.downloadUrl}" target="_blank" rel="noreferrer">${escapeHtml(attachment.name)}</a>
${attachment.size ? `<span>${escapeHtml(formatBytes(attachment.size))}</span>` : ""}
</li>
`
)
.join("")}
</ul>
`;
}
function renderIssueComments(comments: GiteaIssueComment[]): string {
if (!comments.length) {
return `<p class="muted">No comments yet.</p>`;
}
return `
<div class="issue-comment-chain">
${comments
.map(
(comment) => `
<article class="issue-comment">
<div class="issue-comment-meta">
<span>${escapeHtml(comment.author)}</span>
<a href="${comment.webUrl}" target="_blank" rel="noreferrer">${escapeHtml(formatDate(comment.createdAt))}</a>
</div>
${renderBodyText(comment.body, "Empty comment.")}
</article>
`
)
.join("")}
</div>
`;
}
function renderScreenshotList(screenshots: ScreenshotAttachment[], deleteAction: "issue" | "comment"): string {
if (!screenshots.length) {
return `<span class="muted">No screenshots captured.</span>`;
@@ -393,6 +463,41 @@ function renderIssueCommentForm(issue: GiteaIssueSummary): string {
`;
}
function renderSelectedIssueDetail(): string {
const state = appStore.getState();
const detail = state.selectedIssueDetail;
if (state.isLoadingIssueDetail && !detail) {
return `<div class="empty-state">Loading issue details...</div>`;
}
if (!detail) {
return `<div class="empty-state">Issue details are not loaded yet.</div>`;
}
return `
<article class="item-card issue-detail-card">
<div class="section-heading inline-heading">
<span>Original description</span>
<button id="refresh-issue-detail" class="secondary-button compact-button" type="button" ${state.isLoadingIssueDetail ? "disabled" : ""}>
${state.isLoadingIssueDetail ? "Loading..." : "Refresh"}
</button>
</div>
${renderBodyText(detail.issue.body, "No description.")}
<div class="section-heading inline-heading">
<span>Attachments</span>
</div>
${renderIssueAttachments(detail.attachments)}
<div class="section-heading inline-heading">
<span>Comments</span>
</div>
${renderIssueComments(detail.comments)}
</article>
`;
}
function renderTargetIssues(): string {
const state = appStore.getState();
const selectedIssue = state.targetIssues.find((issue) => issue.number === state.selectedIssueNumber) ?? null;
@@ -406,7 +511,7 @@ function renderTargetIssues(): string {
</button>
</div>
${state.isLoadingTargetIssues && !state.targetIssues.length ? `<div class="empty-state">Loading issues...</div>` : renderIssueRows(state.targetIssues, state.selectedIssueNumber)}
${selectedIssue ? renderIssueCommentForm(selectedIssue) : ""}
${selectedIssue ? `${renderSelectedIssueDetail()}${renderIssueCommentForm(selectedIssue)}` : ""}
</section>
`;
}
@@ -591,6 +696,10 @@ function bindViewActions(): void {
void appStore.getState().loadTargetIssues(true);
});
viewContent?.querySelector<HTMLButtonElement>("#refresh-issue-detail")?.addEventListener("click", () => {
void appStore.getState().loadSelectedIssueDetail(true);
});
viewContent?.querySelector<HTMLTextAreaElement>("#issue-comment-content")?.addEventListener("input", (event) => {
appStore.getState().setIssueCommentDraft({
content: (event.target as HTMLTextAreaElement).value
@@ -702,6 +811,7 @@ function render(): void {
renderChrome();
renderView();
ensureTargetIssuesLoaded();
ensureSelectedIssueDetailLoaded();
}
function ensureTargetIssuesLoaded(): void {
@@ -719,6 +829,19 @@ function ensureTargetIssuesLoaded(): void {
}
}
function ensureSelectedIssueDetailLoaded(): void {
const state = appStore.getState();
if (
state.activeView === "add-issue" &&
state.selectedIssueNumber &&
state.selectedIssueDetailNumber !== state.selectedIssueNumber &&
!state.isLoadingIssueDetail
) {
void appStore.getState().loadSelectedIssueDetail();
}
}
viewButtons.forEach((button) => {
button.addEventListener("click", () => {
const view = button.dataset.view as AppView | undefined;
+79 -4
View File
@@ -6,10 +6,12 @@ import {
deleteGiteaIssue,
ensureGiteaLabel,
getCurrentGiteaUser,
getGiteaIssueDetail,
getGiteaIssues,
getReposOrderedByLastCommit,
giteaConfig,
type GiteaIssueAttachment,
type GiteaIssueDetail,
type GiteaIssueSummary,
type GiteaRepoSummary,
type GiteaUser,
@@ -66,8 +68,11 @@ interface AppState {
targetIssues: GiteaIssueSummary[];
issueListRepoFullName: string;
selectedIssueNumber: number | null;
selectedIssueDetail: GiteaIssueDetail | null;
selectedIssueDetailNumber: number | null;
isLoadingData: boolean;
isLoadingTargetIssues: boolean;
isLoadingIssueDetail: boolean;
isTestingConnection: boolean;
isCapturingScreenshot: boolean;
isCapturingIssueCommentScreenshot: boolean;
@@ -87,6 +92,7 @@ interface AppState {
deleteIssueCommentScreenshot: (id: string) => void;
selectIssue: (issueNumber: number) => void;
loadTargetIssues: (force?: boolean) => Promise<void>;
loadSelectedIssueDetail: (force?: boolean) => Promise<void>;
markIssueReady: (issueNumber: number) => Promise<void>;
deleteIssue: (issueNumber: number) => Promise<void>;
captureIssueCommentScreenshot: () => Promise<void>;
@@ -419,8 +425,11 @@ export const appStore = createStore<AppState>()((set, get) => ({
targetIssues: [],
issueListRepoFullName: "",
selectedIssueNumber: null,
selectedIssueDetail: null,
selectedIssueDetailNumber: null,
isLoadingData: false,
isLoadingTargetIssues: false,
isLoadingIssueDetail: false,
isTestingConnection: false,
isCapturingScreenshot: false,
isCapturingIssueCommentScreenshot: false,
@@ -487,14 +496,22 @@ export const appStore = createStore<AppState>()((set, get) => ({
}
})),
selectIssue: (issueNumber) =>
set({
set((state) => ({
selectedIssueNumber: issueNumber,
selectedIssueDetail:
state.selectedIssueDetailNumber === issueNumber
? state.selectedIssueDetail
: null,
selectedIssueDetailNumber:
state.selectedIssueDetailNumber === issueNumber
? state.selectedIssueDetailNumber
: null,
issueCommentDraft: {
content: "",
screenshots: [],
files: []
}
}),
})),
loadTargetIssues: async (force = false) => {
const state = appStore.getState();
const target = getTargetRepo(state);
@@ -503,7 +520,9 @@ export const appStore = createStore<AppState>()((set, get) => ({
set({
targetIssues: [],
issueListRepoFullName: "",
selectedIssueNumber: null
selectedIssueNumber: null,
selectedIssueDetail: null,
selectedIssueDetailNumber: null
});
return;
}
@@ -525,6 +544,14 @@ export const appStore = createStore<AppState>()((set, get) => ({
selectedIssueNumber:
current.selectedIssueNumber && targetIssues.some((issue) => issue.number === current.selectedIssueNumber)
? current.selectedIssueNumber
: null,
selectedIssueDetail:
current.selectedIssueNumber && targetIssues.some((issue) => issue.number === current.selectedIssueNumber)
? current.selectedIssueDetail
: null,
selectedIssueDetailNumber:
current.selectedIssueNumber && targetIssues.some((issue) => issue.number === current.selectedIssueNumber)
? current.selectedIssueDetailNumber
: null
}));
} catch (error) {
@@ -534,6 +561,41 @@ export const appStore = createStore<AppState>()((set, get) => ({
set({ isLoadingTargetIssues: false });
}
},
loadSelectedIssueDetail: async (force = false) => {
const state = appStore.getState();
const target = getTargetRepo(state);
const issueNumber = state.selectedIssueNumber;
if (!target || !issueNumber) {
set({
selectedIssueDetail: null,
selectedIssueDetailNumber: null
});
return;
}
if (!force && state.selectedIssueDetailNumber === issueNumber && state.selectedIssueDetail) {
return;
}
set({
isLoadingIssueDetail: true,
selectedIssueDetailNumber: issueNumber
});
try {
const selectedIssueDetail = await getGiteaIssueDetail(target.owner, target.repo, issueNumber);
set({
selectedIssueDetail,
selectedIssueDetailNumber: issueNumber
});
} catch (error) {
const message = error instanceof Error ? error.message : "Could not load issue details.";
setStatus(set, message, "error");
} finally {
set({ isLoadingIssueDetail: false });
}
},
markIssueReady: async (issueNumber) => {
const target = getTargetRepo(appStore.getState());
@@ -549,6 +611,7 @@ export const appStore = createStore<AppState>()((set, get) => ({
const readyLabel = await ensureGiteaLabel(target.owner, target.repo, "ready");
await addGiteaIssueLabels(target.owner, target.repo, issueNumber, [readyLabel.id]);
await appStore.getState().loadTargetIssues(true);
await appStore.getState().loadSelectedIssueDetail(true);
setStatus(set, `Applied ready label to issue #${issueNumber}.`, "success");
} catch (error) {
const message = error instanceof Error ? error.message : "Could not apply the ready label.";
@@ -573,6 +636,8 @@ export const appStore = createStore<AppState>()((set, get) => ({
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,
issueCommentDraft:
state.selectedIssueNumber === issueNumber
? {
@@ -689,6 +754,7 @@ export const appStore = createStore<AppState>()((set, get) => ({
}
});
await appStore.getState().loadTargetIssues(true);
await appStore.getState().loadSelectedIssueDetail(true);
setStatus(set, `Commented on issue #${selectedIssueNumber}.`, "success");
} catch (error) {
const message = error instanceof Error ? error.message : "Could not comment on the Gitea issue.";
@@ -755,6 +821,7 @@ export const appStore = createStore<AppState>()((set, get) => ({
selectedIssueNumber: issue.number
});
await appStore.getState().loadTargetIssues(true);
await appStore.getState().loadSelectedIssueDetail(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.";
@@ -786,7 +853,15 @@ export const appStore = createStore<AppState>()((set, get) => ({
selectedIssueNumber:
activeBaseUrl !== currentBaseUrl
? null
: get().selectedIssueNumber
: get().selectedIssueNumber,
selectedIssueDetail:
activeBaseUrl !== currentBaseUrl
? null
: get().selectedIssueDetail,
selectedIssueDetailNumber:
activeBaseUrl !== currentBaseUrl
? null
: get().selectedIssueDetailNumber
});
},
loadData: async () => {
+73
View File
@@ -533,6 +533,79 @@ input[readonly] {
margin-top: 2px;
}
.issue-detail-card {
gap: 8px;
margin-top: 2px;
}
.inline-heading {
padding: 0;
}
.issue-body-text {
max-height: 180px;
margin: 0;
overflow: auto;
border: 1px solid #edf2f7;
border-radius: 6px;
padding: 6px;
background: #fbfdff;
color: #243b53;
font-family: inherit;
font-size: 11px;
line-height: 1.4;
white-space: pre-wrap;
}
.issue-attachment-list {
display: grid;
gap: 3px;
margin: 0;
padding-left: 16px;
color: #52616f;
font-size: 11px;
}
.issue-attachment-list li {
min-width: 0;
}
.issue-attachment-list a {
color: #0b6bcb;
font-weight: 750;
text-decoration: none;
}
.issue-attachment-list span {
margin-left: 4px;
color: #829ab1;
font-size: 10px;
}
.issue-comment-chain {
display: grid;
gap: 6px;
}
.issue-comment {
display: grid;
gap: 4px;
}
.issue-comment-meta {
display: flex;
justify-content: space-between;
gap: 8px;
color: #52616f;
font-size: 10px;
font-weight: 800;
}
.issue-comment-meta a {
color: #0b6bcb;
text-decoration: none;
}
.target-summary {
display: grid;
gap: 2px;