This commit is contained in:
@@ -36,14 +36,19 @@ Use `.env.local` for local secrets. Keep `.env.local` and any real token-bearing
|
||||
- `vite.config.ts` emits `dist/manifest.json` during production builds.
|
||||
- `dist/` is the deployable output directory. Deployment or Chrome load-unpacked flows should point directly at `dist/`.
|
||||
- `dist/` is generated output and should not be edited directly.
|
||||
- Static extension assets live in `public/`; the current favicon source is `public/favicon.svg`.
|
||||
- Static extension assets live in `public/`; extension icon sources live under `public/icons/`.
|
||||
- `scripts/generate-local-context.mjs` generates `public/local-context.json` from `/Users/Tabitha/.openclaw/workspace_Father` before builds.
|
||||
- `public/local-context.json` is a committed fallback snapshot so CI builds can still produce a self-contained `dist/` if the local workspace root is unavailable.
|
||||
- `npm run build` runs `scripts/verify-dist.mjs` after Vite to ensure the generated output has the required extension files.
|
||||
- Host permissions are derived from `VITE_GITEA_BASE_URL`. Rebuild after changing the base URL.
|
||||
- Runtime API calls are centralized in `src/api.ts`.
|
||||
- Popup state is centralized in the vanilla Zustand store at `src/store.ts`.
|
||||
- The popup UI is a movable drag/drop window with icon menu sections for URLs, Gitea repos, and workspaces. Add URL and Add Issue are intentionally disabled.
|
||||
- Side panel state is centralized in the vanilla Zustand store at `src/store.ts`.
|
||||
- The extension opens as a Chrome side panel. It is not a draggable in-page window.
|
||||
- The side panel has icon menu sections for Add URL, URLs, Gitea repos, Workspaces, and Add Issue.
|
||||
- Add URL is enabled only when the active tab's base URL is not already in the URLs list. The user must pick either a workspace or a Gitea repo; the app infers the linked counterpart from local context.
|
||||
- Add Issue is enabled only when the active tab's base URL is already in the URLs list and has a linked Gitea repo. The issue form must show the target repo, capture visible-tab screenshots, accept Gitea-friendly file attachments, and include `Chrome profile: Silma` in created issue bodies.
|
||||
- If the Ready checkbox is checked, create the issue first, upload screenshots/files as issue assets second, and apply the `ready` label last.
|
||||
- The extension requests `activeTab` so screenshots can be captured from the current Chrome tab.
|
||||
|
||||
## Chrome Extension Management
|
||||
|
||||
|
||||
@@ -22,11 +22,11 @@ Build the Chrome extension:
|
||||
npm run build
|
||||
```
|
||||
|
||||
The deployable extension output is `dist/`. Load `dist/` in Chrome as an unpacked extension, or point deployment tooling at that folder. `npm run build` runs TypeScript, Vite, and a post-build check that verifies `dist/manifest.json` and the popup entry exist.
|
||||
The deployable extension output is `dist/`. Load `dist/` in Chrome as an unpacked extension, or point deployment tooling at that folder. `npm run build` runs TypeScript, Vite, and a post-build check that verifies `dist/manifest.json`, the side panel entry, and the background service worker exist.
|
||||
|
||||
Static assets, including the favicon, live in `public/` and are copied into `dist/` during the build.
|
||||
Static assets, including the extension icon assets, live in `public/` and are copied into `dist/` during the build.
|
||||
|
||||
The app opens as a draggable window inside the extension popup. The top icon menu includes URLs, Gitea repos, and workspaces; Add URL and Add Issue are present but disabled for now.
|
||||
The app opens in the Chrome side panel. The top icon menu includes Add URL, URLs, Gitea repos, Workspaces, and Add Issue. Add URL is enabled only for an active tab base URL that is not already in the URLs list. Add Issue is enabled when the active tab base URL is already linked to a Gitea repo; it creates an issue, uploads screenshots and files as issue assets, and can apply the `ready` label after uploads.
|
||||
|
||||
Builds generate `public/local-context.json` from `/Users/Tabitha/.openclaw/workspace_Father`. That snapshot links known workspaces to local Gitea repos and is copied into `dist/` so the built extension is self-contained.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Extension Management
|
||||
|
||||
This project builds a Chrome extension from the generated `dist/` directory. Run `npm run build` before loading or reloading the unpacked extension.
|
||||
This project builds a Chrome side panel extension from the generated `dist/` directory. Run `npm run build` before loading or reloading the unpacked extension.
|
||||
|
||||
## Required Chrome Profile
|
||||
|
||||
@@ -13,7 +13,7 @@ This rule applies to:
|
||||
- Opening Chrome for extension work.
|
||||
- Loading `dist/` as an unpacked extension.
|
||||
- Reloading the extension after a build.
|
||||
- Inspecting extension popups, background/service worker behavior, or permissions.
|
||||
- Inspecting extension side panel, background/service worker behavior, or permissions.
|
||||
- Any browser automation that depends on extension state.
|
||||
|
||||
## Build Output
|
||||
@@ -35,6 +35,14 @@ Required variables:
|
||||
- `VITE_GITEA_REPO_OWNER`
|
||||
- `VITE_GITEA_REPO_NAME`
|
||||
|
||||
## Issue Creation
|
||||
|
||||
- Add Issue is enabled only when the active tab's base URL is already in the URLs list and that URL is linked to a Gitea repo.
|
||||
- The Add Issue view must show the Gitea repo that will receive the issue.
|
||||
- Screenshot capture uses Chrome's visible-tab capture API and requires the extension's `activeTab` permission.
|
||||
- Created issue bodies must include `Chrome profile: Silma`.
|
||||
- When Ready is checked, create the issue, upload screenshots/files as Gitea issue assets, then apply the `ready` label last.
|
||||
|
||||
## Agent Checklist
|
||||
|
||||
1. Run `npm run build`.
|
||||
|
||||
+5
-13
@@ -3,22 +3,14 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="icon" type="image/svg+xml" href="/icons/icon.svg" />
|
||||
<title>Silma AI Aide</title>
|
||||
</head>
|
||||
<body>
|
||||
<main class="desktop-canvas">
|
||||
<section id="app-window" class="app-window" aria-label="Silma AI Aide window">
|
||||
<header id="window-drag-handle" class="window-titlebar">
|
||||
<div>
|
||||
<p class="eyebrow">Silma</p>
|
||||
<h1>AI Aide</h1>
|
||||
</div>
|
||||
<span id="config-badge" class="badge">Checking</span>
|
||||
</header>
|
||||
|
||||
<main class="side-panel-shell">
|
||||
<section class="app-panel" aria-label="Silma AI Aide side panel">
|
||||
<nav class="toolbar" aria-label="Aide sections">
|
||||
<button class="tool-button" type="button" data-action="add-url" disabled title="Add URL">
|
||||
<button id="add-url" class="tool-button" type="button" data-view="add-url" disabled title="Add URL">
|
||||
<i data-lucide="link"></i>
|
||||
<span>Add URL</span>
|
||||
</button>
|
||||
@@ -34,7 +26,7 @@
|
||||
<i data-lucide="folder-git-2"></i>
|
||||
<span>Workspaces</span>
|
||||
</button>
|
||||
<button class="tool-button" type="button" data-action="add-issue" disabled title="Add issue">
|
||||
<button id="add-issue" class="tool-button" type="button" data-view="add-issue" disabled title="Add issue">
|
||||
<i data-lucide="circle-plus"></i>
|
||||
<span>Add Issue</span>
|
||||
</button>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# mp-silma-ai-aide
|
||||
|
||||
This project is a basic Vite + TypeScript Chrome extension scaffold. The extension popup reads Gitea configuration from Vite env variables, stores popup state in Zustand, and uses Axios to call the Gitea API.
|
||||
This project is a basic Vite + TypeScript Chrome extension scaffold. The extension side panel reads Gitea configuration from Vite env variables, stores side panel state in Zustand, and uses Axios to call the Gitea API.
|
||||
|
||||
Important files:
|
||||
|
||||
@@ -10,16 +10,16 @@ Important files:
|
||||
- `.env.example`: Required environment variables.
|
||||
- `vite.config.ts`: Vite build config and generated Chrome extension manifest logic.
|
||||
- `src/api.ts`: Axios client and Gitea configuration handling.
|
||||
- `src/store.ts`: Vanilla Zustand popup state store.
|
||||
- `src/main.ts`: Popup UI behavior.
|
||||
- `index.html`: Popup document.
|
||||
- `public/favicon.svg`: Source favicon copied into `dist/` by Vite.
|
||||
- `src/store.ts`: Vanilla Zustand side panel state store.
|
||||
- `src/main.ts`: Side panel UI behavior.
|
||||
- `index.html`: Side panel document.
|
||||
- `public/icons/`: Extension icon source and generated PNG sizes copied into `dist/` by Vite.
|
||||
- `public/local-context.json`: Generated workspace/repo/URL context copied into `dist/`.
|
||||
- `scripts/generate-local-context.mjs`: Scans `/Users/Tabitha/.openclaw/workspace_Father` and writes `public/local-context.json`.
|
||||
|
||||
Build with `npm run build`. The deployable output directory is `dist/`; load that folder in Chrome as an unpacked extension or point deployment tooling at it. Do not edit `dist/` directly.
|
||||
|
||||
UI behavior: the app renders as a movable drag/drop window inside the browser surface. The menu items are Add URL (disabled), URLs, Gitea Repos, Workspaces, and Add Issue (disabled). Gitea repos are ordered by latest default-branch commit. Workspace and URL views show established links between local workspaces, known URLs, and Gitea repos.
|
||||
UI behavior: the app renders as a Chrome side panel, not a draggable window. The menu items are Add URL, URLs, Gitea Repos, Workspaces, and Add Issue. Add URL is enabled only when the active tab base URL is not already known; the user must choose either a workspace or a Gitea repo, and the app infers the linked counterpart. Add Issue is enabled only when the active tab base URL is known and linked to a Gitea repo. It shows the target repo, captures visible-tab screenshots, accepts file attachments, creates the Gitea issue, uploads assets, then applies the `ready` label if requested. Created issue bodies include `Chrome profile: Silma`. Gitea repos are ordered by latest default-branch commit. Workspace and URL views show established links between local workspaces, known URLs, and Gitea repos.
|
||||
|
||||
Extension management rule: use the Codex Chrome extension with the Chrome profile named `Silma`. Do not use `Profile 1`.
|
||||
|
||||
|
||||
+17
-8
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"name": "mp-silma-ai-aide",
|
||||
"description": "A Vite and TypeScript Chrome extension popup for Gitea-backed Silma AI aide workflows.",
|
||||
"description": "A Vite and TypeScript Chrome extension side panel for Gitea-backed Silma AI aide workflows.",
|
||||
"project_type": "chrome-extension",
|
||||
"language": "typescript",
|
||||
"package_manager": "npm",
|
||||
@@ -11,23 +11,24 @@
|
||||
"description": "Build output is self-contained and can be loaded directly as an unpacked Chrome extension."
|
||||
},
|
||||
"entrypoints": {
|
||||
"popup_html": "index.html",
|
||||
"popup_script": "src/main.ts",
|
||||
"side_panel_html": "index.html",
|
||||
"side_panel_script": "src/main.ts",
|
||||
"background_service_worker": "src/background.ts",
|
||||
"state_store": "src/store.ts",
|
||||
"api_client": "src/api.ts",
|
||||
"local_context_types": "src/localContext.ts",
|
||||
"vite_config": "vite.config.ts",
|
||||
"favicon": "public/favicon.svg",
|
||||
"extension_icons": "public/icons/",
|
||||
"local_context": "public/local-context.json",
|
||||
"local_context_generator": "scripts/generate-local-context.mjs",
|
||||
"generated_manifest": "dist/manifest.json"
|
||||
},
|
||||
"ui": {
|
||||
"shell": "movable drag/drop window inside the browser surface",
|
||||
"shell": "Chrome side panel",
|
||||
"menu_items": [
|
||||
{
|
||||
"name": "Add URL",
|
||||
"enabled": false
|
||||
"enabled": "when active tab base URL is not already known"
|
||||
},
|
||||
{
|
||||
"name": "URLs",
|
||||
@@ -43,10 +44,12 @@
|
||||
},
|
||||
{
|
||||
"name": "Add Issue",
|
||||
"enabled": false
|
||||
"enabled": "when active tab base URL is known and linked to a Gitea repo"
|
||||
}
|
||||
],
|
||||
"repo_ordering": "Gitea repos are ordered by latest default-branch commit timestamp.",
|
||||
"add_url_behavior": "The user must pick either a workspace or a Gitea repo. The app infers the linked counterpart from local context and stores the URL link in chrome.storage.local.",
|
||||
"add_issue_behavior": "The form shows the target repo for the active known URL, captures visible-tab screenshots, accepts file attachments, creates a Gitea issue, uploads screenshots/files as issue assets, then applies the ready label after uploads when requested. Issue bodies include Chrome profile: Silma.",
|
||||
"known_urls": [
|
||||
{
|
||||
"url": "http://100.66.226.22:23030/",
|
||||
@@ -68,6 +71,12 @@
|
||||
"load_unpacked_path": "dist/",
|
||||
"instructions": "Use the Codex Chrome extension with the Chrome profile named Silma. Do not use Profile 1."
|
||||
},
|
||||
"extension_permissions": [
|
||||
"activeTab",
|
||||
"sidePanel",
|
||||
"storage",
|
||||
"tabs"
|
||||
],
|
||||
"commands": {
|
||||
"install": "npm ci",
|
||||
"codex_setup": "npm run codex:setup",
|
||||
@@ -92,7 +101,7 @@
|
||||
"name": "VITE_GITEA_TOKEN",
|
||||
"required": true,
|
||||
"secret": true,
|
||||
"description": "Gitea API token used by the popup client."
|
||||
"description": "Gitea API token used by the side panel client."
|
||||
},
|
||||
{
|
||||
"name": "VITE_GITEA_REPO_OWNER",
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "mp-silma-ai-aide",
|
||||
"version": "1.0.0",
|
||||
"description": "Chrome extension popup built with Vite for Silma AI aide workflows.",
|
||||
"description": "Chrome extension side panel built with Vite for Silma AI aide workflows.",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 3.9 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.7 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
@@ -0,0 +1,13 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" role="img" aria-label="Silma AI Aide extension icon">
|
||||
<defs>
|
||||
<linearGradient id="panel" x1="22" y1="10" x2="106" y2="118" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#102a43" />
|
||||
<stop offset="1" stop-color="#075985" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect x="10" y="10" width="108" height="108" rx="24" fill="url(#panel)" />
|
||||
<rect x="26" y="30" width="76" height="68" rx="10" fill="#f8fafc" />
|
||||
<path d="M38 80V48h18c8 0 13 5 13 12 0 5-3 9-8 11l12 9H60L50 72h-3v8h-9Zm9-16h8c3 0 5-2 5-5s-2-5-5-5h-8v10Z" fill="#102a43" />
|
||||
<circle cx="91" cy="38" r="14" fill="#7bc47f" />
|
||||
<path d="M89 31h4v14h-4zM82 37h18v4H82z" fill="#102a43" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 744 B |
+15
-5
@@ -15,16 +15,26 @@ if (!existsSync(distDir)) {
|
||||
fail("Missing dist/manifest.json. The built folder is not loadable as a Chrome extension.");
|
||||
} else {
|
||||
const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
|
||||
const popup = manifest.action?.default_popup;
|
||||
const extensionEntry = manifest.side_panel?.default_path || manifest.action?.default_popup;
|
||||
|
||||
if (manifest.manifest_version !== 3) {
|
||||
fail("dist/manifest.json must be a Manifest V3 extension manifest.");
|
||||
}
|
||||
|
||||
if (!popup) {
|
||||
fail("dist/manifest.json is missing action.default_popup.");
|
||||
} else if (!existsSync(join(distDir, popup))) {
|
||||
fail(`dist/${popup} does not exist.`);
|
||||
if (!extensionEntry) {
|
||||
fail("dist/manifest.json is missing side_panel.default_path or action.default_popup.");
|
||||
} else if (!existsSync(join(distDir, extensionEntry))) {
|
||||
fail(`dist/${extensionEntry} does not exist.`);
|
||||
}
|
||||
|
||||
if (manifest.background?.service_worker && !existsSync(join(distDir, manifest.background.service_worker))) {
|
||||
fail(`dist/${manifest.background.service_worker} does not exist.`);
|
||||
}
|
||||
|
||||
for (const [size, iconPath] of Object.entries(manifest.icons || {})) {
|
||||
if (!existsSync(join(distDir, iconPath))) {
|
||||
fail(`dist/${iconPath} for ${size}px icon does not exist.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+93
@@ -39,6 +39,21 @@ interface GiteaBranchResponse {
|
||||
};
|
||||
}
|
||||
|
||||
interface GiteaIssueResponse {
|
||||
number?: number;
|
||||
html_url?: string;
|
||||
}
|
||||
|
||||
interface GiteaLabelResponse {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface CreatedGiteaIssue {
|
||||
number: number;
|
||||
webUrl: string;
|
||||
}
|
||||
|
||||
export interface GiteaRepoSummary {
|
||||
name: string;
|
||||
fullName: string;
|
||||
@@ -91,6 +106,84 @@ export async function getCurrentGiteaUser(): Promise<GiteaUser> {
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function createGiteaIssue(owner: string, repo: string, title: string, body: string): Promise<CreatedGiteaIssue> {
|
||||
const client = createGiteaClient();
|
||||
const response = await client.post<GiteaIssueResponse>(`/repos/${owner}/${repo}/issues`, {
|
||||
title,
|
||||
body
|
||||
});
|
||||
|
||||
const number = response.data.number;
|
||||
if (!number) {
|
||||
throw new Error("Gitea did not return an issue number.");
|
||||
}
|
||||
|
||||
return {
|
||||
number,
|
||||
webUrl: response.data.html_url || `${giteaConfig.baseUrl.replace(/\/+$/, "")}/${owner}/${repo}/issues/${number}`
|
||||
};
|
||||
}
|
||||
|
||||
export async function uploadGiteaIssueAttachment(
|
||||
owner: string,
|
||||
repo: string,
|
||||
issueNumber: number,
|
||||
file: Blob,
|
||||
fileName: string
|
||||
): Promise<void> {
|
||||
const client = createGiteaClient();
|
||||
const formData = new FormData();
|
||||
formData.append("attachment", file, fileName);
|
||||
|
||||
await client.post(`/repos/${owner}/${repo}/issues/${issueNumber}/assets`, formData);
|
||||
}
|
||||
|
||||
async function getGiteaLabels(owner: string, repo: string): Promise<GiteaLabelResponse[]> {
|
||||
const client = createGiteaClient();
|
||||
const response = await client.get<GiteaLabelResponse[]>(`/repos/${owner}/${repo}/labels`, {
|
||||
params: {
|
||||
limit: 100
|
||||
}
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function ensureGiteaLabel(owner: string, repo: string, name: string, color = "0e8a16"): Promise<GiteaLabelResponse> {
|
||||
const existingLabels = await getGiteaLabels(owner, repo);
|
||||
const existing = existingLabels.find((label) => label.name.toLowerCase() === name.toLowerCase());
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const client = createGiteaClient();
|
||||
try {
|
||||
const response = await client.post<GiteaLabelResponse>(`/repos/${owner}/${repo}/labels`, {
|
||||
name,
|
||||
color
|
||||
});
|
||||
return response.data;
|
||||
} catch {
|
||||
const refreshedLabels = await getGiteaLabels(owner, repo);
|
||||
const refreshed = refreshedLabels.find((label) => label.name.toLowerCase() === name.toLowerCase());
|
||||
if (refreshed) {
|
||||
return refreshed;
|
||||
}
|
||||
throw new Error(`Could not create or find the ${name} label.`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function addGiteaIssueLabels(
|
||||
owner: string,
|
||||
repo: string,
|
||||
issueNumber: number,
|
||||
labelIds: number[]
|
||||
): Promise<void> {
|
||||
const client = createGiteaClient();
|
||||
await client.post(`/repos/${owner}/${repo}/issues/${issueNumber}/labels`, {
|
||||
labels: labelIds
|
||||
});
|
||||
}
|
||||
|
||||
export async function getReposOrderedByLastCommit(): Promise<GiteaRepoSummary[]> {
|
||||
const client = axios.create({
|
||||
baseURL: getApiBaseUrl(giteaConfig.baseUrl),
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
chrome.runtime.onInstalled.addListener(() => {
|
||||
void chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true });
|
||||
});
|
||||
|
||||
chrome.runtime.onStartup.addListener(() => {
|
||||
void chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true });
|
||||
});
|
||||
@@ -29,6 +29,7 @@ export interface KnownUrl {
|
||||
workspaceName: string;
|
||||
workspacePath: string;
|
||||
giteaRepo: LocalGiteaRepoLink | null;
|
||||
source?: "generated" | "user";
|
||||
}
|
||||
|
||||
export interface LocalContext {
|
||||
|
||||
+370
-144
@@ -1,14 +1,19 @@
|
||||
import { createIcons, CirclePlus, FolderGit2, GitBranch, Link, List, RefreshCw } from "lucide";
|
||||
import "./styles.css";
|
||||
import { appStore, type AppView } from "./store";
|
||||
import {
|
||||
appStore,
|
||||
CHROME_PROFILE_NAME,
|
||||
findKnownUrlForBase,
|
||||
type AddUrlMode,
|
||||
type AppView
|
||||
} from "./store";
|
||||
import type { GiteaRepoSummary } from "./api";
|
||||
import type { KnownUrl, LocalWorkspace } from "./localContext";
|
||||
import type { KnownUrl, LocalContext, LocalWorkspace } from "./localContext";
|
||||
|
||||
const appWindow = document.querySelector<HTMLElement>("#app-window");
|
||||
const dragHandle = document.querySelector<HTMLElement>("#window-drag-handle");
|
||||
const configBadge = document.querySelector<HTMLSpanElement>("#config-badge");
|
||||
const status = document.querySelector<HTMLElement>("#status");
|
||||
const refreshData = document.querySelector<HTMLButtonElement>("#refresh-data");
|
||||
const addUrlButton = document.querySelector<HTMLButtonElement>("#add-url");
|
||||
const addIssueButton = document.querySelector<HTMLButtonElement>("#add-issue");
|
||||
const viewHeader = document.querySelector<HTMLElement>("#view-header");
|
||||
const viewContent = document.querySelector<HTMLElement>("#view-content");
|
||||
const viewButtons = Array.from(document.querySelectorAll<HTMLButtonElement>("[data-view]"));
|
||||
@@ -43,8 +48,10 @@ function formatDate(value: string): string {
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short"
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit"
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
@@ -52,6 +59,23 @@ function formatSha(value: string): string {
|
||||
return value ? value.slice(0, 8) : "unknown";
|
||||
}
|
||||
|
||||
function allUrls(): KnownUrl[] {
|
||||
const state = appStore.getState();
|
||||
return [...(state.localContext?.knownUrls ?? []), ...state.userUrls];
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -60,7 +84,7 @@ function getRepoWorkspaceLink(repo: GiteaRepoSummary): string {
|
||||
return `<span class="muted">No workspace link</span>`;
|
||||
}
|
||||
|
||||
return `<span class="link-chip">Workspace link</span><span class="path-text">${escapeHtml(worktree.path)}</span>`;
|
||||
return `<span class="path-text">./${escapeHtml(worktree.relativePath)}</span>`;
|
||||
}
|
||||
|
||||
function renderRepos(repos: GiteaRepoSummary[]): string {
|
||||
@@ -68,28 +92,25 @@ function renderRepos(repos: GiteaRepoSummary[]): string {
|
||||
return `<div class="empty-state">No Gitea repos loaded yet.</div>`;
|
||||
}
|
||||
|
||||
return repos
|
||||
.map(
|
||||
(repo) => `
|
||||
<article class="item-card">
|
||||
<div class="item-heading">
|
||||
<div>
|
||||
<a class="item-title" href="${repo.webUrl}" target="_blank" rel="noreferrer">${escapeHtml(repo.fullName)}</a>
|
||||
<p class="item-subtitle">${escapeHtml(repo.description || "No description")}</p>
|
||||
</div>
|
||||
<span class="badge quiet">${repo.isPrivate ? "Private" : "Public"}</span>
|
||||
</div>
|
||||
<div class="meta-grid">
|
||||
<span>Branch: <strong>${escapeHtml(repo.defaultBranch)}</strong></span>
|
||||
<span>Last commit: <strong>${formatDate(repo.lastCommitAt)}</strong></span>
|
||||
<span>SHA: <strong>${formatSha(repo.lastCommitSha)}</strong></span>
|
||||
</div>
|
||||
<p class="commit-message">${escapeHtml(repo.lastCommitMessage)}</p>
|
||||
<div class="linked-row">${getRepoWorkspaceLink(repo)}</div>
|
||||
</article>
|
||||
`
|
||||
)
|
||||
.join("");
|
||||
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)}</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 {
|
||||
@@ -97,31 +118,32 @@ function renderWorkspaces(workspaces: LocalWorkspace[]): string {
|
||||
return `<div class="empty-state">No workspaces found in workspace_Father.</div>`;
|
||||
}
|
||||
|
||||
return 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 `
|
||||
<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="item-card">
|
||||
<div class="item-heading">
|
||||
<div>
|
||||
<h2 class="item-title">${escapeHtml(workspace.name)}</h2>
|
||||
<p class="path-text">${escapeHtml(workspace.path)}</p>
|
||||
</div>
|
||||
${workspace.linkedRepos.length ? `<span class="link-chip">Linked</span>` : ""}
|
||||
</div>
|
||||
<div class="repo-link-list">${links}</div>
|
||||
</article>
|
||||
`;
|
||||
})
|
||||
.join("");
|
||||
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>
|
||||
</article>
|
||||
`;
|
||||
})
|
||||
.join("")}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderUrls(urls: KnownUrl[]): string {
|
||||
@@ -129,35 +151,193 @@ function renderUrls(urls: KnownUrl[]): string {
|
||||
return `<div class="empty-state">No known URLs registered.</div>`;
|
||||
}
|
||||
|
||||
return urls
|
||||
return `
|
||||
<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>
|
||||
<button class="visit-button" type="button" data-open-url="${knownUrl.url}">Quick visit</button>
|
||||
</article>
|
||||
`
|
||||
)
|
||||
.join("")}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function selectedAttribute(value: string, selectedValue: string): string {
|
||||
return value === selectedValue ? " selected" : "";
|
||||
}
|
||||
|
||||
function workspaceOptions(context: LocalContext, selectedValue: string): string {
|
||||
return context.workspaces
|
||||
.map(
|
||||
(knownUrl) => `
|
||||
<article class="item-card">
|
||||
<div class="item-heading">
|
||||
<div>
|
||||
<h2 class="item-title">${escapeHtml(knownUrl.label)}</h2>
|
||||
<a class="url-text" href="${knownUrl.url}" target="_blank" rel="noreferrer">${escapeHtml(knownUrl.url)}</a>
|
||||
</div>
|
||||
<button class="visit-button" type="button" data-open-url="${knownUrl.url}">Quick visit</button>
|
||||
</div>
|
||||
<div class="linked-row">
|
||||
<span class="link-chip">Workspace link</span>
|
||||
<span class="path-text">${escapeHtml(knownUrl.workspacePath)}</span>
|
||||
</div>
|
||||
<div class="linked-row">
|
||||
<span class="link-chip">Gitea link</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>
|
||||
</article>
|
||||
`
|
||||
(workspace) =>
|
||||
`<option value="${escapeHtml(workspace.name)}"${selectedAttribute(workspace.name, selectedValue)}>${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;
|
||||
|
||||
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(context, selectedValue)}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<button id="save-url-link" class="primary-button" type="button" ${state.addUrlDraft.selectedValue ? "" : "disabled"}>
|
||||
Link URL
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderAttachmentList(items: string[], emptyText: string): string {
|
||||
if (!items.length) {
|
||||
return `<span class="muted">${escapeHtml(emptyText)}</span>`;
|
||||
}
|
||||
|
||||
return `
|
||||
<ul class="attachment-list">
|
||||
${items.map((item) => `<li>${escapeHtml(item)}</li>`).join("")}
|
||||
</ul>
|
||||
`;
|
||||
}
|
||||
|
||||
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>`;
|
||||
}
|
||||
|
||||
return `
|
||||
<article class="item-card issue-card">
|
||||
<div class="target-summary">
|
||||
<span>Target repo</span>
|
||||
<a class="item-title" href="${knownUrl.giteaRepo.webUrl}" target="_blank" rel="noreferrer">${escapeHtml(knownUrl.giteaRepo.fullName)}</a>
|
||||
<span class="path-text">${escapeHtml(knownUrl.workspacePath)}</span>
|
||||
<span class="url-text">${escapeHtml(knownUrl.url)}</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>Content</span>
|
||||
<textarea id="issue-content" rows="8" placeholder="Describe the issue">${escapeHtml(draft.content)}</textarea>
|
||||
</label>
|
||||
|
||||
<section class="attachment-section">
|
||||
<div class="attachment-heading">
|
||||
<span>Screenshots</span>
|
||||
<button id="capture-screenshot" class="secondary-button" type="button">Take snapshot</button>
|
||||
</div>
|
||||
${renderAttachmentList(
|
||||
draft.screenshots.map((screenshot) => screenshot.name),
|
||||
"No screenshots captured."
|
||||
)}
|
||||
</section>
|
||||
|
||||
<label>
|
||||
<span>File attachments</span>
|
||||
<input id="issue-files" type="file" multiple />
|
||||
</label>
|
||||
${renderAttachmentList(
|
||||
draft.files.map((file) => file.name),
|
||||
"No files selected."
|
||||
)}
|
||||
|
||||
<label class="checkbox-row">
|
||||
<input id="issue-ready" type="checkbox" ${draft.ready ? "checked" : ""} />
|
||||
<span>Apply ready label after creation and file uploads</span>
|
||||
</label>
|
||||
|
||||
<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 });
|
||||
@@ -167,6 +347,66 @@ function openUrl(url: string): void {
|
||||
window.open(url, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
|
||||
function bindViewActions(): void {
|
||||
viewContent?.querySelectorAll<HTMLButtonElement>("[data-open-url]").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
const url = button.dataset.openUrl;
|
||||
if (url) {
|
||||
openUrl(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>("#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<HTMLInputElement>("#issue-ready")?.addEventListener("change", (event) => {
|
||||
appStore.getState().setIssueDraft({
|
||||
ready: (event.target as HTMLInputElement).checked
|
||||
});
|
||||
});
|
||||
|
||||
viewContent?.querySelector<HTMLInputElement>("#issue-files")?.addEventListener("change", (event) => {
|
||||
appStore.getState().setIssueFiles(Array.from((event.target as HTMLInputElement).files ?? []));
|
||||
});
|
||||
|
||||
viewContent?.querySelector<HTMLButtonElement>("#capture-screenshot")?.addEventListener("click", () => {
|
||||
void appStore.getState().captureScreenshot();
|
||||
});
|
||||
|
||||
viewContent?.querySelector<HTMLButtonElement>("#create-issue")?.addEventListener("click", () => {
|
||||
void appStore.getState().createIssue();
|
||||
});
|
||||
}
|
||||
|
||||
function renderView(): void {
|
||||
if (!viewHeader || !viewContent) {
|
||||
return;
|
||||
@@ -175,12 +415,16 @@ function renderView(): void {
|
||||
const state = appStore.getState();
|
||||
const context = state.localContext;
|
||||
const titles: Record<AppView, string> = {
|
||||
"add-issue": "Add issue",
|
||||
"add-url": "Add URL",
|
||||
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.",
|
||||
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"}.`
|
||||
@@ -197,28 +441,19 @@ function renderView(): void {
|
||||
viewContent.innerHTML = renderRepos(state.repos);
|
||||
} else if (state.activeView === "workspaces") {
|
||||
viewContent.innerHTML = renderWorkspaces(context?.workspaces ?? []);
|
||||
} else if (state.activeView === "add-issue") {
|
||||
viewContent.innerHTML = renderAddIssue();
|
||||
} else if (state.activeView === "add-url") {
|
||||
viewContent.innerHTML = renderAddUrl();
|
||||
} else {
|
||||
viewContent.innerHTML = renderUrls(context?.knownUrls ?? []);
|
||||
viewContent.innerHTML = renderUrls(allUrls());
|
||||
}
|
||||
|
||||
viewContent.querySelectorAll<HTMLButtonElement>("[data-open-url]").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
const url = button.dataset.openUrl;
|
||||
if (url) {
|
||||
openUrl(url);
|
||||
}
|
||||
});
|
||||
});
|
||||
bindViewActions();
|
||||
}
|
||||
|
||||
function renderChrome(): void {
|
||||
const state = appStore.getState();
|
||||
const config = state.config;
|
||||
|
||||
if (configBadge) {
|
||||
configBadge.textContent = config.isConfigured ? "Configured" : "Missing env";
|
||||
configBadge.dataset.tone = config.isConfigured ? "success" : "warning";
|
||||
}
|
||||
|
||||
if (status) {
|
||||
status.textContent = state.statusMessage;
|
||||
@@ -229,8 +464,12 @@ function renderChrome(): void {
|
||||
refreshData.disabled = state.isLoadingData;
|
||||
}
|
||||
|
||||
if (appWindow) {
|
||||
appWindow.style.transform = `translate(${state.windowPosition.x}px, ${state.windowPosition.y}px)`;
|
||||
if (addUrlButton) {
|
||||
addUrlButton.disabled = !state.activeBaseUrl || isActiveBaseUrlKnown();
|
||||
}
|
||||
|
||||
if (addIssueButton) {
|
||||
addIssueButton.disabled = !canAddIssue();
|
||||
}
|
||||
|
||||
viewButtons.forEach((button) => {
|
||||
@@ -244,54 +483,6 @@ function render(): void {
|
||||
renderView();
|
||||
}
|
||||
|
||||
function setupDragging(): void {
|
||||
if (!appWindow || !dragHandle) {
|
||||
return;
|
||||
}
|
||||
|
||||
let dragStart:
|
||||
| {
|
||||
pointerId: number;
|
||||
pointerX: number;
|
||||
pointerY: number;
|
||||
windowX: number;
|
||||
windowY: number;
|
||||
}
|
||||
| null = null;
|
||||
|
||||
dragHandle.addEventListener("pointerdown", (event) => {
|
||||
const state = appStore.getState();
|
||||
dragStart = {
|
||||
pointerId: event.pointerId,
|
||||
pointerX: event.clientX,
|
||||
pointerY: event.clientY,
|
||||
windowX: state.windowPosition.x,
|
||||
windowY: state.windowPosition.y
|
||||
};
|
||||
dragHandle.setPointerCapture(event.pointerId);
|
||||
});
|
||||
|
||||
dragHandle.addEventListener("pointermove", (event) => {
|
||||
if (!dragStart || event.pointerId !== dragStart.pointerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = appWindow.getBoundingClientRect();
|
||||
const maxX = Math.max(0, window.innerWidth - rect.width);
|
||||
const maxY = Math.max(0, window.innerHeight - rect.height);
|
||||
const x = Math.min(Math.max(0, dragStart.windowX + event.clientX - dragStart.pointerX), maxX);
|
||||
const y = Math.min(Math.max(0, dragStart.windowY + event.clientY - dragStart.pointerY), maxY);
|
||||
appStore.getState().setWindowPosition({ x, y });
|
||||
});
|
||||
|
||||
dragHandle.addEventListener("pointerup", (event) => {
|
||||
if (dragStart?.pointerId === event.pointerId) {
|
||||
dragStart = null;
|
||||
dragHandle.releasePointerCapture(event.pointerId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
viewButtons.forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
const view = button.dataset.view as AppView | undefined;
|
||||
@@ -305,7 +496,42 @@ refreshData?.addEventListener("click", () => {
|
||||
void appStore.getState().loadData();
|
||||
});
|
||||
|
||||
setupDragging();
|
||||
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);
|
||||
}
|
||||
|
||||
setupActiveTabTracking();
|
||||
appStore.subscribe(render);
|
||||
render();
|
||||
void appStore.getState().loadData();
|
||||
|
||||
+434
-34
@@ -1,19 +1,42 @@
|
||||
import { createStore } from "zustand/vanilla";
|
||||
import {
|
||||
addGiteaIssueLabels,
|
||||
createGiteaIssue,
|
||||
ensureGiteaLabel,
|
||||
getCurrentGiteaUser,
|
||||
getReposOrderedByLastCommit,
|
||||
giteaConfig,
|
||||
type GiteaRepoSummary,
|
||||
type GiteaUser
|
||||
type GiteaUser,
|
||||
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";
|
||||
export type AppView = "urls" | "repos" | "workspaces" | "add-url" | "add-issue";
|
||||
export type AddUrlMode = "workspace" | "repo";
|
||||
|
||||
export interface WindowPosition {
|
||||
x: number;
|
||||
y: number;
|
||||
export const CHROME_PROFILE_NAME = "Silma";
|
||||
|
||||
interface AddUrlDraft {
|
||||
mode: AddUrlMode | null;
|
||||
selectedValue: string;
|
||||
}
|
||||
|
||||
export interface ScreenshotAttachment {
|
||||
id: string;
|
||||
name: string;
|
||||
dataUrl: string;
|
||||
blob: Blob;
|
||||
}
|
||||
|
||||
interface IssueDraft {
|
||||
subject: string;
|
||||
content: string;
|
||||
ready: boolean;
|
||||
screenshots: ScreenshotAttachment[];
|
||||
files: File[];
|
||||
}
|
||||
|
||||
interface AppState {
|
||||
@@ -22,81 +45,458 @@ interface AppState {
|
||||
connectedUser: GiteaUser | null;
|
||||
repos: GiteaRepoSummary[];
|
||||
localContext: LocalContext | null;
|
||||
windowPosition: WindowPosition;
|
||||
userUrls: KnownUrl[];
|
||||
activeTabUrl: string;
|
||||
activeBaseUrl: string;
|
||||
addUrlDraft: AddUrlDraft;
|
||||
issueDraft: IssueDraft;
|
||||
isLoadingData: boolean;
|
||||
isTestingConnection: boolean;
|
||||
isCreatingIssue: boolean;
|
||||
statusMessage: string;
|
||||
statusTone: StatusTone;
|
||||
clearStatus: () => void;
|
||||
setActiveView: (view: AppView) => void;
|
||||
setWindowPosition: (position: WindowPosition) => void;
|
||||
setAddUrlDraft: (draft: Partial<AddUrlDraft>) => void;
|
||||
setIssueDraft: (draft: Partial<Omit<IssueDraft, "screenshots" | "files">>) => void;
|
||||
setIssueFiles: (files: File[]) => void;
|
||||
captureScreenshot: () => Promise<void>;
|
||||
createIssue: () => Promise<void>;
|
||||
refreshActiveTab: () => Promise<void>;
|
||||
loadData: () => Promise<void>;
|
||||
saveCurrentUrlLink: () => Promise<void>;
|
||||
testConnection: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const appStore = createStore<AppState>()((set) => ({
|
||||
const USER_URLS_STORAGE_KEY = "silmaAide.userUrls";
|
||||
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;
|
||||
}
|
||||
|
||||
chrome.tabs.query({ active: true, lastFocusedWindow: true }, (tabs) => {
|
||||
resolve(tabs[0]?.url ?? "");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function findWorkspaceByName(context: LocalContext, name: string): LocalWorkspace | undefined {
|
||||
return context.workspaces.find((workspace) => workspace.name === name);
|
||||
}
|
||||
|
||||
function findRepoByFullName(context: LocalContext, fullName: string): LocalGiteaRepoLink | null {
|
||||
return context.gitWorktrees.find((worktree) => worktree.giteaRepo?.fullName === fullName)?.giteaRepo ?? null;
|
||||
}
|
||||
|
||||
function allKnownUrls(state: AppState): KnownUrl[] {
|
||||
return [...(state.localContext?.knownUrls ?? []), ...state.userUrls];
|
||||
}
|
||||
|
||||
export function findKnownUrlForBase(state: AppState): KnownUrl | null {
|
||||
if (!state.activeBaseUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return allKnownUrls(state).find((knownUrl) => getBaseUrl(knownUrl.url) === state.activeBaseUrl) ?? null;
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
function inferUrlLink(context: LocalContext, baseUrl: string, draft: AddUrlDraft): KnownUrl | null {
|
||||
if (!draft.mode || !draft.selectedValue) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (draft.mode === "workspace") {
|
||||
const workspace = findWorkspaceByName(context, 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(context, 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;
|
||||
}
|
||||
|
||||
chrome.tabs.query({ active: true, lastFocusedWindow: true }, (tabs) => {
|
||||
const windowId = tabs[0]?.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."));
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function buildIssueBody(state: AppState, knownUrl: KnownUrl): string {
|
||||
const parts = [
|
||||
state.issueDraft.content.trim(),
|
||||
"---",
|
||||
`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"}`,
|
||||
`Chrome profile: ${CHROME_PROFILE_NAME}`
|
||||
];
|
||||
|
||||
return parts.filter(Boolean).join("\n\n");
|
||||
}
|
||||
|
||||
export const appStore = createStore<AppState>()((set, get) => ({
|
||||
activeView: "repos",
|
||||
config: giteaConfig,
|
||||
connectedUser: null,
|
||||
repos: [],
|
||||
localContext: null,
|
||||
windowPosition: { x: 10, y: 10 },
|
||||
userUrls: [],
|
||||
activeTabUrl: "",
|
||||
activeBaseUrl: "",
|
||||
addUrlDraft: {
|
||||
mode: null,
|
||||
selectedValue: ""
|
||||
},
|
||||
issueDraft: {
|
||||
subject: "",
|
||||
content: "",
|
||||
ready: false,
|
||||
screenshots: [],
|
||||
files: []
|
||||
},
|
||||
isLoadingData: false,
|
||||
isTestingConnection: false,
|
||||
statusMessage: "Ready.",
|
||||
isCreatingIssue: false,
|
||||
statusMessage: "",
|
||||
statusTone: "neutral",
|
||||
clearStatus: () => {
|
||||
if (statusTimer !== undefined) {
|
||||
window.clearTimeout(statusTimer);
|
||||
statusTimer = undefined;
|
||||
}
|
||||
set({ statusMessage: "", statusTone: "neutral" });
|
||||
},
|
||||
setActiveView: (view) => set({ activeView: view }),
|
||||
setWindowPosition: (position) => set({ windowPosition: position }),
|
||||
loadData: async () => {
|
||||
set({
|
||||
isLoadingData: true,
|
||||
statusMessage: "Loading workspace and Gitea context...",
|
||||
statusTone: "neutral"
|
||||
});
|
||||
setAddUrlDraft: (draft) =>
|
||||
set((state) => ({
|
||||
addUrlDraft: {
|
||||
...state.addUrlDraft,
|
||||
...draft
|
||||
}
|
||||
})),
|
||||
setIssueDraft: (draft) =>
|
||||
set((state) => ({
|
||||
issueDraft: {
|
||||
...state.issueDraft,
|
||||
...draft
|
||||
}
|
||||
})),
|
||||
setIssueFiles: (files) =>
|
||||
set((state) => ({
|
||||
issueDraft: {
|
||||
...state.issueDraft,
|
||||
files
|
||||
}
|
||||
})),
|
||||
captureScreenshot: async () => {
|
||||
setStatus(set, "Capturing screenshot...", "neutral", { autoClear: false });
|
||||
|
||||
try {
|
||||
const [localContext, repos] = await Promise.all([
|
||||
const { dataUrl, blob } = await captureVisibleTab();
|
||||
const screenshot: ScreenshotAttachment = {
|
||||
id: crypto.randomUUID(),
|
||||
name: `silma-screenshot-${new Date().toISOString().replace(/[:.]/g, "-")}.png`,
|
||||
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");
|
||||
}
|
||||
},
|
||||
createIssue: async () => {
|
||||
const state = appStore.getState();
|
||||
const knownUrl = findKnownUrlForBase(state);
|
||||
const repo = knownUrl?.giteaRepo ? parseRepoFullName(knownUrl.giteaRepo.fullName) : null;
|
||||
const subject = state.issueDraft.subject.trim();
|
||||
|
||||
if (!knownUrl || !repo) {
|
||||
setStatus(set, "Open a known URL with a linked Gitea repo 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 issue = await createGiteaIssue(repo.owner, repo.repo, subject, buildIssueBody(state, knownUrl));
|
||||
const uploads = [
|
||||
...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(repo.owner, repo.repo, issue.number, upload.blob, upload.name);
|
||||
}
|
||||
|
||||
if (state.issueDraft.ready) {
|
||||
const readyLabel = await ensureGiteaLabel(repo.owner, repo.repo, "ready");
|
||||
await addGiteaIssueLabels(repo.owner, repo.repo, issue.number, [readyLabel.id]);
|
||||
}
|
||||
|
||||
set({
|
||||
issueDraft: {
|
||||
subject: "",
|
||||
content: "",
|
||||
ready: false,
|
||||
screenshots: [],
|
||||
files: []
|
||||
}
|
||||
});
|
||||
setStatus(set, `Created issue #${issue.number} in ${knownUrl.giteaRepo?.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
|
||||
});
|
||||
},
|
||||
loadData: async () => {
|
||||
set({
|
||||
isLoadingData: true
|
||||
});
|
||||
setStatus(set, "Loading workspace and Gitea context...", "neutral", { autoClear: false });
|
||||
|
||||
try {
|
||||
const [localContext, repos, userUrls, activeTabUrl] = await Promise.all([
|
||||
loadLocalContext(),
|
||||
getReposOrderedByLastCommit()
|
||||
getReposOrderedByLastCommit(),
|
||||
storageGet<KnownUrl[]>(USER_URLS_STORAGE_KEY, []),
|
||||
getActiveTabUrl()
|
||||
]);
|
||||
const activeBaseUrl = getBaseUrl(activeTabUrl);
|
||||
|
||||
set({
|
||||
localContext,
|
||||
repos,
|
||||
statusMessage: `Loaded ${repos.length} repos and ${localContext.workspaces.length} workspaces.`,
|
||||
statusTone: "success"
|
||||
userUrls,
|
||||
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.";
|
||||
set({
|
||||
statusMessage: message,
|
||||
statusTone: "error"
|
||||
});
|
||||
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
|
||||
];
|
||||
|
||||
await storageSet(USER_URLS_STORAGE_KEY, userUrls);
|
||||
set({
|
||||
userUrls,
|
||||
activeView: "urls",
|
||||
addUrlDraft: { mode: null, selectedValue: "" }
|
||||
});
|
||||
setStatus(set, `Linked ${state.activeBaseUrl} to ${link.giteaRepo?.fullName || link.workspaceName}.`, "success");
|
||||
},
|
||||
testConnection: async () => {
|
||||
set({
|
||||
connectedUser: null,
|
||||
isTestingConnection: true,
|
||||
statusMessage: "Testing connection...",
|
||||
statusTone: "neutral"
|
||||
isTestingConnection: true
|
||||
});
|
||||
setStatus(set, "Testing connection...", "neutral", { autoClear: false });
|
||||
|
||||
try {
|
||||
const user = await getCurrentGiteaUser();
|
||||
set({
|
||||
connectedUser: user,
|
||||
statusMessage: `Connected as ${user.full_name || user.login}.`,
|
||||
statusTone: "success"
|
||||
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,
|
||||
statusMessage: message,
|
||||
statusTone: "error"
|
||||
connectedUser: null
|
||||
});
|
||||
setStatus(set, message, "error");
|
||||
} finally {
|
||||
set({ isTestingConnection: false });
|
||||
}
|
||||
|
||||
+301
-150
@@ -1,6 +1,6 @@
|
||||
:root {
|
||||
color: #1f2933;
|
||||
background: #e7edf3;
|
||||
background: #f5f7fa;
|
||||
font-family:
|
||||
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
|
||||
sans-serif;
|
||||
@@ -13,14 +13,20 @@
|
||||
}
|
||||
|
||||
body {
|
||||
width: 720px;
|
||||
min-width: 720px;
|
||||
height: 620px;
|
||||
min-width: 360px;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
background:
|
||||
linear-gradient(135deg, rgb(255 255 255 / 72%), rgb(218 228 238 / 72%)),
|
||||
#e7edf3;
|
||||
background: #f5f7fa;
|
||||
}
|
||||
|
||||
button,
|
||||
a,
|
||||
select,
|
||||
input,
|
||||
textarea {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button,
|
||||
@@ -28,49 +34,18 @@ a {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.desktop-canvas {
|
||||
position: relative;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
.side-panel-shell,
|
||||
.app-panel {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.app-window {
|
||||
position: absolute;
|
||||
.app-panel {
|
||||
display: grid;
|
||||
grid-template-rows: auto auto 1fr auto;
|
||||
width: min(700px, calc(100vw - 20px));
|
||||
height: min(600px, calc(100vh - 20px));
|
||||
grid-template-rows: auto 1fr auto;
|
||||
overflow: hidden;
|
||||
border: 1px solid #bcccdc;
|
||||
border-radius: 8px;
|
||||
background: #f8fafc;
|
||||
box-shadow: 0 18px 48px rgb(16 42 67 / 18%);
|
||||
}
|
||||
|
||||
.window-titlebar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid #d9e2ec;
|
||||
background: #ffffff;
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.window-titlebar:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 0 2px;
|
||||
color: #52616f;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
h1,
|
||||
@@ -81,37 +56,8 @@ p {
|
||||
|
||||
h1 {
|
||||
color: #102a43;
|
||||
font-size: 20px;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.badge {
|
||||
flex: 0 0 auto;
|
||||
border: 1px solid #bcccdc;
|
||||
border-radius: 999px;
|
||||
padding: 5px 9px;
|
||||
background: #ffffff;
|
||||
color: #334e68;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.badge[data-tone="success"] {
|
||||
border-color: #7bc47f;
|
||||
background: #e3f9e5;
|
||||
color: #1b5e20;
|
||||
}
|
||||
|
||||
.badge[data-tone="warning"] {
|
||||
border-color: #f7c948;
|
||||
background: #fffbea;
|
||||
color: #8d6e00;
|
||||
}
|
||||
|
||||
.badge.quiet {
|
||||
border-color: #d9e2ec;
|
||||
background: #f5f7fa;
|
||||
color: #52616f;
|
||||
font-size: 18px;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
@@ -124,35 +70,35 @@ h1 {
|
||||
|
||||
.tool-button,
|
||||
.icon-button,
|
||||
.visit-button {
|
||||
.visit-button,
|
||||
.primary-button {
|
||||
border: 0;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.tool-button {
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
gap: 4px;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
min-height: 58px;
|
||||
padding: 8px 6px;
|
||||
min-height: 50px;
|
||||
padding: 7px 4px;
|
||||
background: #ffffff;
|
||||
color: #334e68;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tool-button svg {
|
||||
width: 19px;
|
||||
height: 19px;
|
||||
stroke-width: 2.25;
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
stroke-width: 2.3;
|
||||
}
|
||||
|
||||
.tool-button span {
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
line-height: 1.15;
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
line-height: 1.1;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -164,7 +110,7 @@ h1 {
|
||||
|
||||
.tool-button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.45;
|
||||
opacity: 0.42;
|
||||
}
|
||||
|
||||
.content-panel {
|
||||
@@ -174,55 +120,128 @@ h1 {
|
||||
}
|
||||
|
||||
.view-header {
|
||||
padding: 14px 16px 10px;
|
||||
padding: 10px 12px 8px;
|
||||
border-bottom: 1px solid #edf2f7;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.view-header h2 {
|
||||
color: #102a43;
|
||||
font-size: 17px;
|
||||
font-size: 15px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.view-header p {
|
||||
margin-top: 3px;
|
||||
margin-top: 2px;
|
||||
color: #52616f;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
font-size: 11px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.view-content {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 10px;
|
||||
gap: 8px;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 12px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.repo-list {
|
||||
display: grid;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.compact-list {
|
||||
display: grid;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.repo-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(150px, 1fr) 70px 58px minmax(86px, 0.8fr);
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
border: 0;
|
||||
border-bottom: 1px solid #d9e2ec;
|
||||
border-radius: 0;
|
||||
padding: 3px 6px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.repo-row:first-child {
|
||||
border-top: 1px solid #d9e2ec;
|
||||
}
|
||||
|
||||
.workspace-row,
|
||||
.url-row {
|
||||
display: grid;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
border-bottom: 1px solid #d9e2ec;
|
||||
padding: 3px 6px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.workspace-row {
|
||||
grid-template-columns: minmax(128px, 0.9fr) minmax(120px, 1fr);
|
||||
}
|
||||
|
||||
.url-row {
|
||||
grid-template-columns: minmax(150px, 1fr) auto;
|
||||
}
|
||||
|
||||
.workspace-row:first-child,
|
||||
.url-row:first-child {
|
||||
border-top: 1px solid #d9e2ec;
|
||||
}
|
||||
|
||||
.repo-main {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.row-main {
|
||||
display: grid;
|
||||
gap: 1px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.item-card {
|
||||
display: grid;
|
||||
gap: 9px;
|
||||
gap: 8px;
|
||||
border: 1px solid #d9e2ec;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
border-radius: 7px;
|
||||
padding: 9px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.dense-card {
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.item-heading {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
gap: 9px;
|
||||
}
|
||||
|
||||
.item-title {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: #102a43;
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
font-size: 12px;
|
||||
font-weight: 850;
|
||||
line-height: 1.25;
|
||||
text-decoration: none;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
h2.item-title {
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
a.item-title:hover,
|
||||
@@ -231,39 +250,37 @@ a.item-title:hover,
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.item-subtitle {
|
||||
margin-top: 3px;
|
||||
.repo-meta {
|
||||
overflow: hidden;
|
||||
color: #52616f;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.meta-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
color: #52616f;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.meta-grid strong {
|
||||
color: #243b53;
|
||||
font-weight: 800;
|
||||
.repo-link-cell {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.commit-message {
|
||||
color: #243b53;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: #52616f;
|
||||
font-size: 10px;
|
||||
line-height: 1.2;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.linked-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 7px;
|
||||
gap: 6px;
|
||||
color: #52616f;
|
||||
font-size: 12px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.link-chip {
|
||||
@@ -271,59 +288,189 @@ a.item-title:hover,
|
||||
align-items: center;
|
||||
border: 1px solid #9fb3c8;
|
||||
border-radius: 999px;
|
||||
padding: 2px 7px;
|
||||
padding: 1px 6px;
|
||||
background: #f0f4f8;
|
||||
color: #334e68;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
font-size: 10px;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.path-text {
|
||||
overflow-wrap: anywhere;
|
||||
color: #334e68;
|
||||
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
|
||||
font-size: 11px;
|
||||
line-height: 1.35;
|
||||
font-size: 10px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.repo-link-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 7px;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.repo-link,
|
||||
.url-text {
|
||||
overflow-wrap: anywhere;
|
||||
color: #0b6bcb;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
font-size: 11px;
|
||||
font-weight: 750;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.visit-button {
|
||||
.visit-button,
|
||||
.secondary-button,
|
||||
.primary-button {
|
||||
flex: 0 0 auto;
|
||||
border-radius: 6px;
|
||||
padding: 7px 10px;
|
||||
padding: 4px 7px;
|
||||
background: #0b6bcb;
|
||||
color: #ffffff;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
font-size: 11px;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.secondary-button {
|
||||
border: 1px solid #9fb3c8;
|
||||
background: #eef4f9;
|
||||
color: #243b53;
|
||||
}
|
||||
|
||||
.primary-button {
|
||||
width: 100%;
|
||||
min-height: 34px;
|
||||
}
|
||||
|
||||
.primary-button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.48;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
border: 1px dashed #bcccdc;
|
||||
border-radius: 8px;
|
||||
padding: 18px;
|
||||
border-radius: 7px;
|
||||
padding: 14px;
|
||||
color: #52616f;
|
||||
background: #ffffff;
|
||||
font-size: 13px;
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: #829ab1;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.form-stack {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.form-stack label,
|
||||
fieldset {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.form-stack label > span,
|
||||
legend {
|
||||
color: #52616f;
|
||||
font-size: 11px;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
fieldset {
|
||||
border: 1px solid #d9e2ec;
|
||||
border-radius: 7px;
|
||||
margin: 0;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.radio-row {
|
||||
display: flex;
|
||||
grid-template-columns: auto 1fr;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
color: #243b53;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
input[type="file"],
|
||||
textarea,
|
||||
select {
|
||||
width: 100%;
|
||||
min-height: 32px;
|
||||
border: 1px solid #bcccdc;
|
||||
border-radius: 6px;
|
||||
padding: 6px 8px;
|
||||
background: #ffffff;
|
||||
color: #102a43;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
textarea {
|
||||
min-height: 112px;
|
||||
line-height: 1.35;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
input[readonly] {
|
||||
background: #f0f4f8;
|
||||
color: #334e68;
|
||||
}
|
||||
|
||||
.issue-card {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.target-summary {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
border: 1px solid #d9e2ec;
|
||||
border-radius: 6px;
|
||||
padding: 6px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.target-summary > span:first-child,
|
||||
.attachment-heading > span {
|
||||
color: #52616f;
|
||||
font-size: 10px;
|
||||
font-weight: 850;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.attachment-section {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.attachment-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.attachment-list {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
margin: 0;
|
||||
padding-left: 16px;
|
||||
color: #334e68;
|
||||
font-size: 11px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.checkbox-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
color: #243b53;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@@ -331,10 +478,10 @@ a.item-title:hover,
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
min-height: 42px;
|
||||
gap: 8px;
|
||||
min-height: 38px;
|
||||
border-top: 1px solid #d9e2ec;
|
||||
padding: 8px 10px 8px 14px;
|
||||
padding: 6px 8px 6px 10px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
@@ -342,8 +489,8 @@ a.item-title:hover,
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: #52616f;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
font-size: 11px;
|
||||
line-height: 1.35;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -360,8 +507,8 @@ a.item-title:hover,
|
||||
display: inline-grid;
|
||||
flex: 0 0 auto;
|
||||
place-items: center;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 6px;
|
||||
background: #eef4f9;
|
||||
color: #334e68;
|
||||
@@ -374,17 +521,21 @@ a.item-title:hover,
|
||||
}
|
||||
|
||||
.icon-button svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
body {
|
||||
width: 420px;
|
||||
min-width: 420px;
|
||||
@media (max-width: 430px) {
|
||||
.repo-row {
|
||||
grid-template-columns: minmax(130px, 1fr) 62px 58px;
|
||||
}
|
||||
|
||||
.meta-grid {
|
||||
.repo-link-cell {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.workspace-row,
|
||||
.url-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
+28
-4
@@ -40,12 +40,30 @@ function chromeExtensionManifest(mode: string): Plugin {
|
||||
manifest_version: 3,
|
||||
name: "Silma AI Aide",
|
||||
version: "0.1.0",
|
||||
description: "A Vite-powered popup for Gitea-backed Silma AI aide workflows.",
|
||||
description: "A Vite-powered side panel for Gitea-backed Silma AI aide workflows.",
|
||||
icons: {
|
||||
16: "icons/icon-16.png",
|
||||
32: "icons/icon-32.png",
|
||||
48: "icons/icon-48.png",
|
||||
128: "icons/icon-128.png"
|
||||
},
|
||||
action: {
|
||||
default_title: "Silma AI Aide",
|
||||
default_popup: "index.html"
|
||||
default_icon: {
|
||||
16: "icons/icon-16.png",
|
||||
32: "icons/icon-32.png",
|
||||
48: "icons/icon-48.png",
|
||||
128: "icons/icon-128.png"
|
||||
}
|
||||
},
|
||||
permissions: ["storage"],
|
||||
background: {
|
||||
service_worker: "assets/background.js",
|
||||
type: "module"
|
||||
},
|
||||
side_panel: {
|
||||
default_path: "index.html"
|
||||
},
|
||||
permissions: ["activeTab", "sidePanel", "storage", "tabs"],
|
||||
host_permissions: getGiteaHostPermissions(env.VITE_GITEA_BASE_URL)
|
||||
};
|
||||
|
||||
@@ -64,8 +82,14 @@ export default defineConfig(({ mode }) => ({
|
||||
outDir: "dist",
|
||||
emptyOutDir: true,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
entryFileNames: "assets/[name].js",
|
||||
chunkFileNames: "assets/[name]-[hash].js",
|
||||
assetFileNames: "assets/[name]-[hash][extname]"
|
||||
},
|
||||
input: {
|
||||
popup: resolve(__dirname, "index.html")
|
||||
background: resolve(__dirname, "src/background.ts"),
|
||||
sidePanel: resolve(__dirname, "index.html")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user