2327 lines
74 KiB
TypeScript
2327 lines
74 KiB
TypeScript
import { createStore } from "zustand/vanilla";
|
|
import {
|
|
createGiteaIssueComment,
|
|
createGiteaIssue,
|
|
deleteGiteaIssue,
|
|
deleteGiteaIssueAttachment,
|
|
ensureGiteaLabel,
|
|
getCurrentGiteaUser,
|
|
getGiteaIssueDetail,
|
|
getGiteaIssues,
|
|
getReposOrderedByLastCommit,
|
|
giteaConfig,
|
|
setGiteaIssueLabels,
|
|
type GiteaIssueAttachment,
|
|
type GiteaIssueDetail,
|
|
type GiteaIssueStateFilter,
|
|
type GiteaIssueSummary,
|
|
type GiteaRepoSummary,
|
|
type GiteaUser,
|
|
updateGiteaIssue,
|
|
uploadGiteaIssueAttachment
|
|
} from "./api";
|
|
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" | "prompts" | "agents" | "open-issues" | "add-url" | "add-issue";
|
|
export type AddUrlMode = "workspace" | "repo";
|
|
export type ScreenshotTarget = "issue" | "comment" | "issue-attachment";
|
|
export type OpenIssuesStatusFilter = "open" | "closed";
|
|
export type AppDialog = "add-url" | "add-workspace";
|
|
|
|
export const CHROME_PROFILE_NAME = "Silma";
|
|
export const ISSUE_LABEL_OPTIONS = [
|
|
"Change Request",
|
|
"Idea",
|
|
"Tabitha",
|
|
"Orson",
|
|
"Belisarius",
|
|
"Ozymandias",
|
|
"Ready"
|
|
] as const;
|
|
|
|
export type IssueLabelOption = (typeof ISSUE_LABEL_OPTIONS)[number];
|
|
|
|
export interface AgentProfile {
|
|
id: string;
|
|
name: string;
|
|
file: string;
|
|
description: string;
|
|
content: string;
|
|
}
|
|
|
|
export interface WorkspacePrompt {
|
|
id: string;
|
|
name: string;
|
|
workspacePath: string;
|
|
content: string;
|
|
}
|
|
|
|
const DEFAULT_GLOBAL_AGENT_CONTENT = `# GLOBAL AGENT
|
|
|
|
## Mission
|
|
|
|
Use this file as the governing handoff contract for the issue it is attached to. It is intentionally project-agnostic and should be interpreted in the context supplied by the issue, linked workspace, linked Git repository, and source URL.
|
|
|
|
## Context Variables
|
|
|
|
- Git target: {{git}}
|
|
- Workspace: {{workspace}}
|
|
- Source URL: {{url}}
|
|
- Base URL: {{baseUrl}}
|
|
- Chrome profile: {{profile}}
|
|
- Issue: {{issue}}
|
|
|
|
## Operating Rules
|
|
|
|
- Treat the issue subject, issue body, screenshots, and attachments as the source of truth for the requested change.
|
|
- Inspect {{workspace}} before proposing or applying code changes.
|
|
- Use {{git}} as the repository identity for branch, commit, issue, and review references.
|
|
- Preserve user intent. If evidence conflicts, state the conflict clearly and ask only when the next action would otherwise be risky.
|
|
- Keep changes scoped to the issue. Avoid unrelated refactors, formatting churn, and generated output unless the issue requires them.
|
|
- Run the narrowest meaningful verification for the changed surface, then report exactly what passed or could not be run.
|
|
- Do not include credentials, local-only secrets, or private tokens in comments, commits, logs, or attachments.
|
|
- After the implementation has been pushed to {{git}}, close {{issue}} in Gitea.
|
|
- If delivery is blocked, partial, or risky, leave a Gitea comment on {{issue}} with the blocker, current status, evidence, and recommended next step.
|
|
- When handing off to another agent, include current status, touched files, verification results, and any unresolved decisions.`;
|
|
|
|
const GLOBAL_AGENT_COMPLETION_RULES = [
|
|
"- After the implementation has been pushed to {{git}}, close {{issue}} in Gitea.",
|
|
"- If delivery is blocked, partial, or risky, leave a Gitea comment on {{issue}} with the blocker, current status, evidence, and recommended next step."
|
|
];
|
|
|
|
export const AVAILABLE_AGENTS: AgentProfile[] = [
|
|
{
|
|
id: "global-agent",
|
|
name: "GLOBAL AGENT",
|
|
file: "AGENTS.md",
|
|
description: "Default project-wide agent rules for issue handoff.",
|
|
content: DEFAULT_GLOBAL_AGENT_CONTENT
|
|
}
|
|
];
|
|
|
|
const EMPTY_AGENT_DRAFT: AgentProfile = {
|
|
id: "",
|
|
name: "",
|
|
file: "AGENTS.md",
|
|
description: "",
|
|
content: ""
|
|
};
|
|
|
|
const EMPTY_PROMPT_DRAFT: WorkspacePrompt = {
|
|
id: "",
|
|
name: "",
|
|
workspacePath: "",
|
|
content: ""
|
|
};
|
|
|
|
interface AddUrlDraft {
|
|
mode: AddUrlMode | null;
|
|
selectedValue: string;
|
|
}
|
|
|
|
interface WorkspaceDraft {
|
|
name: string;
|
|
path: string;
|
|
search: string;
|
|
}
|
|
|
|
export interface ScreenshotAttachment {
|
|
id: string;
|
|
name: string;
|
|
dataUrl: string;
|
|
blob: Blob;
|
|
}
|
|
|
|
export interface OpenIssue extends GiteaIssueSummary {
|
|
repoFullName: string;
|
|
repoWebUrl: string;
|
|
owner: string;
|
|
repo: string;
|
|
knownUrl: KnownUrl | null;
|
|
}
|
|
|
|
interface IssueDraft {
|
|
subject: string;
|
|
content: string;
|
|
promptId: string;
|
|
agentId: string;
|
|
labels: IssueLabelOption[];
|
|
screenshots: ScreenshotAttachment[];
|
|
files: File[];
|
|
}
|
|
|
|
interface IssueCommentDraft {
|
|
content: string;
|
|
screenshots: ScreenshotAttachment[];
|
|
files: File[];
|
|
}
|
|
|
|
interface IssueAttachmentDraft {
|
|
screenshots: ScreenshotAttachment[];
|
|
files: File[];
|
|
}
|
|
|
|
interface IssueEditDraft {
|
|
subject: string;
|
|
content: string;
|
|
labels: IssueLabelOption[];
|
|
}
|
|
|
|
interface CropArea {
|
|
left: number;
|
|
top: number;
|
|
width: number;
|
|
height: number;
|
|
}
|
|
|
|
interface ScreenshotPreview {
|
|
target: ScreenshotTarget;
|
|
id: string;
|
|
crop: CropArea;
|
|
}
|
|
|
|
interface AppState {
|
|
activeView: AppView;
|
|
activeDialog: AppDialog | null;
|
|
config: typeof giteaConfig;
|
|
connectedUser: GiteaUser | null;
|
|
repos: GiteaRepoSummary[];
|
|
localContext: LocalContext | null;
|
|
userWorkspaces: LocalWorkspace[];
|
|
userUrls: KnownUrl[];
|
|
deletedUrlBases: string[];
|
|
activeTabUrl: string;
|
|
activeBaseUrl: string;
|
|
addUrlDraft: AddUrlDraft;
|
|
workspaceDraft: WorkspaceDraft;
|
|
issueDraft: IssueDraft;
|
|
issueCommentDraft: IssueCommentDraft;
|
|
issueAttachmentDraft: IssueAttachmentDraft;
|
|
agents: AgentProfile[];
|
|
agentDraft: AgentProfile;
|
|
editingAgentId: string | null;
|
|
prompts: WorkspacePrompt[];
|
|
promptDraft: WorkspacePrompt;
|
|
editingPromptId: string | null;
|
|
screenshotPreview: ScreenshotPreview | null;
|
|
targetIssues: GiteaIssueSummary[];
|
|
openIssues: OpenIssue[];
|
|
openIssuesLimitToCurrentRepo: boolean;
|
|
openIssuesStatusFilters: OpenIssuesStatusFilter[];
|
|
openIssuesLabelFilter: IssueLabelOption | "";
|
|
openIssuesScopeKey: string;
|
|
issueListRepoFullName: string;
|
|
selectedIssueNumber: number | null;
|
|
selectedIssueRepoFullName: string;
|
|
selectedIssueDetail: GiteaIssueDetail | null;
|
|
selectedIssueDetailNumber: number | null;
|
|
selectedIssueEditDraft: IssueEditDraft;
|
|
isLoadingData: boolean;
|
|
isLoadingTargetIssues: boolean;
|
|
isLoadingIssueDetail: boolean;
|
|
isTestingConnection: boolean;
|
|
isCapturingScreenshot: boolean;
|
|
isCapturingIssueCommentScreenshot: boolean;
|
|
isCapturingIssueAttachmentScreenshot: boolean;
|
|
isCreatingIssue: boolean;
|
|
isCreatingIssueComment: boolean;
|
|
isUploadingIssueAttachments: boolean;
|
|
isUpdatingIssue: boolean;
|
|
statusMessage: string;
|
|
statusTone: StatusTone;
|
|
clearStatus: () => void;
|
|
setActiveView: (view: AppView) => void;
|
|
openDialog: (dialog: AppDialog) => void;
|
|
closeDialog: () => void;
|
|
setAddUrlDraft: (draft: Partial<AddUrlDraft>) => void;
|
|
setWorkspaceDraft: (draft: Partial<WorkspaceDraft>) => void;
|
|
saveWorkspace: () => Promise<void>;
|
|
deleteUserWorkspace: (path: string) => Promise<void>;
|
|
setIssueDraft: (draft: Partial<Omit<IssueDraft, "screenshots" | "files">>) => void;
|
|
setIssueFiles: (files: File[]) => void;
|
|
deleteIssueFile: (index: number) => void;
|
|
deleteIssueScreenshot: (id: string) => void;
|
|
setIssueCommentDraft: (draft: Partial<Omit<IssueCommentDraft, "screenshots" | "files">>) => void;
|
|
setIssueCommentFiles: (files: File[]) => void;
|
|
deleteIssueCommentFile: (index: number) => void;
|
|
deleteIssueCommentScreenshot: (id: string) => void;
|
|
setIssueAttachmentFiles: (files: File[]) => void;
|
|
deleteIssueAttachmentFile: (index: number) => void;
|
|
deleteIssueAttachmentScreenshot: (id: string) => void;
|
|
setAgentDraft: (draft: Partial<AgentProfile>) => void;
|
|
startNewAgent: () => void;
|
|
editAgent: (id: string) => void;
|
|
saveAgent: () => Promise<void>;
|
|
deleteAgent: (id: string) => Promise<void>;
|
|
setPromptDraft: (draft: Partial<WorkspacePrompt>) => void;
|
|
startNewPrompt: () => void;
|
|
editPrompt: (id: string) => void;
|
|
savePrompt: () => Promise<void>;
|
|
deletePrompt: (id: string) => Promise<void>;
|
|
setIssuePrompt: (promptId: string) => void;
|
|
openScreenshotPreview: (target: ScreenshotTarget, id: string) => void;
|
|
closeScreenshotPreview: () => void;
|
|
setScreenshotPreviewCrop: (crop: Partial<CropArea>) => void;
|
|
applyScreenshotCrop: () => Promise<void>;
|
|
removePreviewScreenshot: () => void;
|
|
setOpenIssuesLimit: (limitToCurrentRepo: boolean) => void;
|
|
setOpenIssuesStatusFilters: (statusFilters: OpenIssuesStatusFilter[]) => void;
|
|
setOpenIssuesLabelFilter: (labelFilter: IssueLabelOption | "") => void;
|
|
setSelectedIssueEditDraft: (draft: Partial<IssueEditDraft>) => void;
|
|
saveSelectedIssueEdits: () => Promise<void>;
|
|
clearSelectedIssue: () => void;
|
|
selectIssue: (issueNumber: number, repoFullName?: string) => void;
|
|
loadOpenIssues: (force?: boolean) => Promise<void>;
|
|
loadTargetIssues: (force?: boolean) => Promise<void>;
|
|
loadSelectedIssueDetail: (force?: boolean) => Promise<void>;
|
|
deleteIssue: (issueNumber: number, repoFullName?: string) => Promise<void>;
|
|
deleteSelectedIssueAttachment: (attachmentId: number) => Promise<void>;
|
|
captureIssueCommentScreenshot: () => Promise<void>;
|
|
captureIssueAttachmentScreenshot: () => Promise<void>;
|
|
uploadSelectedIssueAttachments: () => Promise<void>;
|
|
createIssueComment: () => Promise<void>;
|
|
captureScreenshot: () => Promise<void>;
|
|
createIssue: () => Promise<void>;
|
|
refreshActiveTab: () => Promise<void>;
|
|
loadData: () => Promise<void>;
|
|
saveCurrentUrlLink: () => Promise<void>;
|
|
deleteUrlLink: (url: string) => Promise<void>;
|
|
testConnection: () => Promise<void>;
|
|
}
|
|
|
|
const USER_URLS_STORAGE_KEY = "silmaAide.userUrls";
|
|
const USER_WORKSPACES_STORAGE_KEY = "silmaAide.userWorkspaces";
|
|
const DELETED_URL_BASES_STORAGE_KEY = "silmaAide.deletedUrlBases";
|
|
const AGENTS_STORAGE_KEY = "silmaAide.agents";
|
|
const PROMPTS_STORAGE_KEY = "silmaAide.prompts";
|
|
let statusTimer: number | undefined;
|
|
|
|
function getBaseUrl(value: string): string {
|
|
try {
|
|
const url = new URL(value);
|
|
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
return "";
|
|
}
|
|
return `${url.origin}/`;
|
|
} catch {
|
|
return "";
|
|
}
|
|
}
|
|
|
|
function storageGet<T>(key: string, fallback: T): Promise<T> {
|
|
return new Promise((resolve) => {
|
|
if (!globalThis.chrome?.storage?.local) {
|
|
resolve(fallback);
|
|
return;
|
|
}
|
|
|
|
chrome.storage.local.get(key, (result) => {
|
|
resolve((result[key] as T | undefined) ?? fallback);
|
|
});
|
|
});
|
|
}
|
|
|
|
function storageSet(key: string, value: unknown): Promise<void> {
|
|
return new Promise((resolve) => {
|
|
if (!globalThis.chrome?.storage?.local) {
|
|
resolve();
|
|
return;
|
|
}
|
|
|
|
chrome.storage.local.set({ [key]: value }, () => resolve());
|
|
});
|
|
}
|
|
|
|
function getActiveTabUrl(): Promise<string> {
|
|
return new Promise((resolve) => {
|
|
if (!globalThis.chrome?.tabs?.query) {
|
|
resolve(globalThis.location.href);
|
|
return;
|
|
}
|
|
|
|
getActiveBrowserTab()
|
|
.then((tab) => resolve(tab.url ?? ""))
|
|
.catch(() => resolve(""));
|
|
});
|
|
}
|
|
|
|
function getActiveBrowserTab(): Promise<chrome.tabs.Tab> {
|
|
return new Promise((resolve, reject) => {
|
|
if (!globalThis.chrome?.tabs?.query) {
|
|
reject(new Error("Chrome tab lookup is only available in the extension side panel."));
|
|
return;
|
|
}
|
|
|
|
chrome.tabs.query({ active: true, currentWindow: true }, (currentWindowTabs) => {
|
|
const currentWindowTab = currentWindowTabs[0];
|
|
if (currentWindowTab?.id !== undefined) {
|
|
resolve(currentWindowTab);
|
|
return;
|
|
}
|
|
|
|
chrome.tabs.query({ active: true, lastFocusedWindow: true }, (lastFocusedTabs) => {
|
|
const lastFocusedTab = lastFocusedTabs[0];
|
|
if (lastFocusedTab?.id !== undefined) {
|
|
resolve(lastFocusedTab);
|
|
return;
|
|
}
|
|
|
|
reject(new Error("Could not find the active browser tab."));
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
export function getAllWorkspaces(state: Pick<AppState, "localContext" | "userWorkspaces">): LocalWorkspace[] {
|
|
const workspaces = [...(state.localContext?.workspaces ?? []), ...state.userWorkspaces];
|
|
return workspaces.filter(
|
|
(workspace, index) => workspaces.findIndex((item) => item.path === workspace.path) === index
|
|
);
|
|
}
|
|
|
|
function findWorkspaceByName(state: AppState, name: string): LocalWorkspace | undefined {
|
|
return getAllWorkspaces(state).find((workspace) => workspace.name === name);
|
|
}
|
|
|
|
function findWorkspaceByPath(state: AppState, path: string): LocalWorkspace | undefined {
|
|
return getAllWorkspaces(state).find((workspace) => workspace.path === path);
|
|
}
|
|
|
|
function findRepoByFullName(context: LocalContext, fullName: string): LocalGiteaRepoLink | null {
|
|
return context.gitWorktrees.find((worktree) => worktree.giteaRepo?.fullName === fullName)?.giteaRepo ?? null;
|
|
}
|
|
|
|
function allKnownUrls(state: AppState): KnownUrl[] {
|
|
const deletedBases = new Set(state.deletedUrlBases);
|
|
const urls = [...(state.localContext?.knownUrls ?? []), ...state.userUrls].filter(
|
|
(knownUrl) => !deletedBases.has(getBaseUrl(knownUrl.url))
|
|
);
|
|
|
|
return urls.filter(
|
|
(knownUrl, index) => urls.findIndex((item) => getBaseUrl(item.url) === getBaseUrl(knownUrl.url)) === index
|
|
);
|
|
}
|
|
|
|
function findKnownUrlByBase(state: AppState, baseUrl: string): KnownUrl | null {
|
|
if (!baseUrl) {
|
|
return null;
|
|
}
|
|
|
|
return allKnownUrls(state).find((knownUrl) => getBaseUrl(knownUrl.url) === baseUrl) ?? null;
|
|
}
|
|
|
|
export function findKnownUrlForBase(state: AppState): KnownUrl | null {
|
|
return findKnownUrlByBase(state, state.activeBaseUrl);
|
|
}
|
|
|
|
function parseRepoFullName(fullName: string): { owner: string; repo: string } | null {
|
|
const [owner, ...repoParts] = fullName.split("/");
|
|
const repo = repoParts.join("/");
|
|
|
|
if (!owner || !repo) {
|
|
return null;
|
|
}
|
|
|
|
return { owner, repo };
|
|
}
|
|
|
|
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;
|
|
|
|
if (!knownUrl || !knownUrl.giteaRepo || !parsed) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
knownUrl,
|
|
owner: parsed.owner,
|
|
repo: parsed.repo,
|
|
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 {
|
|
const repoScope = state.openIssuesLimitToCurrentRepo ? `current:${getTargetRepo(state)?.fullName ?? "none"}` : "all-repos";
|
|
return `${repoScope}:${state.openIssuesStatusFilters.join(",")}:${state.openIssuesLabelFilter || "all-labels"}`;
|
|
}
|
|
|
|
function getOpenIssuesGiteaState(statusFilters: OpenIssuesStatusFilter[]): GiteaIssueStateFilter | null {
|
|
if (!statusFilters.length) {
|
|
return null;
|
|
}
|
|
|
|
return statusFilters.length === 1 ? statusFilters[0] : "all";
|
|
}
|
|
|
|
function issueHasLabel(issue: GiteaIssueSummary, labelFilter: IssueLabelOption | ""): boolean {
|
|
if (!labelFilter) {
|
|
return true;
|
|
}
|
|
|
|
return issue.labels.some((label) => label.toLowerCase() === labelFilter.toLowerCase());
|
|
}
|
|
|
|
function emptyIssueDrafts() {
|
|
return {
|
|
selectedIssueNumber: null,
|
|
selectedIssueRepoFullName: "",
|
|
selectedIssueDetail: null,
|
|
selectedIssueDetailNumber: null,
|
|
selectedIssueEditDraft: {
|
|
subject: "",
|
|
content: "",
|
|
labels: []
|
|
},
|
|
issueCommentDraft: {
|
|
content: "",
|
|
screenshots: [],
|
|
files: []
|
|
},
|
|
issueAttachmentDraft: {
|
|
screenshots: [],
|
|
files: []
|
|
}
|
|
};
|
|
}
|
|
|
|
function toOpenIssue(issue: GiteaIssueSummary, target: RepoTarget): OpenIssue {
|
|
return {
|
|
...issue,
|
|
repoFullName: target.fullName,
|
|
repoWebUrl: target.webUrl,
|
|
owner: target.owner,
|
|
repo: target.repo,
|
|
knownUrl: target.knownUrl
|
|
};
|
|
}
|
|
|
|
function inferUrlLink(context: LocalContext, baseUrl: string, draft: AddUrlDraft): KnownUrl | null {
|
|
if (!draft.mode || !draft.selectedValue) {
|
|
return null;
|
|
}
|
|
|
|
if (draft.mode === "workspace") {
|
|
const state = appStore.getState();
|
|
const workspace = findWorkspaceByPath(state, draft.selectedValue) ?? findWorkspaceByName(state, draft.selectedValue);
|
|
if (!workspace) {
|
|
return null;
|
|
}
|
|
|
|
const repo = workspace.linkedRepos[0] ?? null;
|
|
return {
|
|
label: new URL(baseUrl).host,
|
|
url: baseUrl,
|
|
workspaceName: workspace.name,
|
|
workspacePath: workspace.path,
|
|
giteaRepo: repo ? { fullName: repo.fullName, webUrl: repo.webUrl } : null,
|
|
source: "user"
|
|
};
|
|
}
|
|
|
|
const repo = findRepoByFullName(context, draft.selectedValue);
|
|
const worktree = context.gitWorktrees.find((item) => item.giteaRepo?.fullName === draft.selectedValue);
|
|
const workspace = worktree ? findWorkspaceByName(appStore.getState(), worktree.topWorkspaceName) : undefined;
|
|
|
|
if (!repo || !workspace) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
label: new URL(baseUrl).host,
|
|
url: baseUrl,
|
|
workspaceName: workspace.name,
|
|
workspacePath: workspace.path,
|
|
giteaRepo: repo,
|
|
source: "user"
|
|
};
|
|
}
|
|
|
|
function scheduleStatusClear(set: (partial: Partial<AppState>) => void, delayMs = 5000): void {
|
|
if (statusTimer !== undefined) {
|
|
window.clearTimeout(statusTimer);
|
|
}
|
|
|
|
statusTimer = window.setTimeout(() => {
|
|
statusTimer = undefined;
|
|
set({
|
|
statusMessage: "",
|
|
statusTone: "neutral"
|
|
});
|
|
}, delayMs);
|
|
}
|
|
|
|
function setStatus(
|
|
set: (partial: Partial<AppState>) => void,
|
|
statusMessage: string,
|
|
statusTone: StatusTone,
|
|
options: { autoClear?: boolean } = {}
|
|
): void {
|
|
set({ statusMessage, statusTone });
|
|
|
|
if (options.autoClear ?? statusTone !== "neutral") {
|
|
scheduleStatusClear(set);
|
|
} else if (statusTimer !== undefined) {
|
|
window.clearTimeout(statusTimer);
|
|
statusTimer = undefined;
|
|
}
|
|
}
|
|
|
|
function captureVisibleTab(): Promise<{ dataUrl: string; blob: Blob }> {
|
|
return new Promise((resolve, reject) => {
|
|
if (!globalThis.chrome?.tabs?.captureVisibleTab) {
|
|
reject(new Error("Chrome screenshot capture is only available in the extension side panel."));
|
|
return;
|
|
}
|
|
|
|
getActiveBrowserTab().then((tab) => {
|
|
if (!tab.url || !getBaseUrl(tab.url)) {
|
|
reject(new Error("Open an http or https tab before taking a snapshot."));
|
|
return;
|
|
}
|
|
|
|
const windowId = tab.windowId;
|
|
chrome.tabs.captureVisibleTab(windowId, { format: "png" }, async (dataUrl) => {
|
|
const lastError = chrome.runtime?.lastError;
|
|
if (lastError) {
|
|
reject(new Error(lastError.message));
|
|
return;
|
|
}
|
|
|
|
if (!dataUrl) {
|
|
reject(new Error("Chrome did not return a screenshot."));
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(dataUrl);
|
|
resolve({
|
|
dataUrl,
|
|
blob: await response.blob()
|
|
});
|
|
} catch {
|
|
reject(new Error("Could not prepare the screenshot for upload."));
|
|
}
|
|
});
|
|
}).catch((error: unknown) => {
|
|
reject(error instanceof Error ? error : new Error("Could not find the active browser tab."));
|
|
});
|
|
});
|
|
}
|
|
|
|
function createScreenshotAttachment(dataUrl: string, blob: Blob): ScreenshotAttachment {
|
|
return {
|
|
id: crypto.randomUUID(),
|
|
name: `silma-screenshot-${new Date().toISOString().replace(/[:.]/g, "-")}.png`,
|
|
dataUrl,
|
|
blob
|
|
};
|
|
}
|
|
|
|
function normalizeAgent(agent: Partial<AgentProfile>, index: number): AgentProfile {
|
|
const normalized = {
|
|
id: agent.id || crypto.randomUUID(),
|
|
name: agent.name?.trim() || (index === 0 ? "GLOBAL AGENT" : "Unnamed agent"),
|
|
file: agent.file?.trim() || "AGENTS.md",
|
|
description: agent.description?.trim() || "",
|
|
content: agent.content?.trim() || DEFAULT_GLOBAL_AGENT_CONTENT
|
|
};
|
|
|
|
if (normalized.id !== AVAILABLE_AGENTS[0].id) {
|
|
return normalized;
|
|
}
|
|
|
|
const missingRules = GLOBAL_AGENT_COMPLETION_RULES.filter((rule) => !normalized.content.includes(rule));
|
|
|
|
return missingRules.length
|
|
? {
|
|
...normalized,
|
|
content: `${normalized.content.trim()}\n${missingRules.join("\n")}`
|
|
}
|
|
: normalized;
|
|
}
|
|
|
|
function normalizeAgents(agents: AgentProfile[]): AgentProfile[] {
|
|
const normalized = agents.map(normalizeAgent);
|
|
return normalized.length ? normalized : AVAILABLE_AGENTS;
|
|
}
|
|
|
|
function getFallbackWorkspacePath(state: AppState): string {
|
|
return findKnownUrlForBase(state)?.workspacePath ?? getAllWorkspaces(state)[0]?.path ?? "";
|
|
}
|
|
|
|
function normalizePrompt(prompt: Partial<WorkspacePrompt>, fallbackWorkspacePath: string): WorkspacePrompt {
|
|
return {
|
|
id: prompt.id || crypto.randomUUID(),
|
|
name: prompt.name?.trim() || "Untitled prompt",
|
|
workspacePath: prompt.workspacePath?.trim() || fallbackWorkspacePath,
|
|
content: prompt.content?.trim() || ""
|
|
};
|
|
}
|
|
|
|
function normalizePrompts(prompts: WorkspacePrompt[], fallbackWorkspacePath: string): WorkspacePrompt[] {
|
|
return prompts
|
|
.map((prompt) => normalizePrompt(prompt, fallbackWorkspacePath))
|
|
.filter((prompt) => prompt.name && prompt.workspacePath);
|
|
}
|
|
|
|
function getPromptById(id: string, prompts: WorkspacePrompt[]): WorkspacePrompt | null {
|
|
return prompts.find((prompt) => prompt.id === id) ?? null;
|
|
}
|
|
|
|
function normalizeIssueLabels(labels: string[]): IssueLabelOption[] {
|
|
return ISSUE_LABEL_OPTIONS.filter((option) => labels.some((label) => label.toLowerCase() === option.toLowerCase()));
|
|
}
|
|
|
|
async function ensureIssueLabels(
|
|
owner: string,
|
|
repo: string,
|
|
labels: IssueLabelOption[]
|
|
): Promise<{ ids: number[]; names: string[] }> {
|
|
const ensuredLabels = await Promise.all(labels.map((label) => ensureGiteaLabel(owner, repo, label)));
|
|
return {
|
|
ids: ensuredLabels.map((label) => label.id),
|
|
names: ensuredLabels.map((label) => label.name)
|
|
};
|
|
}
|
|
|
|
function getAgentById(id: string, agents: AgentProfile[]): AgentProfile {
|
|
return agents.find((agent) => agent.id === id) ?? agents[0] ?? AVAILABLE_AGENTS[0];
|
|
}
|
|
|
|
function appendFiles(existingFiles: File[], incomingFiles: File[]): File[] {
|
|
const fileKeys = new Set(existingFiles.map((file) => `${file.name}:${file.size}:${file.lastModified}`));
|
|
const nextFiles = [...existingFiles];
|
|
|
|
for (const file of incomingFiles) {
|
|
const key = `${file.name}:${file.size}:${file.lastModified}`;
|
|
if (!fileKeys.has(key)) {
|
|
fileKeys.add(key);
|
|
nextFiles.push(file);
|
|
}
|
|
}
|
|
|
|
return nextFiles;
|
|
}
|
|
|
|
function clampCrop(crop: CropArea): CropArea {
|
|
const left = Math.min(Math.max(crop.left, 0), 99);
|
|
const top = Math.min(Math.max(crop.top, 0), 99);
|
|
const width = Math.min(Math.max(crop.width, 1), 100 - left);
|
|
const height = Math.min(Math.max(crop.height, 1), 100 - top);
|
|
|
|
return { left, top, width, height };
|
|
}
|
|
|
|
function loadImage(dataUrl: string): Promise<HTMLImageElement> {
|
|
return new Promise((resolve, reject) => {
|
|
const image = new Image();
|
|
image.onload = () => resolve(image);
|
|
image.onerror = () => reject(new Error("Could not load screenshot for cropping."));
|
|
image.src = dataUrl;
|
|
});
|
|
}
|
|
|
|
function canvasToBlob(canvas: HTMLCanvasElement): Promise<Blob> {
|
|
return new Promise((resolve, reject) => {
|
|
canvas.toBlob((blob) => {
|
|
if (blob) {
|
|
resolve(blob);
|
|
return;
|
|
}
|
|
reject(new Error("Could not crop the screenshot."));
|
|
}, "image/png");
|
|
});
|
|
}
|
|
|
|
async function cropScreenshot(screenshot: ScreenshotAttachment, crop: CropArea): Promise<ScreenshotAttachment> {
|
|
const image = await loadImage(screenshot.dataUrl);
|
|
const safeCrop = clampCrop(crop);
|
|
const sourceX = Math.floor((safeCrop.left / 100) * image.naturalWidth);
|
|
const sourceY = Math.floor((safeCrop.top / 100) * image.naturalHeight);
|
|
const sourceWidth = Math.max(1, Math.floor((safeCrop.width / 100) * image.naturalWidth));
|
|
const sourceHeight = Math.max(1, Math.floor((safeCrop.height / 100) * image.naturalHeight));
|
|
const canvas = document.createElement("canvas");
|
|
|
|
canvas.width = sourceWidth;
|
|
canvas.height = sourceHeight;
|
|
|
|
const context = canvas.getContext("2d");
|
|
if (!context) {
|
|
throw new Error("Could not prepare the screenshot cropper.");
|
|
}
|
|
|
|
context.drawImage(image, sourceX, sourceY, sourceWidth, sourceHeight, 0, 0, sourceWidth, sourceHeight);
|
|
const blob = await canvasToBlob(canvas);
|
|
|
|
return {
|
|
...screenshot,
|
|
dataUrl: canvas.toDataURL("image/png"),
|
|
blob
|
|
};
|
|
}
|
|
|
|
function buildIssueBody(state: AppState, knownUrl: KnownUrl): string {
|
|
const agent = getAgentById(state.issueDraft.agentId, state.agents);
|
|
const requirements = state.issueDraft.content.trim() || "No requirements provided.";
|
|
const references = [
|
|
`- Source URL: ${state.activeTabUrl || knownUrl.url}`,
|
|
`- Base URL: ${state.activeBaseUrl || getBaseUrl(knownUrl.url)}`,
|
|
`- Workspace: ${knownUrl.workspaceName} (${knownUrl.workspacePath})`,
|
|
`- Gitea repo: ${knownUrl.giteaRepo?.fullName ?? "Not linked"}`,
|
|
`- Assigned agent: ${agent.name} (${agent.file})`,
|
|
`- Chrome profile: ${CHROME_PROFILE_NAME}`
|
|
];
|
|
|
|
return [`# Requirements`, requirements, `# References`, references.join("\n")].join("\n\n");
|
|
}
|
|
|
|
function renderAgentContent(agent: AgentProfile, state: AppState, knownUrl: KnownUrl, issueNumber: number): string {
|
|
const replacements: Record<string, string> = {
|
|
git: knownUrl.giteaRepo?.fullName ?? "",
|
|
repo: knownUrl.giteaRepo?.fullName ?? "",
|
|
workspace: knownUrl.workspacePath,
|
|
url: state.activeTabUrl || knownUrl.url,
|
|
baseUrl: state.activeBaseUrl || getBaseUrl(knownUrl.url),
|
|
profile: CHROME_PROFILE_NAME,
|
|
issue: `#${issueNumber}`
|
|
};
|
|
|
|
return agent.content.replace(/\{\{(git|repo|workspace|url|baseUrl|profile|issue)\}\}/g, (_match, key: string) => replacements[key] ?? "");
|
|
}
|
|
|
|
function agentContentBlob(content: string): Blob {
|
|
return new Blob([content], { type: "text/markdown;charset=utf-8" });
|
|
}
|
|
|
|
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 | null, attachments: GiteaIssueAttachment[]): string {
|
|
const parts = [
|
|
state.issueCommentDraft.content.trim(),
|
|
buildAttachmentMarkdown(attachments),
|
|
"---",
|
|
`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}`
|
|
];
|
|
|
|
return parts.filter(Boolean).join("\n\n");
|
|
}
|
|
|
|
export const appStore = createStore<AppState>()((set, get) => ({
|
|
activeView: "repos",
|
|
activeDialog: null,
|
|
config: giteaConfig,
|
|
connectedUser: null,
|
|
repos: [],
|
|
localContext: null,
|
|
userWorkspaces: [],
|
|
userUrls: [],
|
|
deletedUrlBases: [],
|
|
activeTabUrl: "",
|
|
activeBaseUrl: "",
|
|
addUrlDraft: {
|
|
mode: null,
|
|
selectedValue: ""
|
|
},
|
|
workspaceDraft: {
|
|
name: "",
|
|
path: "",
|
|
search: ""
|
|
},
|
|
issueDraft: {
|
|
subject: "",
|
|
content: "",
|
|
promptId: "",
|
|
agentId: AVAILABLE_AGENTS[0].id,
|
|
labels: ["Change Request"],
|
|
screenshots: [],
|
|
files: []
|
|
},
|
|
issueCommentDraft: {
|
|
content: "",
|
|
screenshots: [],
|
|
files: []
|
|
},
|
|
issueAttachmentDraft: {
|
|
screenshots: [],
|
|
files: []
|
|
},
|
|
agents: AVAILABLE_AGENTS,
|
|
agentDraft: AVAILABLE_AGENTS[0],
|
|
editingAgentId: AVAILABLE_AGENTS[0].id,
|
|
prompts: [],
|
|
promptDraft: EMPTY_PROMPT_DRAFT,
|
|
editingPromptId: null,
|
|
screenshotPreview: null,
|
|
targetIssues: [],
|
|
openIssues: [],
|
|
openIssuesLimitToCurrentRepo: true,
|
|
openIssuesStatusFilters: ["open"],
|
|
openIssuesLabelFilter: "",
|
|
openIssuesScopeKey: "",
|
|
issueListRepoFullName: "",
|
|
selectedIssueNumber: null,
|
|
selectedIssueRepoFullName: "",
|
|
selectedIssueDetail: null,
|
|
selectedIssueDetailNumber: null,
|
|
selectedIssueEditDraft: {
|
|
subject: "",
|
|
content: "",
|
|
labels: []
|
|
},
|
|
isLoadingData: false,
|
|
isLoadingTargetIssues: false,
|
|
isLoadingIssueDetail: false,
|
|
isTestingConnection: false,
|
|
isCapturingScreenshot: false,
|
|
isCapturingIssueCommentScreenshot: false,
|
|
isCapturingIssueAttachmentScreenshot: false,
|
|
isCreatingIssue: false,
|
|
isCreatingIssueComment: false,
|
|
isUploadingIssueAttachments: false,
|
|
isUpdatingIssue: false,
|
|
statusMessage: "",
|
|
statusTone: "neutral",
|
|
clearStatus: () => {
|
|
if (statusTimer !== undefined) {
|
|
window.clearTimeout(statusTimer);
|
|
statusTimer = undefined;
|
|
}
|
|
set({ statusMessage: "", statusTone: "neutral" });
|
|
},
|
|
setActiveView: (view) =>
|
|
set((state) => ({
|
|
activeView: view,
|
|
promptDraft:
|
|
view === "prompts" && !state.editingPromptId
|
|
? {
|
|
...state.promptDraft,
|
|
workspacePath: getFallbackWorkspacePath(state)
|
|
}
|
|
: state.promptDraft
|
|
})),
|
|
openDialog: (dialog) =>
|
|
set((state) => ({
|
|
activeDialog: dialog,
|
|
addUrlDraft:
|
|
dialog === "add-url"
|
|
? {
|
|
mode: null,
|
|
selectedValue: ""
|
|
}
|
|
: state.addUrlDraft,
|
|
workspaceDraft:
|
|
dialog === "add-workspace"
|
|
? {
|
|
name: "",
|
|
path: "",
|
|
search: findKnownUrlForBase(state)?.workspaceName ?? ""
|
|
}
|
|
: state.workspaceDraft
|
|
})),
|
|
closeDialog: () => set({ activeDialog: null }),
|
|
setAddUrlDraft: (draft) =>
|
|
set((state) => ({
|
|
addUrlDraft: {
|
|
...state.addUrlDraft,
|
|
...draft
|
|
}
|
|
})),
|
|
setWorkspaceDraft: (draft) =>
|
|
set((state) => ({
|
|
workspaceDraft: {
|
|
...state.workspaceDraft,
|
|
...draft
|
|
}
|
|
})),
|
|
saveWorkspace: async () => {
|
|
const state = appStore.getState();
|
|
const path = state.workspaceDraft.path.trim().replace(/\/+$/, "");
|
|
const pathParts = path.split("/").filter(Boolean);
|
|
const name = state.workspaceDraft.name.trim() || pathParts[pathParts.length - 1] || "workspace";
|
|
|
|
if (!path.startsWith("/")) {
|
|
setStatus(set, "Workspace path must be an absolute local path.", "error");
|
|
return;
|
|
}
|
|
|
|
const workspace: LocalWorkspace = {
|
|
name,
|
|
path,
|
|
linkedRepos: []
|
|
};
|
|
const userWorkspaces = [
|
|
...state.userWorkspaces.filter((item) => item.path !== path),
|
|
workspace
|
|
].sort((a, b) => a.name.localeCompare(b.name));
|
|
|
|
await storageSet(USER_WORKSPACES_STORAGE_KEY, userWorkspaces);
|
|
set({
|
|
userWorkspaces,
|
|
workspaceDraft: {
|
|
name: "",
|
|
path: "",
|
|
search: ""
|
|
},
|
|
activeDialog: null
|
|
});
|
|
setStatus(set, `Added workspace ${name}.`, "success");
|
|
},
|
|
deleteUserWorkspace: async (path) => {
|
|
const state = appStore.getState();
|
|
const workspace = state.userWorkspaces.find((item) => item.path === path);
|
|
|
|
if (!workspace) {
|
|
setStatus(set, "Could not find that user-added workspace.", "error");
|
|
return;
|
|
}
|
|
|
|
const userWorkspaces = state.userWorkspaces.filter((item) => item.path !== path);
|
|
await storageSet(USER_WORKSPACES_STORAGE_KEY, userWorkspaces);
|
|
set((current) => ({
|
|
userWorkspaces,
|
|
promptDraft:
|
|
current.promptDraft.workspacePath === path
|
|
? {
|
|
...current.promptDraft,
|
|
workspacePath: getFallbackWorkspacePath({
|
|
...current,
|
|
userWorkspaces
|
|
})
|
|
}
|
|
: current.promptDraft
|
|
}));
|
|
setStatus(set, `Deleted workspace ${workspace.name}.`, "success");
|
|
},
|
|
setIssueDraft: (draft) =>
|
|
set((state) => ({
|
|
issueDraft: {
|
|
...state.issueDraft,
|
|
...draft
|
|
}
|
|
})),
|
|
setIssueFiles: (files) =>
|
|
set((state) => ({
|
|
issueDraft: {
|
|
...state.issueDraft,
|
|
files: appendFiles(state.issueDraft.files, files)
|
|
}
|
|
})),
|
|
deleteIssueFile: (index) =>
|
|
set((state) => ({
|
|
issueDraft: {
|
|
...state.issueDraft,
|
|
files: state.issueDraft.files.filter((_file, fileIndex) => fileIndex !== index)
|
|
}
|
|
})),
|
|
deleteIssueScreenshot: (id) =>
|
|
set((state) => ({
|
|
issueDraft: {
|
|
...state.issueDraft,
|
|
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: appendFiles(state.issueCommentDraft.files, files)
|
|
}
|
|
})),
|
|
deleteIssueCommentFile: (index) =>
|
|
set((state) => ({
|
|
issueCommentDraft: {
|
|
...state.issueCommentDraft,
|
|
files: state.issueCommentDraft.files.filter((_file, fileIndex) => fileIndex !== index)
|
|
}
|
|
})),
|
|
deleteIssueCommentScreenshot: (id) =>
|
|
set((state) => ({
|
|
issueCommentDraft: {
|
|
...state.issueCommentDraft,
|
|
screenshots: state.issueCommentDraft.screenshots.filter((screenshot) => screenshot.id !== id)
|
|
}
|
|
})),
|
|
setIssueAttachmentFiles: (files) =>
|
|
set((state) => ({
|
|
issueAttachmentDraft: {
|
|
...state.issueAttachmentDraft,
|
|
files: appendFiles(state.issueAttachmentDraft.files, files)
|
|
}
|
|
})),
|
|
deleteIssueAttachmentFile: (index) =>
|
|
set((state) => ({
|
|
issueAttachmentDraft: {
|
|
...state.issueAttachmentDraft,
|
|
files: state.issueAttachmentDraft.files.filter((_file, fileIndex) => fileIndex !== index)
|
|
}
|
|
})),
|
|
deleteIssueAttachmentScreenshot: (id) =>
|
|
set((state) => ({
|
|
issueAttachmentDraft: {
|
|
...state.issueAttachmentDraft,
|
|
screenshots: state.issueAttachmentDraft.screenshots.filter((screenshot) => screenshot.id !== id)
|
|
}
|
|
})),
|
|
setAgentDraft: (draft) =>
|
|
set((state) => ({
|
|
agentDraft: {
|
|
...state.agentDraft,
|
|
...draft
|
|
}
|
|
})),
|
|
startNewAgent: () =>
|
|
set({
|
|
agentDraft: {
|
|
...EMPTY_AGENT_DRAFT,
|
|
id: crypto.randomUUID()
|
|
},
|
|
editingAgentId: null
|
|
}),
|
|
editAgent: (id) =>
|
|
set((state) => {
|
|
const agent = state.agents.find((item) => item.id === id);
|
|
return agent
|
|
? {
|
|
agentDraft: { ...agent },
|
|
editingAgentId: id
|
|
}
|
|
: {};
|
|
}),
|
|
saveAgent: async () => {
|
|
const state = appStore.getState();
|
|
const draft = normalizeAgent(
|
|
{
|
|
...state.agentDraft,
|
|
id: state.editingAgentId ?? state.agentDraft.id ?? crypto.randomUUID()
|
|
},
|
|
state.agents.length
|
|
);
|
|
|
|
if (!draft.name.trim() || !draft.file.trim() || !draft.content.trim()) {
|
|
setStatus(set, "Agent name, file, and content are required.", "error");
|
|
return;
|
|
}
|
|
|
|
const agents = state.editingAgentId
|
|
? state.agents.map((agent) => (agent.id === state.editingAgentId ? draft : agent))
|
|
: [...state.agents, draft];
|
|
const normalizedAgents = normalizeAgents(agents);
|
|
|
|
await storageSet(AGENTS_STORAGE_KEY, normalizedAgents);
|
|
set((current) => ({
|
|
agents: normalizedAgents,
|
|
issueDraft: {
|
|
...current.issueDraft,
|
|
agentId: normalizedAgents.some((agent) => agent.id === current.issueDraft.agentId)
|
|
? current.issueDraft.agentId
|
|
: normalizedAgents[0].id
|
|
},
|
|
agentDraft: {
|
|
...draft
|
|
},
|
|
editingAgentId: draft.id
|
|
}));
|
|
setStatus(set, `Saved ${draft.name}.`, "success");
|
|
},
|
|
deleteAgent: async (id) => {
|
|
const state = appStore.getState();
|
|
const agent = state.agents.find((item) => item.id === id);
|
|
|
|
if (!agent) {
|
|
setStatus(set, "Could not find that agent.", "error");
|
|
return;
|
|
}
|
|
|
|
if (state.agents.length <= 1) {
|
|
setStatus(set, "At least one agent must remain available.", "error");
|
|
return;
|
|
}
|
|
|
|
const agents = state.agents.filter((item) => item.id !== id);
|
|
await storageSet(AGENTS_STORAGE_KEY, agents);
|
|
set((current) => ({
|
|
agents,
|
|
issueDraft: {
|
|
...current.issueDraft,
|
|
agentId: current.issueDraft.agentId === id ? agents[0].id : current.issueDraft.agentId
|
|
},
|
|
agentDraft: current.editingAgentId === id ? EMPTY_AGENT_DRAFT : current.agentDraft,
|
|
editingAgentId: current.editingAgentId === id ? null : current.editingAgentId
|
|
}));
|
|
setStatus(set, `Deleted ${agent.name}.`, "success");
|
|
},
|
|
setPromptDraft: (draft) =>
|
|
set((state) => ({
|
|
promptDraft: {
|
|
...state.promptDraft,
|
|
...draft
|
|
}
|
|
})),
|
|
startNewPrompt: () =>
|
|
set((state) => ({
|
|
promptDraft: {
|
|
...EMPTY_PROMPT_DRAFT,
|
|
id: crypto.randomUUID(),
|
|
workspacePath: getFallbackWorkspacePath(state)
|
|
},
|
|
editingPromptId: null
|
|
})),
|
|
editPrompt: (id) =>
|
|
set((state) => {
|
|
const prompt = state.prompts.find((item) => item.id === id);
|
|
return prompt
|
|
? {
|
|
promptDraft: { ...prompt },
|
|
editingPromptId: id
|
|
}
|
|
: {};
|
|
}),
|
|
savePrompt: async () => {
|
|
const state = appStore.getState();
|
|
const draft = normalizePrompt(
|
|
{
|
|
...state.promptDraft,
|
|
id: state.editingPromptId ?? state.promptDraft.id ?? crypto.randomUUID()
|
|
},
|
|
getFallbackWorkspacePath(state)
|
|
);
|
|
|
|
if (!draft.name.trim() || !draft.workspacePath.trim() || !draft.content.trim()) {
|
|
setStatus(set, "Prompt name, workspace, and content are required.", "error");
|
|
return;
|
|
}
|
|
|
|
const prompts = state.editingPromptId
|
|
? state.prompts.map((prompt) => (prompt.id === state.editingPromptId ? draft : prompt))
|
|
: [...state.prompts, draft];
|
|
const normalizedPrompts = normalizePrompts(prompts, getFallbackWorkspacePath(state));
|
|
|
|
await storageSet(PROMPTS_STORAGE_KEY, normalizedPrompts);
|
|
set({
|
|
prompts: normalizedPrompts,
|
|
promptDraft: { ...draft },
|
|
editingPromptId: draft.id
|
|
});
|
|
setStatus(set, `Saved ${draft.name}.`, "success");
|
|
},
|
|
deletePrompt: async (id) => {
|
|
const state = appStore.getState();
|
|
const prompt = state.prompts.find((item) => item.id === id);
|
|
|
|
if (!prompt) {
|
|
setStatus(set, "Could not find that prompt.", "error");
|
|
return;
|
|
}
|
|
|
|
const prompts = state.prompts.filter((item) => item.id !== id);
|
|
await storageSet(PROMPTS_STORAGE_KEY, prompts);
|
|
set((current) => ({
|
|
prompts,
|
|
issueDraft: {
|
|
...current.issueDraft,
|
|
promptId: current.issueDraft.promptId === id ? "" : current.issueDraft.promptId
|
|
},
|
|
promptDraft: current.editingPromptId === id
|
|
? {
|
|
...EMPTY_PROMPT_DRAFT,
|
|
workspacePath: getFallbackWorkspacePath(current)
|
|
}
|
|
: current.promptDraft,
|
|
editingPromptId: current.editingPromptId === id ? null : current.editingPromptId
|
|
}));
|
|
setStatus(set, `Deleted ${prompt.name}.`, "success");
|
|
},
|
|
setIssuePrompt: (promptId) =>
|
|
set((state) => {
|
|
const prompt = promptId ? getPromptById(promptId, state.prompts) : null;
|
|
|
|
return {
|
|
issueDraft: {
|
|
...state.issueDraft,
|
|
promptId: prompt?.id ?? "",
|
|
content: prompt ? prompt.content : state.issueDraft.content
|
|
}
|
|
};
|
|
}),
|
|
openScreenshotPreview: (target, id) =>
|
|
set({
|
|
screenshotPreview: {
|
|
target,
|
|
id,
|
|
crop: {
|
|
left: 0,
|
|
top: 0,
|
|
width: 100,
|
|
height: 100
|
|
}
|
|
}
|
|
}),
|
|
closeScreenshotPreview: () => set({ screenshotPreview: null }),
|
|
setScreenshotPreviewCrop: (crop) =>
|
|
set((state) => ({
|
|
screenshotPreview: state.screenshotPreview
|
|
? {
|
|
...state.screenshotPreview,
|
|
crop: clampCrop({
|
|
...state.screenshotPreview.crop,
|
|
...crop
|
|
})
|
|
}
|
|
: null
|
|
})),
|
|
applyScreenshotCrop: async () => {
|
|
const state = appStore.getState();
|
|
const preview = state.screenshotPreview;
|
|
|
|
if (!preview) {
|
|
return;
|
|
}
|
|
|
|
const screenshots =
|
|
preview.target === "issue"
|
|
? state.issueDraft.screenshots
|
|
: preview.target === "comment"
|
|
? state.issueCommentDraft.screenshots
|
|
: state.issueAttachmentDraft.screenshots;
|
|
const screenshot = screenshots.find((item) => item.id === preview.id);
|
|
|
|
if (!screenshot) {
|
|
setStatus(set, "Could not find that screenshot.", "error");
|
|
set({ screenshotPreview: null });
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const cropped = await cropScreenshot(screenshot, preview.crop);
|
|
set((current) =>
|
|
preview.target === "issue"
|
|
? {
|
|
issueDraft: {
|
|
...current.issueDraft,
|
|
screenshots: current.issueDraft.screenshots.map((item) => (item.id === preview.id ? cropped : item))
|
|
},
|
|
screenshotPreview: null
|
|
}
|
|
: preview.target === "comment"
|
|
? {
|
|
issueCommentDraft: {
|
|
...current.issueCommentDraft,
|
|
screenshots: current.issueCommentDraft.screenshots.map((item) =>
|
|
item.id === preview.id ? cropped : item
|
|
)
|
|
},
|
|
screenshotPreview: null
|
|
}
|
|
: {
|
|
issueAttachmentDraft: {
|
|
...current.issueAttachmentDraft,
|
|
screenshots: current.issueAttachmentDraft.screenshots.map((item) =>
|
|
item.id === preview.id ? cropped : item
|
|
)
|
|
},
|
|
screenshotPreview: null
|
|
}
|
|
);
|
|
setStatus(set, `Cropped ${screenshot.name}.`, "success");
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : "Could not crop the screenshot.";
|
|
setStatus(set, message, "error");
|
|
}
|
|
},
|
|
removePreviewScreenshot: () => {
|
|
const preview = appStore.getState().screenshotPreview;
|
|
|
|
if (!preview) {
|
|
return;
|
|
}
|
|
|
|
if (preview.target === "issue") {
|
|
appStore.getState().deleteIssueScreenshot(preview.id);
|
|
} else if (preview.target === "comment") {
|
|
appStore.getState().deleteIssueCommentScreenshot(preview.id);
|
|
} else {
|
|
appStore.getState().deleteIssueAttachmentScreenshot(preview.id);
|
|
}
|
|
|
|
set({ screenshotPreview: null });
|
|
},
|
|
setOpenIssuesLimit: (limitToCurrentRepo) =>
|
|
set({
|
|
openIssuesLimitToCurrentRepo: limitToCurrentRepo,
|
|
openIssuesScopeKey: "",
|
|
openIssues: [],
|
|
...emptyIssueDrafts()
|
|
}),
|
|
setOpenIssuesStatusFilters: (statusFilters) =>
|
|
set({
|
|
openIssuesStatusFilters: statusFilters,
|
|
openIssuesScopeKey: "",
|
|
openIssues: [],
|
|
...emptyIssueDrafts()
|
|
}),
|
|
setOpenIssuesLabelFilter: (labelFilter) =>
|
|
set({
|
|
openIssuesLabelFilter: labelFilter,
|
|
openIssuesScopeKey: "",
|
|
openIssues: [],
|
|
...emptyIssueDrafts()
|
|
}),
|
|
clearSelectedIssue: () => set(emptyIssueDrafts()),
|
|
selectIssue: (issueNumber, repoFullName) =>
|
|
set((state) => {
|
|
const selectedRepoFullName = repoFullName ?? state.selectedIssueRepoFullName ?? getTargetRepo(state)?.fullName ?? "";
|
|
|
|
if (state.selectedIssueNumber === issueNumber && state.selectedIssueRepoFullName === selectedRepoFullName) {
|
|
return emptyIssueDrafts();
|
|
}
|
|
|
|
return {
|
|
selectedIssueNumber: issueNumber,
|
|
selectedIssueRepoFullName: selectedRepoFullName,
|
|
selectedIssueDetail:
|
|
state.selectedIssueDetailNumber === issueNumber &&
|
|
(!repoFullName || state.selectedIssueRepoFullName === repoFullName)
|
|
? state.selectedIssueDetail
|
|
: null,
|
|
selectedIssueDetailNumber:
|
|
state.selectedIssueDetailNumber === issueNumber &&
|
|
(!repoFullName || state.selectedIssueRepoFullName === repoFullName)
|
|
? state.selectedIssueDetailNumber
|
|
: null,
|
|
selectedIssueEditDraft:
|
|
state.selectedIssueDetailNumber === issueNumber &&
|
|
(!repoFullName || state.selectedIssueRepoFullName === repoFullName) &&
|
|
state.selectedIssueDetail
|
|
? {
|
|
subject: state.selectedIssueDetail.issue.title,
|
|
content: state.selectedIssueDetail.issue.body,
|
|
labels: normalizeIssueLabels(state.selectedIssueDetail.issue.labels)
|
|
}
|
|
: {
|
|
subject: "",
|
|
content: "",
|
|
labels: []
|
|
},
|
|
issueCommentDraft: {
|
|
content: "",
|
|
screenshots: [],
|
|
files: []
|
|
},
|
|
issueAttachmentDraft: {
|
|
screenshots: [],
|
|
files: []
|
|
}
|
|
};
|
|
}),
|
|
loadOpenIssues: async (force = false) => {
|
|
const state = appStore.getState();
|
|
const scopeKey = getOpenIssuesScopeKey(state);
|
|
const giteaState = getOpenIssuesGiteaState(state.openIssuesStatusFilters);
|
|
|
|
if (!force && state.openIssuesScopeKey === scopeKey) {
|
|
return;
|
|
}
|
|
|
|
set({
|
|
isLoadingTargetIssues: true,
|
|
openIssuesScopeKey: scopeKey
|
|
});
|
|
|
|
try {
|
|
if (!giteaState) {
|
|
set({
|
|
openIssues: [],
|
|
openIssuesScopeKey: scopeKey,
|
|
...emptyIssueDrafts()
|
|
});
|
|
return;
|
|
}
|
|
|
|
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: [],
|
|
...emptyIssueDrafts()
|
|
});
|
|
return;
|
|
}
|
|
|
|
const issueGroups = await Promise.all(
|
|
targets.map(async (target) =>
|
|
(
|
|
await getGiteaIssues(target.owner, target.repo, {
|
|
limit: 25,
|
|
order: "desc",
|
|
sort: "created",
|
|
state: giteaState
|
|
})
|
|
).map((issue) => toOpenIssue(issue, target))
|
|
)
|
|
);
|
|
const openIssues = issueGroups
|
|
.flat()
|
|
.filter((issue) => issueHasLabel(issue, state.openIssuesLabelFilter))
|
|
.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,
|
|
selectedIssueEditDraft:
|
|
current.selectedIssueNumber &&
|
|
openIssues.some(
|
|
(issue) =>
|
|
issue.number === current.selectedIssueNumber &&
|
|
issue.repoFullName === current.selectedIssueRepoFullName
|
|
)
|
|
? current.selectedIssueEditDraft
|
|
: {
|
|
subject: "",
|
|
content: "",
|
|
labels: []
|
|
}
|
|
}));
|
|
} 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);
|
|
|
|
if (!target) {
|
|
set({
|
|
targetIssues: [],
|
|
issueListRepoFullName: "",
|
|
selectedIssueNumber: null,
|
|
selectedIssueDetail: null,
|
|
selectedIssueDetailNumber: 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,
|
|
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) {
|
|
const message = error instanceof Error ? error.message : "Could not load Gitea issues.";
|
|
setStatus(set, message, "error");
|
|
} finally {
|
|
set({ isLoadingTargetIssues: false });
|
|
}
|
|
},
|
|
loadSelectedIssueDetail: async (force = false) => {
|
|
const state = appStore.getState();
|
|
const target = getIssueTarget(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,
|
|
selectedIssueEditDraft: {
|
|
subject: selectedIssueDetail.issue.title,
|
|
content: selectedIssueDetail.issue.body,
|
|
labels: normalizeIssueLabels(selectedIssueDetail.issue.labels)
|
|
}
|
|
});
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : "Could not load issue details.";
|
|
setStatus(set, message, "error");
|
|
} finally {
|
|
set({ isLoadingIssueDetail: false });
|
|
}
|
|
},
|
|
setSelectedIssueEditDraft: (draft) =>
|
|
set((state) => ({
|
|
selectedIssueEditDraft: {
|
|
...state.selectedIssueEditDraft,
|
|
...draft
|
|
}
|
|
})),
|
|
saveSelectedIssueEdits: async () => {
|
|
const state = appStore.getState();
|
|
const issueNumber = state.selectedIssueNumber;
|
|
const repoFullName = state.selectedIssueRepoFullName;
|
|
const target = repoFullName ? getRepoTargetByFullName(state, repoFullName) : null;
|
|
const subject = state.selectedIssueEditDraft.subject.trim();
|
|
|
|
if (!target || !issueNumber) {
|
|
setStatus(set, "Select an issue from Issues before editing it.", "error");
|
|
return;
|
|
}
|
|
|
|
if (!subject) {
|
|
setStatus(set, "Issue subject is required.", "error");
|
|
return;
|
|
}
|
|
|
|
set({ isUpdatingIssue: true });
|
|
setStatus(set, `Saving issue #${issueNumber}...`, "neutral", { autoClear: false });
|
|
|
|
try {
|
|
const selectedLabels = state.selectedIssueEditDraft.labels;
|
|
const ensuredLabels = await ensureIssueLabels(target.owner, target.repo, selectedLabels);
|
|
const updatedIssue = await updateGiteaIssue(target.owner, target.repo, issueNumber, {
|
|
title: subject,
|
|
body: state.selectedIssueEditDraft.content
|
|
});
|
|
const savedLabels = await setGiteaIssueLabels(target.owner, target.repo, issueNumber, ensuredLabels.names);
|
|
const normalizedSavedLabels = normalizeIssueLabels(
|
|
savedLabels.length || !selectedLabels.length ? savedLabels : ensuredLabels.names
|
|
);
|
|
const issueWithLabels = {
|
|
...updatedIssue,
|
|
labels: normalizedSavedLabels
|
|
};
|
|
const updatedOpenIssue = toOpenIssue(issueWithLabels, target);
|
|
|
|
set((current) => ({
|
|
openIssues: current.openIssues.map((issue) =>
|
|
issue.number === issueNumber && issue.repoFullName === target.fullName ? updatedOpenIssue : issue
|
|
),
|
|
selectedIssueRepoFullName: target.fullName,
|
|
selectedIssueDetail:
|
|
current.selectedIssueDetail && current.selectedIssueNumber === issueNumber
|
|
? {
|
|
...current.selectedIssueDetail,
|
|
issue: {
|
|
...current.selectedIssueDetail.issue,
|
|
...issueWithLabels
|
|
}
|
|
}
|
|
: current.selectedIssueDetail,
|
|
selectedIssueEditDraft: {
|
|
subject: issueWithLabels.title,
|
|
content: issueWithLabels.body,
|
|
labels: normalizeIssueLabels(issueWithLabels.labels)
|
|
}
|
|
}));
|
|
|
|
await appStore.getState().loadSelectedIssueDetail(true);
|
|
setStatus(set, `Saved issue #${issueNumber}.`, "success");
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : "Could not save the issue.";
|
|
setStatus(set, message, "error");
|
|
} finally {
|
|
set({ isUpdatingIssue: false });
|
|
}
|
|
},
|
|
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");
|
|
return;
|
|
}
|
|
|
|
set({ isUpdatingIssue: true });
|
|
setStatus(set, `Deleting issue #${issueNumber}...`, "neutral", { autoClear: false });
|
|
|
|
try {
|
|
await deleteGiteaIssue(target.owner, target.repo, issueNumber);
|
|
set((state) => ({
|
|
targetIssues: state.targetIssues.filter((issue) => issue.number !== issueNumber),
|
|
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,
|
|
selectedIssueEditDraft:
|
|
state.selectedIssueNumber === issueNumber && state.selectedIssueRepoFullName === target.fullName
|
|
? {
|
|
subject: "",
|
|
content: "",
|
|
labels: []
|
|
}
|
|
: state.selectedIssueEditDraft,
|
|
issueCommentDraft:
|
|
state.selectedIssueNumber === issueNumber && state.selectedIssueRepoFullName === target.fullName
|
|
? {
|
|
content: "",
|
|
screenshots: [],
|
|
files: []
|
|
}
|
|
: state.issueCommentDraft,
|
|
issueAttachmentDraft:
|
|
state.selectedIssueNumber === issueNumber && state.selectedIssueRepoFullName === target.fullName
|
|
? {
|
|
screenshots: [],
|
|
files: []
|
|
}
|
|
: state.issueAttachmentDraft
|
|
}));
|
|
await appStore.getState().loadOpenIssues(true);
|
|
await appStore.getState().loadTargetIssues(true);
|
|
setStatus(set, `Deleted issue #${issueNumber}.`, "success");
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : "Could not delete the Gitea issue.";
|
|
setStatus(set, message, "error");
|
|
} finally {
|
|
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 });
|
|
|
|
try {
|
|
const { dataUrl, blob } = await captureVisibleTab();
|
|
const screenshot = createScreenshotAttachment(dataUrl, blob);
|
|
|
|
set((state) => ({
|
|
issueDraft: {
|
|
...state.issueDraft,
|
|
screenshots: [...state.issueDraft.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({ 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 });
|
|
}
|
|
},
|
|
captureIssueAttachmentScreenshot: async () => {
|
|
set({ isCapturingIssueAttachmentScreenshot: true });
|
|
setStatus(set, "Capturing screenshot...", "neutral", { autoClear: false });
|
|
|
|
try {
|
|
const { dataUrl, blob } = await captureVisibleTab();
|
|
const screenshot = createScreenshotAttachment(dataUrl, blob);
|
|
|
|
set((state) => ({
|
|
issueAttachmentDraft: {
|
|
...state.issueAttachmentDraft,
|
|
screenshots: [...state.issueAttachmentDraft.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({ isCapturingIssueAttachmentScreenshot: false });
|
|
}
|
|
},
|
|
uploadSelectedIssueAttachments: async () => {
|
|
const state = appStore.getState();
|
|
const target = getIssueTarget(state);
|
|
const issueNumber = state.selectedIssueNumber;
|
|
const uploads = [
|
|
...state.issueAttachmentDraft.screenshots.map((screenshot) => ({
|
|
name: screenshot.name,
|
|
blob: screenshot.blob
|
|
})),
|
|
...state.issueAttachmentDraft.files.map((file) => ({
|
|
name: file.name,
|
|
blob: file
|
|
}))
|
|
];
|
|
|
|
if (!target || !issueNumber) {
|
|
setStatus(set, "Select an issue before uploading attachments.", "error");
|
|
return;
|
|
}
|
|
|
|
if (!uploads.length) {
|
|
setStatus(set, "Add a screenshot or file before uploading.", "error");
|
|
return;
|
|
}
|
|
|
|
set({ isUploadingIssueAttachments: true });
|
|
setStatus(set, `Uploading attachments to issue #${issueNumber}...`, "neutral", { autoClear: false });
|
|
|
|
try {
|
|
for (const upload of uploads) {
|
|
await uploadGiteaIssueAttachment(target.owner, target.repo, issueNumber, upload.blob, upload.name);
|
|
}
|
|
|
|
set({
|
|
issueAttachmentDraft: {
|
|
screenshots: [],
|
|
files: []
|
|
}
|
|
});
|
|
await appStore.getState().loadSelectedIssueDetail(true);
|
|
setStatus(set, `Uploaded ${uploads.length} attachment${uploads.length === 1 ? "" : "s"}.`, "success");
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : "Could not upload issue attachments.";
|
|
setStatus(set, message, "error");
|
|
} finally {
|
|
set({ isUploadingIssueAttachments: false });
|
|
}
|
|
},
|
|
createIssueComment: async () => {
|
|
const state = appStore.getState();
|
|
const target = getIssueTarget(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().loadOpenIssues(true);
|
|
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.";
|
|
setStatus(set, message, "error");
|
|
} finally {
|
|
set({ isCreatingIssueComment: false });
|
|
}
|
|
},
|
|
createIssue: async () => {
|
|
const state = appStore.getState();
|
|
const target = getTargetRepo(state);
|
|
const subject = state.issueDraft.subject.trim();
|
|
|
|
if (!target) {
|
|
setStatus(set, "Open a known URL with a linked Gitea repo before creating an issue.", "error");
|
|
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;
|
|
}
|
|
|
|
set({ isCreatingIssue: true });
|
|
setStatus(set, "Creating Gitea issue...", "neutral", { autoClear: false });
|
|
|
|
try {
|
|
const { ids: labelIds } = await ensureIssueLabels(target.owner, target.repo, state.issueDraft.labels);
|
|
const issue = await createGiteaIssue(
|
|
target.owner,
|
|
target.repo,
|
|
subject,
|
|
buildIssueBody(state, target.knownUrl),
|
|
labelIds
|
|
);
|
|
const agent = getAgentById(state.issueDraft.agentId, state.agents);
|
|
const uploads = [
|
|
{
|
|
name: agent.file,
|
|
blob: agentContentBlob(renderAgentContent(agent, state, target.knownUrl, issue.number))
|
|
},
|
|
...state.issueDraft.screenshots.map((screenshot) => ({
|
|
name: screenshot.name,
|
|
blob: screenshot.blob
|
|
})),
|
|
...state.issueDraft.files.map((file) => ({
|
|
name: file.name,
|
|
blob: file
|
|
}))
|
|
];
|
|
|
|
for (const upload of uploads) {
|
|
await uploadGiteaIssueAttachment(target.owner, target.repo, issue.number, upload.blob, upload.name);
|
|
}
|
|
|
|
set({
|
|
issueDraft: {
|
|
subject: "",
|
|
content: "",
|
|
promptId: "",
|
|
agentId: appStore.getState().agents[0]?.id ?? AVAILABLE_AGENTS[0].id,
|
|
labels: ["Change Request"],
|
|
screenshots: [],
|
|
files: []
|
|
},
|
|
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");
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : "Could not create the Gitea issue.";
|
|
setStatus(set, message, "error");
|
|
} finally {
|
|
set({ isCreatingIssue: false });
|
|
}
|
|
},
|
|
refreshActiveTab: async () => {
|
|
const activeTabUrl = await getActiveTabUrl();
|
|
const activeBaseUrl = getBaseUrl(activeTabUrl);
|
|
const currentBaseUrl = get().activeBaseUrl;
|
|
|
|
set({
|
|
activeTabUrl,
|
|
activeBaseUrl,
|
|
addUrlDraft:
|
|
activeBaseUrl !== currentBaseUrl
|
|
? { mode: null, selectedValue: "" }
|
|
: get().addUrlDraft,
|
|
issueDraft:
|
|
activeBaseUrl !== currentBaseUrl
|
|
? {
|
|
...get().issueDraft,
|
|
promptId: ""
|
|
}
|
|
: get().issueDraft,
|
|
targetIssues:
|
|
activeBaseUrl !== currentBaseUrl
|
|
? []
|
|
: get().targetIssues,
|
|
issueListRepoFullName:
|
|
activeBaseUrl !== currentBaseUrl
|
|
? ""
|
|
: get().issueListRepoFullName,
|
|
openIssues:
|
|
activeBaseUrl !== currentBaseUrl && get().openIssuesLimitToCurrentRepo
|
|
? []
|
|
: get().openIssues,
|
|
openIssuesScopeKey:
|
|
activeBaseUrl !== currentBaseUrl && get().openIssuesLimitToCurrentRepo
|
|
? ""
|
|
: get().openIssuesScopeKey,
|
|
selectedIssueNumber:
|
|
activeBaseUrl !== currentBaseUrl && get().openIssuesLimitToCurrentRepo
|
|
? null
|
|
: get().selectedIssueNumber,
|
|
selectedIssueRepoFullName:
|
|
activeBaseUrl !== currentBaseUrl && get().openIssuesLimitToCurrentRepo
|
|
? ""
|
|
: get().selectedIssueRepoFullName,
|
|
selectedIssueDetail:
|
|
activeBaseUrl !== currentBaseUrl && get().openIssuesLimitToCurrentRepo
|
|
? null
|
|
: get().selectedIssueDetail,
|
|
selectedIssueDetailNumber:
|
|
activeBaseUrl !== currentBaseUrl && get().openIssuesLimitToCurrentRepo
|
|
? null
|
|
: get().selectedIssueDetailNumber,
|
|
selectedIssueEditDraft:
|
|
activeBaseUrl !== currentBaseUrl && get().openIssuesLimitToCurrentRepo
|
|
? {
|
|
subject: "",
|
|
content: "",
|
|
labels: []
|
|
}
|
|
: get().selectedIssueEditDraft,
|
|
promptDraft:
|
|
activeBaseUrl !== currentBaseUrl && !get().editingPromptId
|
|
? {
|
|
...get().promptDraft,
|
|
workspacePath: findKnownUrlByBase(get(), activeBaseUrl)?.workspacePath || get().promptDraft.workspacePath
|
|
}
|
|
: get().promptDraft
|
|
});
|
|
},
|
|
loadData: async () => {
|
|
set({
|
|
isLoadingData: true
|
|
});
|
|
setStatus(set, "Loading workspace and Gitea context...", "neutral", { autoClear: false });
|
|
|
|
try {
|
|
const [localContext, repos, userUrls, userWorkspaces, deletedUrlBases, agents, prompts, activeTabUrl] = await Promise.all([
|
|
loadLocalContext(),
|
|
getReposOrderedByLastCommit(),
|
|
storageGet<KnownUrl[]>(USER_URLS_STORAGE_KEY, []),
|
|
storageGet<LocalWorkspace[]>(USER_WORKSPACES_STORAGE_KEY, []),
|
|
storageGet<string[]>(DELETED_URL_BASES_STORAGE_KEY, []),
|
|
storageGet<AgentProfile[]>(AGENTS_STORAGE_KEY, AVAILABLE_AGENTS),
|
|
storageGet<WorkspacePrompt[]>(PROMPTS_STORAGE_KEY, []),
|
|
getActiveTabUrl()
|
|
]);
|
|
const activeBaseUrl = getBaseUrl(activeTabUrl);
|
|
const normalizedAgents = normalizeAgents(agents);
|
|
const normalizedUserWorkspaces = userWorkspaces
|
|
.map((workspace) => ({
|
|
name: workspace.name?.trim() || workspace.path.split("/").filter(Boolean).slice(-1)[0] || "workspace",
|
|
path: workspace.path?.trim().replace(/\/+$/, "") || "",
|
|
linkedRepos: workspace.linkedRepos ?? []
|
|
}))
|
|
.filter((workspace) => workspace.path);
|
|
const activeKnownUrl = findKnownUrlByBase(
|
|
{
|
|
...get(),
|
|
localContext,
|
|
userWorkspaces: normalizedUserWorkspaces,
|
|
userUrls,
|
|
deletedUrlBases,
|
|
activeBaseUrl
|
|
},
|
|
activeBaseUrl
|
|
);
|
|
const fallbackWorkspacePath = activeKnownUrl?.workspacePath ?? localContext.workspaces[0]?.path ?? normalizedUserWorkspaces[0]?.path ?? "";
|
|
const normalizedPrompts = normalizePrompts(prompts, fallbackWorkspacePath);
|
|
|
|
set({
|
|
localContext,
|
|
repos,
|
|
userWorkspaces: normalizedUserWorkspaces,
|
|
userUrls: userUrls.filter((url) => !deletedUrlBases.includes(getBaseUrl(url.url))),
|
|
deletedUrlBases,
|
|
agents: normalizedAgents,
|
|
prompts: normalizedPrompts,
|
|
issueDraft: {
|
|
...get().issueDraft,
|
|
agentId: normalizedAgents.some((agent) => agent.id === get().issueDraft.agentId)
|
|
? get().issueDraft.agentId
|
|
: normalizedAgents[0].id,
|
|
promptId: normalizedPrompts.some((prompt) => prompt.id === get().issueDraft.promptId)
|
|
? get().issueDraft.promptId
|
|
: ""
|
|
},
|
|
agentDraft: normalizedAgents.find((agent) => agent.id === get().editingAgentId) ?? normalizedAgents[0],
|
|
editingAgentId:
|
|
normalizedAgents.find((agent) => agent.id === get().editingAgentId)?.id ?? normalizedAgents[0].id,
|
|
promptDraft: normalizedPrompts.find((prompt) => prompt.id === get().editingPromptId) ?? {
|
|
...EMPTY_PROMPT_DRAFT,
|
|
workspacePath: fallbackWorkspacePath
|
|
},
|
|
editingPromptId: normalizedPrompts.find((prompt) => prompt.id === get().editingPromptId)?.id ?? null,
|
|
activeTabUrl,
|
|
activeBaseUrl
|
|
});
|
|
setStatus(set, `Loaded ${repos.length} repos and ${localContext.workspaces.length} workspaces.`, "success");
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : "Could not load app data.";
|
|
setStatus(set, message, "error");
|
|
} finally {
|
|
set({ isLoadingData: false });
|
|
}
|
|
},
|
|
saveCurrentUrlLink: async () => {
|
|
const state = appStore.getState();
|
|
const context = state.localContext;
|
|
|
|
if (!context || !state.activeBaseUrl) {
|
|
setStatus(set, "No active URL is available to link.", "error");
|
|
return;
|
|
}
|
|
|
|
const link = inferUrlLink(context, state.activeBaseUrl, state.addUrlDraft);
|
|
if (!link) {
|
|
setStatus(set, "Pick either a workspace or a Gitea repo before adding this URL.", "error");
|
|
return;
|
|
}
|
|
|
|
const userUrls = [
|
|
...state.userUrls.filter((url) => getBaseUrl(url.url) !== state.activeBaseUrl),
|
|
link
|
|
];
|
|
const deletedUrlBases = state.deletedUrlBases.filter((baseUrl) => baseUrl !== state.activeBaseUrl);
|
|
|
|
await storageSet(USER_URLS_STORAGE_KEY, userUrls);
|
|
await storageSet(DELETED_URL_BASES_STORAGE_KEY, deletedUrlBases);
|
|
set({
|
|
userUrls,
|
|
deletedUrlBases,
|
|
activeView: "urls",
|
|
activeDialog: null,
|
|
addUrlDraft: { mode: null, selectedValue: "" }
|
|
});
|
|
setStatus(set, `Linked ${state.activeBaseUrl} to ${link.giteaRepo?.fullName || link.workspaceName}.`, "success");
|
|
},
|
|
deleteUrlLink: async (url) => {
|
|
const state = appStore.getState();
|
|
const baseUrl = getBaseUrl(url);
|
|
|
|
if (!baseUrl) {
|
|
setStatus(set, "Could not delete that URL because it is not a valid http or https URL.", "error");
|
|
return;
|
|
}
|
|
|
|
const userUrls = state.userUrls.filter((knownUrl) => getBaseUrl(knownUrl.url) !== baseUrl);
|
|
const deletedUrlBases = state.deletedUrlBases.includes(baseUrl)
|
|
? state.deletedUrlBases
|
|
: [...state.deletedUrlBases, baseUrl];
|
|
|
|
await storageSet(USER_URLS_STORAGE_KEY, userUrls);
|
|
await storageSet(DELETED_URL_BASES_STORAGE_KEY, deletedUrlBases);
|
|
set({
|
|
userUrls,
|
|
deletedUrlBases,
|
|
activeView: "urls"
|
|
});
|
|
setStatus(set, `Deleted ${baseUrl} from URLs.`, "success");
|
|
},
|
|
testConnection: async () => {
|
|
set({
|
|
connectedUser: null,
|
|
isTestingConnection: true
|
|
});
|
|
setStatus(set, "Testing connection...", "neutral", { autoClear: false });
|
|
|
|
try {
|
|
const user = await getCurrentGiteaUser();
|
|
set({
|
|
connectedUser: user
|
|
});
|
|
setStatus(set, `Connected as ${user.full_name || user.login}.`, "success");
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : "Connection failed.";
|
|
set({
|
|
connectedUser: null
|
|
});
|
|
setStatus(set, message, "error");
|
|
} finally {
|
|
set({ isTestingConnection: false });
|
|
}
|
|
}
|
|
}));
|