1908 lines
66 KiB
TypeScript
1908 lines
66 KiB
TypeScript
import {
|
|
createIcons,
|
|
Bot,
|
|
CirclePlus,
|
|
FileText,
|
|
FolderGit2,
|
|
GitBranch,
|
|
Link,
|
|
List,
|
|
ListChecks,
|
|
RefreshCw,
|
|
Send,
|
|
X
|
|
} from "lucide";
|
|
import "./styles.css";
|
|
import {
|
|
appStore,
|
|
CHROME_PROFILE_NAME,
|
|
findKnownUrlForBase,
|
|
getAllWorkspaces,
|
|
ISSUE_LABEL_OPTIONS,
|
|
type AgentProfile,
|
|
type AddUrlMode,
|
|
type AppView,
|
|
type IssueLabelOption,
|
|
type OpenIssue,
|
|
type OpenIssuesStatusFilter,
|
|
type ScreenshotAttachment,
|
|
type WorkspacePrompt
|
|
} from "./store";
|
|
import type { GiteaIssueAttachment, GiteaIssueComment, GiteaIssueSummary, GiteaRepoSummary } from "./api";
|
|
import type { KnownUrl, LocalContext, LocalFolderCandidate, LocalWorkspace } from "./localContext";
|
|
|
|
type CropHandle = "n" | "e" | "s" | "w" | "nw" | "ne" | "sw" | "se";
|
|
|
|
const status = document.querySelector<HTMLElement>("#status");
|
|
const refreshData = document.querySelector<HTMLButtonElement>("#refresh-data");
|
|
const addIssueButton = document.querySelector<HTMLButtonElement>("#add-issue");
|
|
const manageViewSelect = document.querySelector<HTMLSelectElement>("#manage-view-select");
|
|
const viewHeader = document.querySelector<HTMLElement>("#view-header");
|
|
const viewContent = document.querySelector<HTMLElement>("#view-content");
|
|
const viewButtons = Array.from(document.querySelectorAll<HTMLButtonElement>("[data-view]"));
|
|
|
|
const lucideIcons = {
|
|
Bot,
|
|
CirclePlus,
|
|
FileText,
|
|
FolderGit2,
|
|
GitBranch,
|
|
Link,
|
|
List,
|
|
ListChecks,
|
|
RefreshCw,
|
|
Send,
|
|
X
|
|
};
|
|
|
|
function hydrateIcons(): void {
|
|
createIcons({
|
|
icons: lucideIcons
|
|
});
|
|
}
|
|
|
|
hydrateIcons();
|
|
|
|
function escapeHtml(value: string): string {
|
|
return value.replace(/[&<>"']/g, (char) => {
|
|
const replacements: Record<string, string> = {
|
|
"&": "&",
|
|
"<": "<",
|
|
">": ">",
|
|
"\"": """,
|
|
"'": "'"
|
|
};
|
|
return replacements[char];
|
|
});
|
|
}
|
|
|
|
function formatDate(value: string, emptyText = "No date"): string {
|
|
if (!value) {
|
|
return emptyText;
|
|
}
|
|
|
|
return new Intl.DateTimeFormat(undefined, {
|
|
month: "short",
|
|
day: "numeric",
|
|
hour: "numeric",
|
|
minute: "2-digit"
|
|
}).format(new Date(value));
|
|
}
|
|
|
|
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);
|
|
const urls = [...(state.localContext?.knownUrls ?? []), ...state.userUrls].filter(
|
|
(knownUrl) => !deletedBases.has(baseUrlFor(knownUrl.url))
|
|
);
|
|
|
|
return urls.filter(
|
|
(knownUrl, index) => urls.findIndex((item) => baseUrlFor(item.url) === baseUrlFor(knownUrl.url)) === index
|
|
);
|
|
}
|
|
|
|
function baseUrlFor(value: string): string {
|
|
try {
|
|
const url = new URL(value);
|
|
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
return "";
|
|
}
|
|
return `${url.origin}/`;
|
|
} catch {
|
|
return "";
|
|
}
|
|
}
|
|
|
|
function isActiveBaseUrlKnown(): boolean {
|
|
return Boolean(findKnownUrlForBase(appStore.getState()));
|
|
}
|
|
|
|
function getActiveKnownUrl(): KnownUrl | null {
|
|
return findKnownUrlForBase(appStore.getState());
|
|
}
|
|
|
|
function canAddIssue(): boolean {
|
|
return Boolean(getActiveKnownUrl()?.giteaRepo);
|
|
}
|
|
|
|
function getRepoWorkspaceLink(repo: GiteaRepoSummary): string {
|
|
const context = appStore.getState().localContext;
|
|
const worktree = context?.gitWorktrees.find((item) => item.giteaRepo?.fullName === repo.fullName);
|
|
|
|
if (!worktree) {
|
|
return `<span class="muted">No workspace link</span>`;
|
|
}
|
|
|
|
return `<span class="path-text">./${escapeHtml(worktree.relativePath)}</span>`;
|
|
}
|
|
|
|
function renderRepos(repos: GiteaRepoSummary[]): string {
|
|
if (!repos.length) {
|
|
return `<div class="empty-state">No Gitea repos loaded yet.</div>`;
|
|
}
|
|
|
|
return `
|
|
<div class="repo-list">
|
|
${repos
|
|
.map(
|
|
(repo) => `
|
|
<article class="repo-row">
|
|
<div class="repo-main">
|
|
<a class="item-title" href="${repo.webUrl}" target="_blank" rel="noreferrer">${escapeHtml(repo.fullName)}</a>
|
|
<span class="commit-message">${escapeHtml(repo.lastCommitMessage)}</span>
|
|
</div>
|
|
<span class="repo-meta">${formatDate(repo.lastCommitAt, "No commit date")}</span>
|
|
<span class="repo-meta">${formatSha(repo.lastCommitSha)}</span>
|
|
<span class="repo-link-cell">${getRepoWorkspaceLink(repo)}</span>
|
|
</article>
|
|
`
|
|
)
|
|
.join("")}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function renderWorkspaces(workspaces: LocalWorkspace[]): string {
|
|
const state = appStore.getState();
|
|
const userWorkspacePaths = new Set(state.userWorkspaces.map((workspace) => workspace.path));
|
|
const workspaceList = workspaces.length
|
|
? `
|
|
<div class="compact-list">
|
|
${workspaces
|
|
.map((workspace) => {
|
|
const links = workspace.linkedRepos.length
|
|
? workspace.linkedRepos
|
|
.map(
|
|
(repo) =>
|
|
`<a class="repo-link" href="${repo.webUrl}" target="_blank" rel="noreferrer">${escapeHtml(repo.fullName)}</a>`
|
|
)
|
|
.join("")
|
|
: `<span class="muted">No Gitea repo link established</span>`;
|
|
|
|
return `
|
|
<article class="workspace-row">
|
|
<div class="row-main">
|
|
<h2 class="item-title">${escapeHtml(workspace.name)}</h2>
|
|
<p class="path-text">${escapeHtml(workspace.path)}</p>
|
|
</div>
|
|
<div class="repo-link-list">${links}</div>
|
|
<div class="workspace-actions">
|
|
${
|
|
userWorkspacePaths.has(workspace.path)
|
|
? `<button class="danger-button compact-button" type="button" data-delete-user-workspace="${escapeHtml(workspace.path)}">Delete</button>`
|
|
: `<span class="muted">Generated</span>`
|
|
}
|
|
</div>
|
|
</article>
|
|
`;
|
|
})
|
|
.join("")}
|
|
</div>
|
|
`
|
|
: `<div class="empty-state">No workspaces found.</div>`;
|
|
|
|
return `
|
|
<div class="view-action-row">
|
|
<button id="open-workspace-dialog" class="secondary-button compact-button" type="button">
|
|
Add workspace
|
|
</button>
|
|
</div>
|
|
${workspaceList}
|
|
`;
|
|
}
|
|
|
|
function renderAgents(agents: AgentProfile[]): string {
|
|
const state = appStore.getState();
|
|
const draft = state.agentDraft;
|
|
|
|
return `
|
|
<div class="compact-list">
|
|
${agents
|
|
.map(
|
|
(agent) => `
|
|
<article class="agent-row">
|
|
<div class="row-main">
|
|
<h2 class="item-title">${escapeHtml(agent.name)}</h2>
|
|
<span class="path-text">${escapeHtml(agent.file)}</span>
|
|
<span class="muted">${escapeHtml(agent.description)}</span>
|
|
</div>
|
|
<div class="agent-actions">
|
|
<button class="secondary-button compact-button" type="button" data-edit-agent="${agent.id}">Edit</button>
|
|
<button class="danger-button compact-button" type="button" data-delete-agent="${agent.id}">Delete</button>
|
|
</div>
|
|
</article>
|
|
`
|
|
)
|
|
.join("")}
|
|
</div>
|
|
<article class="item-card agent-editor-card">
|
|
<div class="form-stack">
|
|
<label>
|
|
<span>Name</span>
|
|
<input id="agent-name" type="text" value="${escapeHtml(draft.name)}" placeholder="GLOBAL AGENT" />
|
|
</label>
|
|
<label>
|
|
<span>File</span>
|
|
<input id="agent-file" type="text" value="${escapeHtml(draft.file)}" placeholder="AGENTS.md" />
|
|
</label>
|
|
<label>
|
|
<span>Description</span>
|
|
<input id="agent-description" type="text" value="${escapeHtml(draft.description)}" placeholder="Agent handoff rules" />
|
|
</label>
|
|
<label>
|
|
<span>Content</span>
|
|
<textarea id="agent-content" rows="14" placeholder="Markdown content">${escapeHtml(draft.content)}</textarea>
|
|
</label>
|
|
<div class="agent-editor-actions">
|
|
<button id="new-agent" class="secondary-button" type="button">New</button>
|
|
<button id="save-agent" class="primary-button" type="button">${state.editingAgentId ? "Save agent" : "Add agent"}</button>
|
|
</div>
|
|
</div>
|
|
</article>
|
|
`;
|
|
}
|
|
|
|
function workspacePathOptions(workspaces: LocalWorkspace[], selectedPath: string): string {
|
|
if (!workspaces.length) {
|
|
return selectedPath
|
|
? `<option value="${escapeHtml(selectedPath)}" selected>${escapeHtml(selectedPath)}</option>`
|
|
: "";
|
|
}
|
|
|
|
return workspaces
|
|
.map(
|
|
(workspace) =>
|
|
`<option value="${escapeHtml(workspace.path)}"${selectedAttribute(workspace.path, selectedPath)}>${escapeHtml(workspace.name)} - ${escapeHtml(workspace.path)}</option>`
|
|
)
|
|
.join("");
|
|
}
|
|
|
|
function renderPrompts(prompts: WorkspacePrompt[]): string {
|
|
const state = appStore.getState();
|
|
const draft = state.promptDraft;
|
|
|
|
return `
|
|
<div class="compact-list">
|
|
${
|
|
prompts.length
|
|
? prompts
|
|
.map(
|
|
(prompt) => `
|
|
<article class="prompt-row">
|
|
<div class="row-main">
|
|
<h2 class="item-title">${escapeHtml(prompt.name)}</h2>
|
|
<span class="path-text">${escapeHtml(prompt.workspacePath)}</span>
|
|
</div>
|
|
<div class="prompt-actions">
|
|
<button class="secondary-button compact-button" type="button" data-edit-prompt="${prompt.id}">Edit</button>
|
|
<button class="danger-button compact-button" type="button" data-delete-prompt="${prompt.id}">Delete</button>
|
|
</div>
|
|
</article>
|
|
`
|
|
)
|
|
.join("")
|
|
: `<div class="empty-state">No workspace prompts saved yet.</div>`
|
|
}
|
|
</div>
|
|
<article class="item-card prompt-editor-card">
|
|
<div class="form-stack">
|
|
<label>
|
|
<span>Name</span>
|
|
<input id="prompt-name" type="text" value="${escapeHtml(draft.name)}" placeholder="Default implementation prompt" />
|
|
</label>
|
|
<label>
|
|
<span>Workspace</span>
|
|
<select id="prompt-workspace">
|
|
<option value="">Pick a workspace</option>
|
|
${workspacePathOptions(getAllWorkspaces(state), draft.workspacePath)}
|
|
</select>
|
|
</label>
|
|
<label>
|
|
<span>Content</span>
|
|
<textarea id="prompt-content" rows="12" placeholder="Prompt text copied into Add Issue content">${escapeHtml(draft.content)}</textarea>
|
|
</label>
|
|
<div class="prompt-editor-actions">
|
|
<button id="new-prompt" class="secondary-button" type="button">New</button>
|
|
<button id="save-prompt" class="primary-button" type="button">${state.editingPromptId ? "Save prompt" : "Add prompt"}</button>
|
|
</div>
|
|
</div>
|
|
</article>
|
|
`;
|
|
}
|
|
|
|
function renderUrls(urls: KnownUrl[]): string {
|
|
const state = appStore.getState();
|
|
const canOpenAddUrl = Boolean(state.activeBaseUrl && !isActiveBaseUrlKnown());
|
|
const urlList = urls.length
|
|
? `
|
|
<div class="compact-list">
|
|
${urls
|
|
.map(
|
|
(knownUrl) => `
|
|
<article class="url-row">
|
|
<div class="row-main">
|
|
<h2 class="item-title">${escapeHtml(knownUrl.label)}</h2>
|
|
<a class="url-text" href="${knownUrl.url}" target="_blank" rel="noreferrer">${escapeHtml(knownUrl.url)}</a>
|
|
<span class="path-text">${escapeHtml(knownUrl.workspacePath)}</span>
|
|
${
|
|
knownUrl.giteaRepo
|
|
? `<a class="repo-link" href="${knownUrl.giteaRepo.webUrl}" target="_blank" rel="noreferrer">${escapeHtml(knownUrl.giteaRepo.fullName)}</a>`
|
|
: `<span class="muted">No Gitea repo link established</span>`
|
|
}
|
|
</div>
|
|
<div class="url-actions">
|
|
<button class="visit-button" type="button" data-open-url="${knownUrl.url}">Quick visit</button>
|
|
<button class="danger-button" type="button" data-delete-url="${knownUrl.url}">Delete</button>
|
|
</div>
|
|
</article>
|
|
`
|
|
)
|
|
.join("")}
|
|
</div>
|
|
`
|
|
: `<div class="empty-state">No known URLs registered.</div>`;
|
|
|
|
return `
|
|
<div class="view-action-row">
|
|
<button id="open-add-url-dialog" class="secondary-button compact-button" type="button" ${canOpenAddUrl ? "" : "disabled"}>
|
|
Add URL
|
|
</button>
|
|
<span class="muted">${escapeHtml(state.activeBaseUrl || "Open an http or https tab to add a URL.")}</span>
|
|
</div>
|
|
${urlList}
|
|
`;
|
|
}
|
|
|
|
function selectedAttribute(value: string, selectedValue: string): string {
|
|
return value === selectedValue ? " selected" : "";
|
|
}
|
|
|
|
function workspaceOptions(workspaces: LocalWorkspace[], selectedValue: string): string {
|
|
return workspaces
|
|
.map(
|
|
(workspace) =>
|
|
`<option value="${escapeHtml(workspace.path)}"${workspace.path === selectedValue || workspace.name === selectedValue ? " selected" : ""}>${escapeHtml(workspace.name)}</option>`
|
|
)
|
|
.join("");
|
|
}
|
|
|
|
function repoOptions(context: LocalContext, selectedValue: string): string {
|
|
const repos = context.gitWorktrees
|
|
.map((worktree) => worktree.giteaRepo)
|
|
.filter((repo): repo is NonNullable<typeof repo> => Boolean(repo))
|
|
.filter((repo, index, repos) => repos.findIndex((item) => item.fullName === repo.fullName) === index)
|
|
.sort((a, b) => a.fullName.localeCompare(b.fullName));
|
|
|
|
return repos
|
|
.map(
|
|
(repo) =>
|
|
`<option value="${escapeHtml(repo.fullName)}"${selectedAttribute(repo.fullName, selectedValue)}>${escapeHtml(repo.fullName)}</option>`
|
|
)
|
|
.join("");
|
|
}
|
|
|
|
function renderAddUrl(): string {
|
|
const state = appStore.getState();
|
|
const context = state.localContext;
|
|
|
|
if (!state.activeBaseUrl) {
|
|
return `<div class="empty-state">Open an http or https tab before adding a URL link.</div>`;
|
|
}
|
|
|
|
if (isActiveBaseUrlKnown()) {
|
|
return `<div class="empty-state">${escapeHtml(state.activeBaseUrl)} is already linked.</div>`;
|
|
}
|
|
|
|
if (!context) {
|
|
return `<div class="empty-state">Workspace context has not loaded yet.</div>`;
|
|
}
|
|
|
|
const mode = state.addUrlDraft.mode;
|
|
const selectedValue = state.addUrlDraft.selectedValue;
|
|
const workspaces = getAllWorkspaces(state);
|
|
|
|
return `
|
|
<article class="item-card">
|
|
<div class="form-stack">
|
|
<label>
|
|
<span>Base URL</span>
|
|
<input type="text" value="${escapeHtml(state.activeBaseUrl)}" readonly />
|
|
</label>
|
|
|
|
<fieldset>
|
|
<legend>Link by</legend>
|
|
<label class="radio-row">
|
|
<input type="radio" name="add-url-mode" value="workspace" ${mode === "workspace" ? "checked" : ""} />
|
|
<span>Workspace</span>
|
|
</label>
|
|
<label class="radio-row">
|
|
<input type="radio" name="add-url-mode" value="repo" ${mode === "repo" ? "checked" : ""} />
|
|
<span>Gitea repo</span>
|
|
</label>
|
|
</fieldset>
|
|
|
|
<label>
|
|
<span>${mode === "repo" ? "Gitea repo" : "Workspace"}</span>
|
|
<select id="add-url-selection" ${mode ? "" : "disabled"}>
|
|
<option value="">Pick ${mode === "repo" ? "a repo" : "a workspace"}</option>
|
|
${mode === "repo" ? repoOptions(context, selectedValue) : workspaceOptions(workspaces, selectedValue)}
|
|
</select>
|
|
</label>
|
|
|
|
<button id="save-url-link" class="primary-button" type="button" ${state.addUrlDraft.selectedValue ? "" : "disabled"}>
|
|
Link URL
|
|
</button>
|
|
</div>
|
|
</article>
|
|
`;
|
|
}
|
|
|
|
function workspaceFolderCandidates(): LocalFolderCandidate[] {
|
|
const state = appStore.getState();
|
|
const workspaceCandidates = getAllWorkspaces(state).map((workspace) => ({
|
|
name: workspace.name,
|
|
path: workspace.path,
|
|
source: "workspace-root" as const
|
|
}));
|
|
const contextCandidates = state.localContext?.folderCandidates ?? [];
|
|
const candidates = [...workspaceCandidates, ...contextCandidates];
|
|
|
|
return candidates
|
|
.filter((candidate, index) => candidates.findIndex((item) => item.path === candidate.path) === index)
|
|
.sort((a, b) => a.path.localeCompare(b.path));
|
|
}
|
|
|
|
function renderWorkspaceSearchResults(candidates: LocalFolderCandidate[], query: string): string {
|
|
const normalizedQuery = query.trim().toLowerCase();
|
|
const matches = (normalizedQuery
|
|
? candidates.filter(
|
|
(candidate) =>
|
|
candidate.name.toLowerCase().includes(normalizedQuery) ||
|
|
candidate.path.toLowerCase().includes(normalizedQuery)
|
|
)
|
|
: candidates
|
|
).slice(0, 40);
|
|
|
|
if (!matches.length) {
|
|
return `<div class="empty-state">No matching folders found in the generated folder index.</div>`;
|
|
}
|
|
|
|
return `
|
|
<div class="folder-result-list">
|
|
${matches
|
|
.map(
|
|
(candidate) => `
|
|
<button class="folder-result" type="button" data-select-workspace-folder="${escapeHtml(candidate.path)}" data-folder-name="${escapeHtml(candidate.name)}">
|
|
<span>${escapeHtml(candidate.name)}</span>
|
|
<span class="path-text">${escapeHtml(candidate.path)}</span>
|
|
</button>
|
|
`
|
|
)
|
|
.join("")}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function renderWorkspaceDialog(): string {
|
|
const state = appStore.getState();
|
|
const draft = state.workspaceDraft;
|
|
|
|
return `
|
|
<div class="form-stack">
|
|
<label>
|
|
<span>Folder search</span>
|
|
<input id="workspace-search" type="text" value="${escapeHtml(draft.search)}" placeholder="Search folders by name or path" />
|
|
</label>
|
|
<div id="workspace-folder-results">
|
|
${renderWorkspaceSearchResults(workspaceFolderCandidates(), draft.search)}
|
|
</div>
|
|
<label>
|
|
<span>Name</span>
|
|
<input id="workspace-name" type="text" value="${escapeHtml(draft.name)}" placeholder="Workspace name" />
|
|
</label>
|
|
<label>
|
|
<span>Selected folder</span>
|
|
<input id="workspace-path" type="text" value="${escapeHtml(draft.path)}" readonly placeholder="Select a folder from search results" />
|
|
</label>
|
|
<button id="save-workspace" class="primary-button" type="button" ${draft.path.trim() ? "" : "disabled"}>Add workspace</button>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function renderActiveDialog(): string {
|
|
const state = appStore.getState();
|
|
|
|
if (!state.activeDialog) {
|
|
return "";
|
|
}
|
|
|
|
const title = state.activeDialog === "add-url" ? "Add URL" : "Add workspace";
|
|
const body = state.activeDialog === "add-url" ? renderAddUrl() : renderWorkspaceDialog();
|
|
|
|
return `
|
|
<div class="app-modal" role="dialog" aria-modal="true" aria-label="${escapeHtml(title)}">
|
|
<article class="app-modal-panel">
|
|
<div class="app-modal-heading">
|
|
<h2>${escapeHtml(title)}</h2>
|
|
<button class="secondary-button compact-button icon-only-button" type="button" data-close-app-dialog title="Close" aria-label="Close">
|
|
<i data-lucide="x"></i>
|
|
</button>
|
|
</div>
|
|
${body}
|
|
</article>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function renderFileList(files: File[], target: "issue" | "comment" | "issue-attachment"): string {
|
|
if (!files.length) {
|
|
return `<span class="muted">No files selected.</span>`;
|
|
}
|
|
|
|
const attribute =
|
|
target === "issue"
|
|
? "data-delete-issue-file"
|
|
: target === "comment"
|
|
? "data-delete-comment-file"
|
|
: "data-delete-issue-asset-file";
|
|
|
|
return `
|
|
<div class="file-list">
|
|
${files
|
|
.map(
|
|
(file, index) => `
|
|
<div class="file-row">
|
|
<span>${escapeHtml(file.name)}</span>
|
|
<span>${escapeHtml(formatBytes(file.size))}</span>
|
|
<button class="danger-button compact-button" type="button" ${attribute}="${index}">Delete</button>
|
|
</div>
|
|
`
|
|
)
|
|
.join("")}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function renderAgentOptions(agents: AgentProfile[], selectedAgentId: string): string {
|
|
return agents
|
|
.map(
|
|
(agent) =>
|
|
`<option value="${escapeHtml(agent.id)}" ${agent.id === selectedAgentId ? "selected" : ""}>${escapeHtml(agent.name)} (${escapeHtml(agent.file)})</option>`
|
|
)
|
|
.join("");
|
|
}
|
|
|
|
function renderIssuePromptOptions(prompts: WorkspacePrompt[], selectedPromptId: string): string {
|
|
return `
|
|
<option value="">No default prompt</option>
|
|
${prompts
|
|
.map(
|
|
(prompt) =>
|
|
`<option value="${escapeHtml(prompt.id)}"${prompt.id === selectedPromptId ? " selected" : ""}>${escapeHtml(prompt.name)}</option>`
|
|
)
|
|
.join("")}
|
|
`;
|
|
}
|
|
|
|
function renderLabelOptions(selectedLabels: IssueLabelOption[], target: "draft" | "edit"): string {
|
|
return `
|
|
<fieldset class="label-fieldset">
|
|
<legend>Labels</legend>
|
|
<div class="label-option-grid">
|
|
${ISSUE_LABEL_OPTIONS.map(
|
|
(label) => `
|
|
<label class="checkbox-row label-option">
|
|
<input type="checkbox" value="${escapeHtml(label)}" data-label-target="${target}" ${selectedLabels.includes(label) ? "checked" : ""} />
|
|
<span>${escapeHtml(label)}</span>
|
|
</label>
|
|
`
|
|
).join("")}
|
|
</div>
|
|
</fieldset>
|
|
`;
|
|
}
|
|
|
|
function renderIssueLabelFilter(selectedLabel: IssueLabelOption | ""): string {
|
|
return `
|
|
<label class="compact-select-label">
|
|
<span>Label</span>
|
|
<select id="issues-label-filter">
|
|
<option value="">All labels</option>
|
|
${ISSUE_LABEL_OPTIONS.map(
|
|
(label) => `<option value="${escapeHtml(label)}"${label === selectedLabel ? " selected" : ""}>${escapeHtml(label)}</option>`
|
|
).join("")}
|
|
</select>
|
|
</label>
|
|
`;
|
|
}
|
|
|
|
function renderIssueStatusFilters(selectedStatuses: OpenIssuesStatusFilter[]): string {
|
|
return `
|
|
<fieldset class="inline-fieldset status-filter-fieldset">
|
|
<legend>Status</legend>
|
|
<label class="checkbox-row">
|
|
<input type="checkbox" value="open" data-issues-status-filter ${selectedStatuses.includes("open") ? "checked" : ""} />
|
|
<span>Open</span>
|
|
</label>
|
|
<label class="checkbox-row">
|
|
<input type="checkbox" value="closed" data-issues-status-filter ${selectedStatuses.includes("closed") ? "checked" : ""} />
|
|
<span>Closed</span>
|
|
</label>
|
|
</fieldset>
|
|
`;
|
|
}
|
|
|
|
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>`;
|
|
}
|
|
|
|
const state = appStore.getState();
|
|
|
|
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>` : ""}
|
|
<button class="danger-button compact-button" type="button" data-delete-issue-attachment="${attachment.id}" ${state.isUpdatingIssue ? "disabled" : ""}>Delete</button>
|
|
</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" | "issue-attachment"
|
|
): string {
|
|
if (!screenshots.length) {
|
|
return `<span class="muted">No screenshots captured.</span>`;
|
|
}
|
|
|
|
const attribute =
|
|
deleteAction === "issue"
|
|
? "data-delete-screenshot"
|
|
: deleteAction === "comment"
|
|
? "data-delete-comment-screenshot"
|
|
: "data-delete-issue-asset-screenshot";
|
|
const previewTarget = deleteAction;
|
|
|
|
return `
|
|
<div class="screenshot-list">
|
|
${screenshots
|
|
.map(
|
|
(screenshot) => `
|
|
<figure class="screenshot-item">
|
|
<button class="screenshot-preview-button" type="button" data-preview-screenshot="${screenshot.id}" data-preview-target="${previewTarget}">
|
|
<img src="${screenshot.dataUrl}" alt="${escapeHtml(screenshot.name)}" />
|
|
</button>
|
|
<figcaption>${escapeHtml(screenshot.name)}</figcaption>
|
|
<button class="danger-button compact-button" type="button" ${attribute}="${screenshot.id}">Delete</button>
|
|
</figure>
|
|
`
|
|
)
|
|
.join("")}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function getPreviewScreenshot(): ScreenshotAttachment | null {
|
|
const state = appStore.getState();
|
|
const preview = state.screenshotPreview;
|
|
|
|
if (!preview) {
|
|
return null;
|
|
}
|
|
|
|
const screenshots =
|
|
preview.target === "issue"
|
|
? state.issueDraft.screenshots
|
|
: preview.target === "comment"
|
|
? state.issueCommentDraft.screenshots
|
|
: state.issueAttachmentDraft.screenshots;
|
|
return screenshots.find((screenshot) => screenshot.id === preview.id) ?? null;
|
|
}
|
|
|
|
function renderScreenshotPreview(): string {
|
|
const state = appStore.getState();
|
|
const preview = state.screenshotPreview;
|
|
const screenshot = getPreviewScreenshot();
|
|
|
|
if (!preview || !screenshot) {
|
|
return "";
|
|
}
|
|
|
|
const crop = preview.crop;
|
|
|
|
return `
|
|
<div class="screenshot-modal" role="dialog" aria-modal="true" aria-label="Screenshot preview">
|
|
<div class="screenshot-modal-panel">
|
|
<div class="screenshot-modal-toolbar">
|
|
<span>${escapeHtml(screenshot.name)}</span>
|
|
<button class="secondary-button compact-button" type="button" data-close-screenshot-preview>Close</button>
|
|
</div>
|
|
<div class="crop-frame">
|
|
<img src="${screenshot.dataUrl}" alt="${escapeHtml(screenshot.name)}" />
|
|
<button class="crop-box" type="button" data-crop-drag style="left:${crop.left}%; top:${crop.top}%; width:${crop.width}%; height:${crop.height}%">
|
|
${(["nw", "n", "ne", "e", "se", "s", "sw", "w"] as CropHandle[])
|
|
.map((handle) => `<span class="crop-handle crop-handle-${handle}" data-crop-handle="${handle}"></span>`)
|
|
.join("")}
|
|
</button>
|
|
</div>
|
|
<div class="modal-actions">
|
|
<button class="primary-button" type="button" data-apply-screenshot-crop>Crop</button>
|
|
<button class="danger-button" type="button" data-remove-preview-screenshot>Remove</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function renderIssueRows(issues: OpenIssue[], selectedIssueNumber: number | null, selectedRepoFullName: string): string {
|
|
const state = appStore.getState();
|
|
|
|
if (!issues.length) {
|
|
return `<div class="empty-state">No issues found.</div>`;
|
|
}
|
|
|
|
return `
|
|
<div class="issue-list">
|
|
${issues
|
|
.map(
|
|
(issue) => `
|
|
<div class="issue-row" data-selected="${issue.number === selectedIssueNumber && issue.repoFullName === selectedRepoFullName ? "true" : "false"}">
|
|
<button class="issue-select-button open-issue-select-button" type="button" data-select-issue="${issue.number}" data-issue-repo="${escapeHtml(issue.repoFullName)}">
|
|
<span class="issue-number">#${issue.number}</span>
|
|
<span class="issue-title">${escapeHtml(issue.title)}</span>
|
|
<span class="issue-repo">${escapeHtml(issue.repoFullName)}</span>
|
|
<span class="issue-state">${escapeHtml(issue.state)}</span>
|
|
<span class="issue-meta">${formatDate(issue.createdAt)}</span>
|
|
</button>
|
|
<button class="danger-button compact-button" type="button" data-delete-issue="${issue.number}" data-issue-repo="${escapeHtml(issue.repoFullName)}" ${state.isUpdatingIssue ? "disabled" : ""}>Delete</button>
|
|
</div>
|
|
`
|
|
)
|
|
.join("")}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function renderIssueCommentForm(issue: GiteaIssueSummary): string {
|
|
const state = appStore.getState();
|
|
const draft = state.issueCommentDraft;
|
|
const canSubmit =
|
|
Boolean(draft.content.trim() || draft.screenshots.length || draft.files.length) && !state.isCreatingIssueComment;
|
|
|
|
return `
|
|
<article class="item-card issue-comment-card">
|
|
<div class="item-heading">
|
|
<a class="item-title" href="${issue.webUrl}" target="_blank" rel="noreferrer">#${issue.number} ${escapeHtml(issue.title)}</a>
|
|
<span class="issue-state">${escapeHtml(issue.state)}</span>
|
|
</div>
|
|
|
|
<div class="form-stack">
|
|
<label>
|
|
<span>Comment</span>
|
|
<textarea id="issue-comment-content" rows="6" placeholder="Add a comment">${escapeHtml(draft.content)}</textarea>
|
|
</label>
|
|
|
|
<section class="attachment-section">
|
|
<div class="attachment-heading">
|
|
<span>Screenshots</span>
|
|
<button id="capture-comment-screenshot" class="secondary-button" type="button" ${state.isCapturingIssueCommentScreenshot ? "disabled" : ""}>
|
|
${state.isCapturingIssueCommentScreenshot ? "Taking..." : "Take snapshot"}
|
|
</button>
|
|
</div>
|
|
${renderScreenshotList(draft.screenshots, "comment")}
|
|
</section>
|
|
|
|
<label>
|
|
<span>File attachments</span>
|
|
<input id="issue-comment-files" type="file" multiple />
|
|
</label>
|
|
${renderFileList(draft.files, "comment")}
|
|
|
|
<button id="submit-issue-comment" class="primary-button" type="button" ${canSubmit ? "" : "disabled"}>
|
|
${state.isCreatingIssueComment ? "Commenting..." : "Comment"}
|
|
</button>
|
|
</div>
|
|
</article>
|
|
`;
|
|
}
|
|
|
|
function renderIssueAttachmentForm(): string {
|
|
const state = appStore.getState();
|
|
const draft = state.issueAttachmentDraft;
|
|
const canUpload = Boolean(draft.screenshots.length || draft.files.length) && !state.isUploadingIssueAttachments;
|
|
|
|
return `
|
|
<section class="attachment-section issue-asset-uploader">
|
|
<div class="attachment-heading">
|
|
<span>Add attachments</span>
|
|
<button id="capture-issue-asset-screenshot" class="secondary-button compact-button" type="button" ${state.isCapturingIssueAttachmentScreenshot ? "disabled" : ""}>
|
|
${state.isCapturingIssueAttachmentScreenshot ? "Taking..." : "Take snapshot"}
|
|
</button>
|
|
</div>
|
|
${renderScreenshotList(draft.screenshots, "issue-attachment")}
|
|
<label>
|
|
<span>File attachments</span>
|
|
<input id="issue-asset-files" type="file" multiple />
|
|
</label>
|
|
${renderFileList(draft.files, "issue-attachment")}
|
|
<button id="upload-issue-assets" class="primary-button" type="button" ${canUpload ? "" : "disabled"}>
|
|
${state.isUploadingIssueAttachments ? "Uploading..." : "Upload attachments"}
|
|
</button>
|
|
</section>
|
|
`;
|
|
}
|
|
|
|
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>`;
|
|
}
|
|
|
|
const draft = state.selectedIssueEditDraft;
|
|
const detailLabels = ISSUE_LABEL_OPTIONS.filter((label) =>
|
|
detail.issue.labels.some((issueLabel) => issueLabel.toLowerCase() === label.toLowerCase())
|
|
);
|
|
const hasChanges =
|
|
draft.subject !== detail.issue.title ||
|
|
draft.content !== detail.issue.body ||
|
|
draft.labels.join("|") !== detailLabels.join("|");
|
|
|
|
return `
|
|
<article class="item-card issue-detail-card">
|
|
<div class="item-heading">
|
|
<a class="item-title" href="${detail.issue.webUrl}" target="_blank" rel="noreferrer">#${detail.issue.number} ${escapeHtml(detail.issue.title)}</a>
|
|
<div class="issue-detail-actions">
|
|
<a class="secondary-link" href="${detail.issue.webUrl}" target="_blank" rel="noreferrer">Open in Gitea</a>
|
|
<button id="send-selected-issue-to-codex" class="secondary-button compact-button" type="button">
|
|
<i data-lucide="send"></i>
|
|
<span>Send to Codex</span>
|
|
</button>
|
|
<button id="refresh-issue-detail" class="secondary-button compact-button icon-only-button" type="button" title="Refresh issue" aria-label="Refresh issue" ${state.isLoadingIssueDetail ? "disabled" : ""}>
|
|
<i data-lucide="refresh-cw"></i>
|
|
</button>
|
|
<button id="close-selected-issue" class="secondary-button compact-button icon-only-button" type="button" title="Close issue detail" aria-label="Close issue detail">
|
|
<i data-lucide="x"></i>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="form-stack">
|
|
<label>
|
|
<span>Subject</span>
|
|
<input id="selected-issue-subject" type="text" value="${escapeHtml(draft.subject)}" />
|
|
</label>
|
|
<label>
|
|
<span>Content</span>
|
|
<textarea id="selected-issue-content" rows="8">${escapeHtml(draft.content)}</textarea>
|
|
</label>
|
|
${renderLabelOptions(draft.labels, "edit")}
|
|
<button id="save-selected-issue" class="primary-button" type="button" ${hasChanges && draft.subject.trim() && !state.isUpdatingIssue ? "" : "disabled"}>
|
|
${state.isUpdatingIssue ? "Saving issue..." : "Save issue"}
|
|
</button>
|
|
</div>
|
|
|
|
<div class="section-heading inline-heading">
|
|
<span>Attachments</span>
|
|
</div>
|
|
${renderIssueAttachments(detail.attachments)}
|
|
${renderIssueAttachmentForm()}
|
|
|
|
<div class="section-heading inline-heading">
|
|
<span>Comments</span>
|
|
</div>
|
|
${renderIssueComments(detail.comments)}
|
|
</article>
|
|
`;
|
|
}
|
|
|
|
function renderOpenIssues(): string {
|
|
const state = appStore.getState();
|
|
|
|
return `
|
|
<article class="item-card dense-card">
|
|
<div class="issue-filter-row">
|
|
<label class="checkbox-row">
|
|
<input id="limit-open-issues" type="checkbox" ${state.openIssuesLimitToCurrentRepo ? "checked" : ""} />
|
|
<span>Only current repo</span>
|
|
</label>
|
|
${renderIssueStatusFilters(state.openIssuesStatusFilters)}
|
|
${renderIssueLabelFilter(state.openIssuesLabelFilter)}
|
|
<button id="refresh-target-issues" class="secondary-button compact-button icon-only-button" type="button" title="Refresh issues" aria-label="Refresh issues" ${state.isLoadingTargetIssues ? "disabled" : ""}>
|
|
<i data-lucide="refresh-cw"></i>
|
|
</button>
|
|
</div>
|
|
${
|
|
state.openIssuesLimitToCurrentRepo && !getActiveKnownUrl()?.giteaRepo
|
|
? `<div class="empty-state">Open a known URL linked to a Gitea repo, or turn off the current repo filter.</div>`
|
|
: state.isLoadingTargetIssues && !state.openIssues.length
|
|
? `<div class="empty-state">Loading issues...</div>`
|
|
: renderIssueRows(state.openIssues, state.selectedIssueNumber, state.selectedIssueRepoFullName)
|
|
}
|
|
</article>
|
|
${
|
|
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
|
|
)
|
|
: ""
|
|
}`
|
|
: ""
|
|
}
|
|
`;
|
|
}
|
|
|
|
function renderAddIssue(): string {
|
|
const state = appStore.getState();
|
|
const knownUrl = getActiveKnownUrl();
|
|
const draft = state.issueDraft;
|
|
|
|
if (!knownUrl) {
|
|
return `<div class="empty-state">Open a known URL before creating an issue.</div>`;
|
|
}
|
|
|
|
if (!knownUrl.giteaRepo) {
|
|
return `<div class="empty-state">${escapeHtml(knownUrl.label)} is linked to a workspace but not a Gitea repo.</div>`;
|
|
}
|
|
|
|
const workspacePrompts = state.prompts.filter((prompt) => prompt.workspacePath === knownUrl.workspacePath);
|
|
|
|
return `
|
|
<article class="item-card issue-card">
|
|
<div class="target-summary">
|
|
<span class="target-label">Target repo</span>
|
|
<a class="item-title" href="${knownUrl.giteaRepo.webUrl}" target="_blank" rel="noreferrer">${escapeHtml(knownUrl.giteaRepo.fullName)}</a>
|
|
<span class="target-label">Active URL</span>
|
|
<span class="url-text">${escapeHtml(state.activeTabUrl || "Unknown active tab")}</span>
|
|
<span class="target-label">Known URL</span>
|
|
<span class="url-text">${escapeHtml(knownUrl.url)}</span>
|
|
<span class="path-text">${escapeHtml(knownUrl.workspacePath)}</span>
|
|
<span class="muted">Chrome profile: ${escapeHtml(CHROME_PROFILE_NAME)}</span>
|
|
</div>
|
|
|
|
<div class="form-stack">
|
|
<label>
|
|
<span>Subject</span>
|
|
<input id="issue-subject" type="text" value="${escapeHtml(draft.subject)}" placeholder="Issue subject" />
|
|
</label>
|
|
|
|
<label>
|
|
<span>Default prompt</span>
|
|
<select id="issue-prompt">
|
|
${renderIssuePromptOptions(workspacePrompts, draft.promptId)}
|
|
</select>
|
|
</label>
|
|
|
|
<label>
|
|
<span>Content</span>
|
|
<textarea id="issue-content" rows="8" placeholder="Describe the issue">${escapeHtml(draft.content)}</textarea>
|
|
</label>
|
|
|
|
<label>
|
|
<span>Agent file</span>
|
|
<select id="issue-agent">
|
|
${renderAgentOptions(state.agents, draft.agentId)}
|
|
</select>
|
|
</label>
|
|
|
|
${renderLabelOptions(draft.labels, "draft")}
|
|
|
|
<section class="attachment-section">
|
|
<div class="attachment-heading">
|
|
<span>Screenshots</span>
|
|
<button id="capture-screenshot" class="secondary-button" type="button" ${state.isCapturingScreenshot ? "disabled" : ""}>
|
|
${state.isCapturingScreenshot ? "Taking..." : "Take snapshot"}
|
|
</button>
|
|
</div>
|
|
${renderScreenshotList(draft.screenshots, "issue")}
|
|
</section>
|
|
|
|
<label>
|
|
<span>File attachments</span>
|
|
<input id="issue-files" type="file" multiple />
|
|
</label>
|
|
${renderFileList(draft.files, "issue")}
|
|
|
|
<button id="create-issue" class="primary-button" type="button" ${draft.subject.trim() && !state.isCreatingIssue ? "" : "disabled"}>
|
|
${state.isCreatingIssue ? "Creating issue..." : "Create issue"}
|
|
</button>
|
|
</div>
|
|
</article>
|
|
`;
|
|
}
|
|
|
|
function openUrl(url: string): void {
|
|
if (globalThis.chrome?.tabs?.create) {
|
|
void globalThis.chrome.tabs.create({ url });
|
|
return;
|
|
}
|
|
|
|
window.open(url, "_blank", "noopener,noreferrer");
|
|
}
|
|
|
|
function bindWorkspaceFolderResultActions(): void {
|
|
viewContent?.querySelectorAll<HTMLButtonElement>("[data-select-workspace-folder]").forEach((button) => {
|
|
button.addEventListener("click", () => {
|
|
const path = button.dataset.selectWorkspaceFolder;
|
|
const name = button.dataset.folderName;
|
|
|
|
if (path) {
|
|
appStore.getState().setWorkspaceDraft({
|
|
path,
|
|
name: appStore.getState().workspaceDraft.name || name || ""
|
|
});
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
function bindViewActions(): void {
|
|
viewContent?.querySelector<HTMLButtonElement>("#open-add-url-dialog")?.addEventListener("click", () => {
|
|
appStore.getState().openDialog("add-url");
|
|
});
|
|
|
|
viewContent?.querySelector<HTMLButtonElement>("#open-workspace-dialog")?.addEventListener("click", () => {
|
|
appStore.getState().openDialog("add-workspace");
|
|
});
|
|
|
|
viewContent?.querySelector<HTMLButtonElement>("[data-close-app-dialog]")?.addEventListener("click", () => {
|
|
appStore.getState().closeDialog();
|
|
});
|
|
|
|
viewContent?.querySelector<HTMLInputElement>("#agent-name")?.addEventListener("input", (event) => {
|
|
appStore.getState().setAgentDraft({
|
|
name: (event.target as HTMLInputElement).value
|
|
});
|
|
});
|
|
|
|
viewContent?.querySelector<HTMLInputElement>("#agent-file")?.addEventListener("input", (event) => {
|
|
appStore.getState().setAgentDraft({
|
|
file: (event.target as HTMLInputElement).value
|
|
});
|
|
});
|
|
|
|
viewContent?.querySelector<HTMLInputElement>("#agent-description")?.addEventListener("input", (event) => {
|
|
appStore.getState().setAgentDraft({
|
|
description: (event.target as HTMLInputElement).value
|
|
});
|
|
});
|
|
|
|
viewContent?.querySelector<HTMLTextAreaElement>("#agent-content")?.addEventListener("input", (event) => {
|
|
appStore.getState().setAgentDraft({
|
|
content: (event.target as HTMLTextAreaElement).value
|
|
});
|
|
});
|
|
|
|
viewContent?.querySelector<HTMLButtonElement>("#new-agent")?.addEventListener("click", () => {
|
|
appStore.getState().startNewAgent();
|
|
});
|
|
|
|
viewContent?.querySelector<HTMLButtonElement>("#save-agent")?.addEventListener("click", () => {
|
|
void appStore.getState().saveAgent();
|
|
});
|
|
|
|
viewContent?.querySelectorAll<HTMLButtonElement>("[data-edit-agent]").forEach((button) => {
|
|
button.addEventListener("click", () => {
|
|
const id = button.dataset.editAgent;
|
|
if (id) {
|
|
appStore.getState().editAgent(id);
|
|
}
|
|
});
|
|
});
|
|
|
|
viewContent?.querySelectorAll<HTMLButtonElement>("[data-delete-agent]").forEach((button) => {
|
|
button.addEventListener("click", () => {
|
|
const id = button.dataset.deleteAgent;
|
|
if (id && window.confirm("Delete this agent?")) {
|
|
void appStore.getState().deleteAgent(id);
|
|
}
|
|
});
|
|
});
|
|
|
|
viewContent?.querySelector<HTMLInputElement>("#workspace-name")?.addEventListener("input", (event) => {
|
|
appStore.getState().setWorkspaceDraft({
|
|
name: (event.target as HTMLInputElement).value
|
|
});
|
|
});
|
|
|
|
viewContent?.querySelector<HTMLInputElement>("#workspace-search")?.addEventListener("input", (event) => {
|
|
appStore.getState().setWorkspaceDraft({
|
|
search: (event.target as HTMLInputElement).value
|
|
});
|
|
});
|
|
|
|
bindWorkspaceFolderResultActions();
|
|
|
|
viewContent?.querySelector<HTMLButtonElement>("#save-workspace")?.addEventListener("click", () => {
|
|
void appStore.getState().saveWorkspace();
|
|
});
|
|
|
|
viewContent?.querySelectorAll<HTMLButtonElement>("[data-delete-user-workspace]").forEach((button) => {
|
|
button.addEventListener("click", () => {
|
|
const path = button.dataset.deleteUserWorkspace;
|
|
if (path && window.confirm("Delete this user-added workspace?")) {
|
|
void appStore.getState().deleteUserWorkspace(path);
|
|
}
|
|
});
|
|
});
|
|
|
|
viewContent?.querySelector<HTMLInputElement>("#prompt-name")?.addEventListener("input", (event) => {
|
|
appStore.getState().setPromptDraft({
|
|
name: (event.target as HTMLInputElement).value
|
|
});
|
|
});
|
|
|
|
viewContent?.querySelector<HTMLSelectElement>("#prompt-workspace")?.addEventListener("change", (event) => {
|
|
appStore.getState().setPromptDraft({
|
|
workspacePath: (event.target as HTMLSelectElement).value
|
|
});
|
|
});
|
|
|
|
viewContent?.querySelector<HTMLTextAreaElement>("#prompt-content")?.addEventListener("input", (event) => {
|
|
appStore.getState().setPromptDraft({
|
|
content: (event.target as HTMLTextAreaElement).value
|
|
});
|
|
});
|
|
|
|
viewContent?.querySelector<HTMLButtonElement>("#new-prompt")?.addEventListener("click", () => {
|
|
appStore.getState().startNewPrompt();
|
|
});
|
|
|
|
viewContent?.querySelector<HTMLButtonElement>("#save-prompt")?.addEventListener("click", () => {
|
|
void appStore.getState().savePrompt();
|
|
});
|
|
|
|
viewContent?.querySelectorAll<HTMLButtonElement>("[data-edit-prompt]").forEach((button) => {
|
|
button.addEventListener("click", () => {
|
|
const id = button.dataset.editPrompt;
|
|
if (id) {
|
|
appStore.getState().editPrompt(id);
|
|
}
|
|
});
|
|
});
|
|
|
|
viewContent?.querySelectorAll<HTMLButtonElement>("[data-delete-prompt]").forEach((button) => {
|
|
button.addEventListener("click", () => {
|
|
const id = button.dataset.deletePrompt;
|
|
if (id && window.confirm("Delete this prompt?")) {
|
|
void appStore.getState().deletePrompt(id);
|
|
}
|
|
});
|
|
});
|
|
|
|
viewContent?.querySelectorAll<HTMLButtonElement>("[data-open-url]").forEach((button) => {
|
|
button.addEventListener("click", () => {
|
|
const url = button.dataset.openUrl;
|
|
if (url) {
|
|
openUrl(url);
|
|
}
|
|
});
|
|
});
|
|
|
|
viewContent?.querySelectorAll<HTMLButtonElement>("[data-delete-url]").forEach((button) => {
|
|
button.addEventListener("click", () => {
|
|
const url = button.dataset.deleteUrl;
|
|
if (url) {
|
|
void appStore.getState().deleteUrlLink(url);
|
|
}
|
|
});
|
|
});
|
|
|
|
viewContent?.querySelectorAll<HTMLInputElement>("input[name='add-url-mode']").forEach((input) => {
|
|
input.addEventListener("change", () => {
|
|
appStore.getState().setAddUrlDraft({
|
|
mode: input.value as AddUrlMode,
|
|
selectedValue: ""
|
|
});
|
|
});
|
|
});
|
|
|
|
viewContent?.querySelector<HTMLSelectElement>("#add-url-selection")?.addEventListener("change", (event) => {
|
|
appStore.getState().setAddUrlDraft({
|
|
selectedValue: (event.target as HTMLSelectElement).value
|
|
});
|
|
});
|
|
|
|
viewContent?.querySelector<HTMLButtonElement>("#save-url-link")?.addEventListener("click", () => {
|
|
void appStore.getState().saveCurrentUrlLink();
|
|
});
|
|
|
|
viewContent?.querySelector<HTMLInputElement>("#limit-open-issues")?.addEventListener("change", (event) => {
|
|
appStore.getState().setOpenIssuesLimit((event.target as HTMLInputElement).checked);
|
|
});
|
|
|
|
viewContent?.querySelectorAll<HTMLInputElement>("[data-issues-status-filter]").forEach((input) => {
|
|
input.addEventListener("change", () => {
|
|
const statusFilters = Array.from(
|
|
viewContent.querySelectorAll<HTMLInputElement>("[data-issues-status-filter]:checked")
|
|
).map((checkedInput) => checkedInput.value as OpenIssuesStatusFilter);
|
|
|
|
appStore.getState().setOpenIssuesStatusFilters(statusFilters);
|
|
});
|
|
});
|
|
|
|
viewContent?.querySelector<HTMLSelectElement>("#issues-label-filter")?.addEventListener("change", (event) => {
|
|
appStore.getState().setOpenIssuesLabelFilter((event.target as HTMLSelectElement).value as IssueLabelOption | "");
|
|
});
|
|
|
|
viewContent?.querySelector<HTMLInputElement>("#issue-subject")?.addEventListener("input", (event) => {
|
|
appStore.getState().setIssueDraft({
|
|
subject: (event.target as HTMLInputElement).value
|
|
});
|
|
});
|
|
|
|
viewContent?.querySelector<HTMLTextAreaElement>("#issue-content")?.addEventListener("input", (event) => {
|
|
appStore.getState().setIssueDraft({
|
|
content: (event.target as HTMLTextAreaElement).value
|
|
});
|
|
});
|
|
|
|
viewContent?.querySelector<HTMLSelectElement>("#issue-prompt")?.addEventListener("change", (event) => {
|
|
appStore.getState().setIssuePrompt((event.target as HTMLSelectElement).value);
|
|
});
|
|
|
|
viewContent?.querySelector<HTMLSelectElement>("#issue-agent")?.addEventListener("change", (event) => {
|
|
appStore.getState().setIssueDraft({
|
|
agentId: (event.target as HTMLSelectElement).value
|
|
});
|
|
});
|
|
|
|
viewContent?.querySelector<HTMLInputElement>("#issue-files")?.addEventListener("change", (event) => {
|
|
const input = event.target as HTMLInputElement;
|
|
appStore.getState().setIssueFiles(Array.from(input.files ?? []));
|
|
input.value = "";
|
|
});
|
|
|
|
viewContent?.querySelector<HTMLButtonElement>("#capture-screenshot")?.addEventListener("click", () => {
|
|
void appStore.getState().captureScreenshot();
|
|
});
|
|
|
|
viewContent?.querySelectorAll<HTMLButtonElement>("[data-delete-screenshot]").forEach((button) => {
|
|
button.addEventListener("click", () => {
|
|
const id = button.dataset.deleteScreenshot;
|
|
if (id) {
|
|
appStore.getState().deleteIssueScreenshot(id);
|
|
}
|
|
});
|
|
});
|
|
|
|
viewContent?.querySelectorAll<HTMLButtonElement>("[data-delete-issue-file]").forEach((button) => {
|
|
button.addEventListener("click", () => {
|
|
const index = Number(button.dataset.deleteIssueFile);
|
|
if (Number.isInteger(index)) {
|
|
appStore.getState().deleteIssueFile(index);
|
|
}
|
|
});
|
|
});
|
|
|
|
viewContent?.querySelectorAll<HTMLButtonElement>("[data-preview-screenshot]").forEach((button) => {
|
|
button.addEventListener("click", () => {
|
|
const id = button.dataset.previewScreenshot;
|
|
const target = button.dataset.previewTarget;
|
|
if (id && (target === "issue" || target === "comment" || target === "issue-attachment")) {
|
|
appStore.getState().openScreenshotPreview(target, id);
|
|
}
|
|
});
|
|
});
|
|
|
|
viewContent?.querySelectorAll<HTMLButtonElement>("[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, repoFullName);
|
|
}
|
|
});
|
|
});
|
|
|
|
viewContent?.querySelectorAll<HTMLButtonElement>("[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, repoFullName);
|
|
}
|
|
});
|
|
});
|
|
|
|
viewContent?.querySelector<HTMLButtonElement>("#refresh-target-issues")?.addEventListener("click", () => {
|
|
void appStore.getState().loadOpenIssues(true);
|
|
});
|
|
|
|
viewContent?.querySelector<HTMLButtonElement>("#refresh-issue-detail")?.addEventListener("click", () => {
|
|
void appStore.getState().loadSelectedIssueDetail(true);
|
|
});
|
|
|
|
viewContent?.querySelector<HTMLButtonElement>("#close-selected-issue")?.addEventListener("click", () => {
|
|
appStore.getState().clearSelectedIssue();
|
|
});
|
|
|
|
viewContent?.querySelector<HTMLButtonElement>("#send-selected-issue-to-codex")?.addEventListener("click", () => {
|
|
const state = appStore.getState();
|
|
const selectedIssue = state.openIssues.find(
|
|
(issue) => issue.number === state.selectedIssueNumber && issue.repoFullName === state.selectedIssueRepoFullName
|
|
);
|
|
|
|
console.log("Send to Codex", {
|
|
issue: state.selectedIssueDetail?.issue ?? selectedIssue ?? null,
|
|
repoFullName: state.selectedIssueRepoFullName,
|
|
knownUrl: selectedIssue?.knownUrl ?? null,
|
|
attachments: state.selectedIssueDetail?.attachments ?? [],
|
|
comments: state.selectedIssueDetail?.comments ?? []
|
|
});
|
|
});
|
|
|
|
viewContent?.querySelector<HTMLInputElement>("#selected-issue-subject")?.addEventListener("input", (event) => {
|
|
appStore.getState().setSelectedIssueEditDraft({
|
|
subject: (event.target as HTMLInputElement).value
|
|
});
|
|
});
|
|
|
|
viewContent?.querySelector<HTMLTextAreaElement>("#selected-issue-content")?.addEventListener("input", (event) => {
|
|
appStore.getState().setSelectedIssueEditDraft({
|
|
content: (event.target as HTMLTextAreaElement).value
|
|
});
|
|
});
|
|
|
|
viewContent?.querySelector<HTMLButtonElement>("#save-selected-issue")?.addEventListener("click", () => {
|
|
void appStore.getState().saveSelectedIssueEdits();
|
|
});
|
|
|
|
viewContent?.querySelectorAll<HTMLInputElement>("[data-label-target]").forEach((input) => {
|
|
input.addEventListener("change", () => {
|
|
const target = input.dataset.labelTarget;
|
|
const labels = Array.from(
|
|
viewContent.querySelectorAll<HTMLInputElement>(`input[data-label-target='${target}']:checked`)
|
|
).map((checkedInput) => checkedInput.value as IssueLabelOption);
|
|
|
|
if (target === "draft") {
|
|
appStore.getState().setIssueDraft({ labels });
|
|
} else if (target === "edit") {
|
|
appStore.getState().setSelectedIssueEditDraft({ labels });
|
|
}
|
|
});
|
|
});
|
|
|
|
viewContent?.querySelectorAll<HTMLButtonElement>("[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<HTMLInputElement>("#issue-asset-files")?.addEventListener("change", (event) => {
|
|
const input = event.target as HTMLInputElement;
|
|
appStore.getState().setIssueAttachmentFiles(Array.from(input.files ?? []));
|
|
input.value = "";
|
|
});
|
|
|
|
viewContent?.querySelector<HTMLButtonElement>("#capture-issue-asset-screenshot")?.addEventListener("click", () => {
|
|
void appStore.getState().captureIssueAttachmentScreenshot();
|
|
});
|
|
|
|
viewContent?.querySelectorAll<HTMLButtonElement>("[data-delete-issue-asset-screenshot]").forEach((button) => {
|
|
button.addEventListener("click", () => {
|
|
const id = button.dataset.deleteIssueAssetScreenshot;
|
|
if (id) {
|
|
appStore.getState().deleteIssueAttachmentScreenshot(id);
|
|
}
|
|
});
|
|
});
|
|
|
|
viewContent?.querySelectorAll<HTMLButtonElement>("[data-delete-issue-asset-file]").forEach((button) => {
|
|
button.addEventListener("click", () => {
|
|
const index = Number(button.dataset.deleteIssueAssetFile);
|
|
if (Number.isInteger(index)) {
|
|
appStore.getState().deleteIssueAttachmentFile(index);
|
|
}
|
|
});
|
|
});
|
|
|
|
viewContent?.querySelector<HTMLButtonElement>("#upload-issue-assets")?.addEventListener("click", () => {
|
|
void appStore.getState().uploadSelectedIssueAttachments();
|
|
});
|
|
|
|
viewContent?.querySelector<HTMLTextAreaElement>("#issue-comment-content")?.addEventListener("input", (event) => {
|
|
appStore.getState().setIssueCommentDraft({
|
|
content: (event.target as HTMLTextAreaElement).value
|
|
});
|
|
});
|
|
|
|
viewContent?.querySelector<HTMLInputElement>("#issue-comment-files")?.addEventListener("change", (event) => {
|
|
const input = event.target as HTMLInputElement;
|
|
appStore.getState().setIssueCommentFiles(Array.from(input.files ?? []));
|
|
input.value = "";
|
|
});
|
|
|
|
viewContent?.querySelector<HTMLButtonElement>("#capture-comment-screenshot")?.addEventListener("click", () => {
|
|
void appStore.getState().captureIssueCommentScreenshot();
|
|
});
|
|
|
|
viewContent?.querySelectorAll<HTMLButtonElement>("[data-delete-comment-screenshot]").forEach((button) => {
|
|
button.addEventListener("click", () => {
|
|
const id = button.dataset.deleteCommentScreenshot;
|
|
if (id) {
|
|
appStore.getState().deleteIssueCommentScreenshot(id);
|
|
}
|
|
});
|
|
});
|
|
|
|
viewContent?.querySelectorAll<HTMLButtonElement>("[data-delete-comment-file]").forEach((button) => {
|
|
button.addEventListener("click", () => {
|
|
const index = Number(button.dataset.deleteCommentFile);
|
|
if (Number.isInteger(index)) {
|
|
appStore.getState().deleteIssueCommentFile(index);
|
|
}
|
|
});
|
|
});
|
|
|
|
bindCropInteractions();
|
|
|
|
viewContent?.querySelector<HTMLButtonElement>("[data-close-screenshot-preview]")?.addEventListener("click", () => {
|
|
appStore.getState().closeScreenshotPreview();
|
|
});
|
|
|
|
viewContent?.querySelector<HTMLButtonElement>("[data-remove-preview-screenshot]")?.addEventListener("click", () => {
|
|
appStore.getState().removePreviewScreenshot();
|
|
});
|
|
|
|
viewContent?.querySelector<HTMLButtonElement>("[data-apply-screenshot-crop]")?.addEventListener("click", () => {
|
|
void appStore.getState().applyScreenshotCrop();
|
|
});
|
|
|
|
viewContent?.querySelector<HTMLButtonElement>("#submit-issue-comment")?.addEventListener("click", () => {
|
|
void appStore.getState().createIssueComment();
|
|
});
|
|
|
|
viewContent?.querySelector<HTMLButtonElement>("#create-issue")?.addEventListener("click", () => {
|
|
void appStore.getState().createIssue();
|
|
});
|
|
}
|
|
|
|
function bindCropInteractions(): void {
|
|
const frame = viewContent?.querySelector<HTMLElement>(".crop-frame");
|
|
const cropBox = viewContent?.querySelector<HTMLButtonElement>("[data-crop-drag]");
|
|
|
|
if (!frame || !cropBox) {
|
|
return;
|
|
}
|
|
|
|
cropBox.addEventListener("pointerdown", (event) => {
|
|
const state = appStore.getState();
|
|
const preview = state.screenshotPreview;
|
|
|
|
if (!preview) {
|
|
return;
|
|
}
|
|
|
|
event.preventDefault();
|
|
cropBox.setPointerCapture(event.pointerId);
|
|
|
|
const handle = (event.target as HTMLElement).dataset.cropHandle as CropHandle | undefined;
|
|
const frameRect = frame.getBoundingClientRect();
|
|
const startX = event.clientX;
|
|
const startY = event.clientY;
|
|
const startCrop = { ...preview.crop };
|
|
|
|
const updateCrop = (moveEvent: PointerEvent): void => {
|
|
const deltaX = ((moveEvent.clientX - startX) / frameRect.width) * 100;
|
|
const deltaY = ((moveEvent.clientY - startY) / frameRect.height) * 100;
|
|
|
|
if (!handle) {
|
|
appStore.getState().setScreenshotPreviewCrop({
|
|
left: startCrop.left + deltaX,
|
|
top: startCrop.top + deltaY
|
|
});
|
|
return;
|
|
}
|
|
|
|
const nextCrop = { ...startCrop };
|
|
|
|
if (handle.includes("e")) {
|
|
nextCrop.width = startCrop.width + deltaX;
|
|
}
|
|
|
|
if (handle.includes("s")) {
|
|
nextCrop.height = startCrop.height + deltaY;
|
|
}
|
|
|
|
if (handle.includes("w")) {
|
|
nextCrop.left = startCrop.left + deltaX;
|
|
nextCrop.width = startCrop.width - deltaX;
|
|
}
|
|
|
|
if (handle.includes("n")) {
|
|
nextCrop.top = startCrop.top + deltaY;
|
|
nextCrop.height = startCrop.height - deltaY;
|
|
}
|
|
|
|
appStore.getState().setScreenshotPreviewCrop(nextCrop);
|
|
};
|
|
|
|
const stopCrop = (): void => {
|
|
window.removeEventListener("pointermove", updateCrop);
|
|
window.removeEventListener("pointerup", stopCrop);
|
|
window.removeEventListener("pointercancel", stopCrop);
|
|
};
|
|
|
|
window.addEventListener("pointermove", updateCrop);
|
|
window.addEventListener("pointerup", stopCrop);
|
|
window.addEventListener("pointercancel", stopCrop);
|
|
});
|
|
}
|
|
|
|
function renderView(): void {
|
|
if (!viewHeader || !viewContent) {
|
|
return;
|
|
}
|
|
|
|
const state = appStore.getState();
|
|
const context = state.localContext;
|
|
const titles: Record<AppView, string> = {
|
|
"add-issue": "Add issue",
|
|
"add-url": "Add URL",
|
|
agents: "Agents",
|
|
"open-issues": "Issues",
|
|
prompts: "Prompts",
|
|
urls: "URLs",
|
|
repos: "Gitea repos",
|
|
workspaces: "Workspaces"
|
|
};
|
|
|
|
const descriptions: Record<AppView, string> = {
|
|
"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.",
|
|
prompts: "Workspace-scoped prompt templates for issue content.",
|
|
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"}.`
|
|
};
|
|
|
|
viewHeader.hidden = state.activeView === "add-issue";
|
|
viewHeader.innerHTML =
|
|
state.activeView === "add-issue"
|
|
? ""
|
|
: `
|
|
<div>
|
|
<h2>${titles[state.activeView]}</h2>
|
|
<p>${escapeHtml(descriptions[state.activeView])}</p>
|
|
</div>
|
|
`;
|
|
|
|
if (state.activeView === "repos") {
|
|
viewContent.innerHTML = renderRepos(state.repos);
|
|
} else if (state.activeView === "workspaces") {
|
|
viewContent.innerHTML = renderWorkspaces(getAllWorkspaces(state));
|
|
} else if (state.activeView === "prompts") {
|
|
viewContent.innerHTML = renderPrompts(state.prompts);
|
|
} 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") {
|
|
viewContent.innerHTML = renderAddUrl();
|
|
} else {
|
|
viewContent.innerHTML = renderUrls(allUrls());
|
|
}
|
|
|
|
viewContent.insertAdjacentHTML("beforeend", renderActiveDialog());
|
|
viewContent.insertAdjacentHTML("beforeend", renderScreenshotPreview());
|
|
hydrateIcons();
|
|
bindViewActions();
|
|
}
|
|
|
|
function renderChrome(): void {
|
|
const state = appStore.getState();
|
|
|
|
if (status) {
|
|
status.textContent = state.statusMessage;
|
|
status.dataset.tone = state.statusTone;
|
|
}
|
|
|
|
if (refreshData) {
|
|
refreshData.disabled = state.isLoadingData;
|
|
}
|
|
|
|
if (addIssueButton) {
|
|
addIssueButton.disabled = !canAddIssue();
|
|
}
|
|
|
|
if (manageViewSelect) {
|
|
manageViewSelect.value = ["agents", "prompts", "workspaces"].includes(state.activeView) ? state.activeView : "";
|
|
}
|
|
|
|
viewButtons.forEach((button) => {
|
|
const view = button.dataset.view;
|
|
button.dataset.active = view === state.activeView ? "true" : "false";
|
|
});
|
|
}
|
|
|
|
function shouldRenderView(): boolean {
|
|
const activeElement = document.activeElement;
|
|
|
|
if (!activeElement || !viewContent?.contains(activeElement)) {
|
|
return true;
|
|
}
|
|
|
|
return !activeElement.matches(
|
|
"#issue-subject, #issue-content, #issue-agent, #selected-issue-subject, #selected-issue-content, #issue-comment-content, #agent-name, #agent-file, #agent-description, #agent-content, #prompt-name, #prompt-content, #workspace-name, #workspace-search, [data-label-target]"
|
|
);
|
|
}
|
|
|
|
function syncFocusedFormControls(): void {
|
|
const state = appStore.getState();
|
|
const createIssue = viewContent?.querySelector<HTMLButtonElement>("#create-issue");
|
|
const saveSelectedIssue = viewContent?.querySelector<HTMLButtonElement>("#save-selected-issue");
|
|
const submitComment = viewContent?.querySelector<HTMLButtonElement>("#submit-issue-comment");
|
|
const saveWorkspace = viewContent?.querySelector<HTMLButtonElement>("#save-workspace");
|
|
const workspaceResults = viewContent?.querySelector<HTMLElement>("#workspace-folder-results");
|
|
const workspacePath = viewContent?.querySelector<HTMLInputElement>("#workspace-path");
|
|
const workspaceName = viewContent?.querySelector<HTMLInputElement>("#workspace-name");
|
|
|
|
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;
|
|
}
|
|
|
|
if (saveWorkspace) {
|
|
saveWorkspace.disabled = !state.workspaceDraft.path.trim();
|
|
}
|
|
|
|
if (workspaceResults) {
|
|
workspaceResults.innerHTML = renderWorkspaceSearchResults(workspaceFolderCandidates(), state.workspaceDraft.search);
|
|
bindWorkspaceFolderResultActions();
|
|
}
|
|
|
|
if (workspacePath) {
|
|
workspacePath.value = state.workspaceDraft.path;
|
|
}
|
|
|
|
if (workspaceName && document.activeElement !== workspaceName) {
|
|
workspaceName.value = state.workspaceDraft.name;
|
|
}
|
|
|
|
if (saveSelectedIssue && state.selectedIssueDetail) {
|
|
const detailLabels = ISSUE_LABEL_OPTIONS.filter((label) =>
|
|
state.selectedIssueDetail?.issue.labels.some((issueLabel) => issueLabel.toLowerCase() === label.toLowerCase())
|
|
);
|
|
saveSelectedIssue.disabled =
|
|
!state.selectedIssueEditDraft.subject.trim() ||
|
|
state.isUpdatingIssue ||
|
|
(state.selectedIssueEditDraft.subject === state.selectedIssueDetail.issue.title &&
|
|
state.selectedIssueEditDraft.content === state.selectedIssueDetail.issue.body &&
|
|
state.selectedIssueEditDraft.labels.join("|") === detailLabels.join("|"));
|
|
}
|
|
}
|
|
|
|
function render(): void {
|
|
renderChrome();
|
|
if (shouldRenderView()) {
|
|
renderView();
|
|
} else {
|
|
syncFocusedFormControls();
|
|
}
|
|
ensureOpenIssuesLoaded();
|
|
ensureSelectedIssueDetailLoaded();
|
|
}
|
|
|
|
function ensureOpenIssuesLoaded(): void {
|
|
const state = appStore.getState();
|
|
const scopeKey = state.openIssuesLimitToCurrentRepo
|
|
? `current:${getActiveKnownUrl()?.giteaRepo?.fullName ?? "none"}:${state.openIssuesStatusFilters.join(",")}:${state.openIssuesLabelFilter || "all-labels"}`
|
|
: `all-repos:${state.openIssuesStatusFilters.join(",")}:${state.openIssuesLabelFilter || "all-labels"}`;
|
|
|
|
if (
|
|
state.activeView === "open-issues" &&
|
|
state.openIssuesScopeKey !== scopeKey &&
|
|
!state.isLoadingTargetIssues
|
|
) {
|
|
void appStore.getState().loadOpenIssues();
|
|
}
|
|
}
|
|
|
|
function ensureSelectedIssueDetailLoaded(): void {
|
|
const state = appStore.getState();
|
|
|
|
if (
|
|
state.activeView === "open-issues" &&
|
|
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;
|
|
if (view) {
|
|
appStore.getState().setActiveView(view);
|
|
}
|
|
});
|
|
});
|
|
|
|
manageViewSelect?.addEventListener("change", () => {
|
|
const view = manageViewSelect.value as AppView;
|
|
if (view === "agents" || view === "prompts" || view === "workspaces") {
|
|
appStore.getState().setActiveView(view);
|
|
}
|
|
});
|
|
|
|
refreshData?.addEventListener("click", () => {
|
|
void appStore.getState().loadData();
|
|
});
|
|
|
|
function setupActiveTabTracking(): void {
|
|
let refreshTimer: number | undefined;
|
|
const scheduleRefresh = (): void => {
|
|
if (refreshTimer !== undefined) {
|
|
window.clearTimeout(refreshTimer);
|
|
}
|
|
|
|
refreshTimer = window.setTimeout(() => {
|
|
refreshTimer = undefined;
|
|
void appStore.getState().refreshActiveTab();
|
|
}, 80);
|
|
};
|
|
|
|
globalThis.chrome?.tabs?.onActivated?.addListener(scheduleRefresh);
|
|
globalThis.chrome?.tabs?.onUpdated?.addListener((_tabId, changeInfo) => {
|
|
if (changeInfo.url || changeInfo.status === "complete") {
|
|
scheduleRefresh();
|
|
}
|
|
});
|
|
|
|
globalThis.chrome?.windows?.onFocusChanged?.addListener((windowId) => {
|
|
if (windowId !== chrome.windows.WINDOW_ID_NONE) {
|
|
scheduleRefresh();
|
|
}
|
|
});
|
|
|
|
document.addEventListener("visibilitychange", () => {
|
|
if (!document.hidden) {
|
|
scheduleRefresh();
|
|
}
|
|
});
|
|
|
|
window.addEventListener("focus", scheduleRefresh);
|
|
}
|
|
|
|
function setupLocalContextAutoRefresh(): void {
|
|
let isRefreshing = false;
|
|
window.setInterval(() => {
|
|
const state = appStore.getState();
|
|
const shouldRefresh =
|
|
!document.hidden &&
|
|
!state.isLoadingData &&
|
|
(state.activeView === "workspaces" ||
|
|
state.activeView === "prompts" ||
|
|
state.activeView === "urls" ||
|
|
state.activeDialog === "add-workspace" ||
|
|
state.activeDialog === "add-url");
|
|
|
|
if (!shouldRefresh || isRefreshing) {
|
|
return;
|
|
}
|
|
|
|
isRefreshing = true;
|
|
appStore.getState().refreshLocalContext().finally(() => {
|
|
isRefreshing = false;
|
|
});
|
|
}, 10000);
|
|
}
|
|
|
|
setupActiveTabTracking();
|
|
setupLocalContextAutoRefresh();
|
|
appStore.subscribe(render);
|
|
render();
|
|
void appStore.getState().loadData();
|