diff --git a/apps/desktop/src/main/gitea.ts b/apps/desktop/src/main/gitea.ts index 1bafee0..863bb9f 100644 --- a/apps/desktop/src/main/gitea.ts +++ b/apps/desktop/src/main/gitea.ts @@ -13,6 +13,7 @@ import { dirname, join } from 'node:path' import { appendDirective, createGiteaClient, + discoverRepos, GiteaApiError, type DirectiveEntry, type GiteaClient, @@ -359,6 +360,17 @@ export function registerGiteaIpc(): void { return { ok: true as const } }) + // Discover the owners + repos a token can reach, so onboarding can offer them + // as dropdowns. Token-scoped (not repo-scoped) — no owner/repo needed yet. + ipcMain.handle('config:discover', async (_event, conn: { baseUrl: string; token: string }) => { + try { + const found = await discoverRepos({ baseUrl: conn.baseUrl.replace(/\/+$/, ''), token: conn.token }, fetch) + return { ok: true as const, ...found } + } catch (e) { + return { ok: false as const, error: e instanceof GiteaApiError ? `${e.status}` : e instanceof Error ? e.message : String(e) } + } + }) + // Validate a token + repo before saving: any authed read on the repo proves access. ipcMain.handle('config:test', async (_event, cfg: AppConfig) => { try { diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index b780fee..d507def 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -27,6 +27,8 @@ const api = { config: { /** The saved config (never the token) — null when unset. */ get: () => ipcRenderer.invoke('config:get'), + /** Discover the owners + repos a token can reach (for the onboarding pickers). */ + discover: (conn: unknown) => ipcRenderer.invoke('config:discover', conn), /** Validate a token + repo before saving. */ test: (cfg: unknown) => ipcRenderer.invoke('config:test', cfg), /** Save config (token encrypted in main); takes effect without restart. */ diff --git a/apps/desktop/src/renderer/src/components/gallery.tsx b/apps/desktop/src/renderer/src/components/gallery.tsx deleted file mode 100644 index ebb2761..0000000 --- a/apps/desktop/src/renderer/src/components/gallery.tsx +++ /dev/null @@ -1,211 +0,0 @@ -import React, { useState } from 'react' - -import { - Badge, - Button, - Card, - Checkbox, - Dialog, - Icon, - IconButton, - Input, - Radio, - Select, - Switch, - Tabs, - Tag, - Toast, - Tooltip, -} from './ui/index.js' - -function Section({ title, children }: { title: string; children: React.ReactNode }) { - return ( -
-

{title}

-
{children}
-
- ) -} - -/** - * Visual + behavioral proof for the ported primitives (P3-1). Not a product - * screen — the real shell lands in P3-2. Exercises every primitive in both - * themes via the toggle. - */ -export function PrimitivesGallery() { - const [tab, setTab] = useState('board') - const [dialogOpen, setDialogOpen] = useState(false) - const [checked, setChecked] = useState(true) - const [radio, setRadio] = useState('a') - const [on, setOn] = useState(true) - - return ( -
-
-
-

Primitives

-

- 15 components · toggle Evening service in the rail for dark -

-
- -
- - - - - - -
- -
- - - - -
- -
- - ahead - - - at risk - - - behind - - steeping - triage - on track -
- -
- - - - - - - - {}} /> -
- -
- } - footer={ - <> - - - - } - style={{ width: 340 }} - > -

- The tap-root. Everything sinks into it — grab it first. -

-
- -

Hairline border, whispered shadow.

-
-
- -
- -
- -
- - - - - -
- -
- - - - set(e.target.value)} - /> - {opts.hint ? {opts.hint} : null} -
- ) + const placeholder = (label: string) => [{ value: '', label }] return (
vo

- {field('Gitea URL', baseUrl, setBaseUrl, { placeholder: 'https://gitea.example.com' })} + { + setBaseUrl(e.target.value) + setDiscovery('idle') + setFound(null) + }} + /> + n + (found.reposByOwner[o]?.length ?? 0), 0)} repositories across ${found.owners.length} owner${found.owners.length === 1 ? '' : 's'}.` + : 'Paste your token, then tab out to load your repositories.' + } + onChange={(e) => { + setToken(e.target.value) + setDiscovery('idle') + }} + onBlur={() => void discover()} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault() + void discover() + } + }} + /> +
- {field('Owner', owner, setOwner, { placeholder: 'your-org' })} - {field('Repository', repo, setRepo, { placeholder: 'your-repo' })} + ({ value: r, label: r })) : placeholder('—') + } + onChange={(e) => setRepo(e.target.value)} + />
- {field('Access token', token, setToken, { - type: 'password', - placeholder: existing?.hasToken ? '•••••••• (saved — leave blank to keep)' : 'gitea PAT · scopes: repo + issue', - })} - {field('Model URL', modelUrl, setModelUrl, { - placeholder: 'http://localhost:1234/v1', - hint: 'Optional — an OpenAI-compatible endpoint for Reginald. Leave blank to keep chat off.', - })} + + setModelUrl(e.target.value)} + />
{error ? ( @@ -108,7 +178,7 @@ export function ConnectScreen({ onConnected, existing }: { onConnected: () => vo
diff --git a/apps/desktop/src/renderer/src/components/shell/app-shell.tsx b/apps/desktop/src/renderer/src/components/shell/app-shell.tsx index 3763759..2f326b2 100644 --- a/apps/desktop/src/renderer/src/components/shell/app-shell.tsx +++ b/apps/desktop/src/renderer/src/components/shell/app-shell.tsx @@ -23,7 +23,6 @@ import { inboxView } from '../../lib/views/inbox-view.js' import { issueDetailView } from '../../lib/views/issue-detail.js' import type { ProjectData } from '../../lib/views/project-data.js' import { standupView } from '../../lib/views/standup-view.js' -import { PrimitivesGallery } from '../gallery.js' import { BoardScreen } from '../screens/board-screen.js' import { CalibrationScreen } from '../screens/calibration-screen.js' import { CaptureScreen } from '../screens/capture-screen.js' @@ -38,8 +37,7 @@ import { SettingsScreen } from '../screens/settings-screen.js' import { StandupScreen } from '../screens/standup-screen.js' import { Icon, Switch } from '../ui/index.js' import { ChatPanel } from './chat-panel.js' -import { PlaceholderScreen } from './placeholder-screen.js' -import { OfflineBanner, StatesScreen } from './states.js' +import { OfflineBanner } from './states.js' type View = | 'standup' @@ -50,9 +48,7 @@ type View = | 'runway' | 'directives' | 'settings' - | 'states' | 'firstrun' - | 'primitives' | 'issue' | 'calibration' | 'milestone' @@ -64,52 +60,21 @@ interface NavEntry { count?: number | null } -// which phase builds each not-yet-real view (shown on its placeholder) -const PHASE: Partial> = { - standup: 'P3-3', - focus: 'P3-3', - inbox: 'P3-6', - capture: 'P3-7', - board: 'P3-4', - runway: 'P3-5', - directives: 'P3-8', - settings: 'P3-8', - firstrun: 'P3-8', - issue: 'P3-6', - calibration: 'P3-5', - milestone: 'P3-5', -} - -const TITLE: Partial> = { - standup: 'Standup', - focus: 'Morning service', - inbox: 'Inbox', - capture: 'Capture', - board: 'The pot', - runway: 'Runway', - directives: 'Directives', - settings: 'Settings', - firstrun: 'First run', - issue: 'Issue', - calibration: 'Calibration', - milestone: 'Milestone', -} - -const INBOX_UNREAD = 3 - export function AppShell() { const [view, setView] = useState('focus') const [prevView, setPrevView] = useState('focus') const [dark, setDark] = useState(false) - const [offline, setOffline] = useState(false) const [issue, setIssue] = useState(null) const [readIds, setReadIds] = useState([]) const [milestoneId, setMilestoneId] = useState(null) const [gate, setGate] = useState<'checking' | 'connect' | 'ready'>('checking') - const [demo, setDemo] = useState(false) const [pubConfig, setPubConfig] = useState(null) const [collaborators, setCollaborators] = useState<{ login: string; name: string }[]>([]) const [backlog, refetchBacklog] = useBacklog() + // Real connectivity: the reconcile serves the stale cache (or errors with no + // cache) only when gitea is unreachable. No manual toggle — this is the truth. + const offline = backlog.status === 'error' || (backlog.status === 'ready' && backlog.stale) + const staleSince = backlog.status === 'ready' ? backlog.savedAt : undefined const capacityMembers = useCapacity() const workers = capacityWorkers(capacityMembers) const boardColumns = @@ -161,10 +126,10 @@ export function AppShell() { const gantt = projectData ? ganttView(projectData) : undefined const depsGraph = projectData ? depsGraphView(projectData) : undefined // The rail's unread badge tracks the real inbox once reconciled (still-unread - // minus what's been opened); demo shows the fixture count. + // minus what's been opened); null until the first reconcile lands. const inboxUnread = inbox ? inbox.filter((n) => n.unread && !readIds.includes(n.id)).length - : INBOX_UNREAD + : null // The real connected host (from the saved config), for the rail's status line. const hostLabel = (() => { @@ -185,7 +150,6 @@ export function AppShell() { window.commitea.gitea .status() .then((s) => { - setDemo(s.demo) setGate(s.demo || s.configured ? 'ready' : 'connect') }) .catch(() => setGate('connect')) @@ -367,12 +331,8 @@ export function AppShell() { currentMilestoneId={currentIssue?.milestone?.id ?? null} /> ) : null - case 'states': - return setView('capture')} /> - case 'primitives': - return default: - return + return null } } @@ -435,33 +395,25 @@ export function AppShell() {
- {/* Dev-only surfaces (fixture galleries / onboarding preview) — shown in dev - and in demo/e2e mode; hidden in a real configured, packaged app. */} - {import.meta.env.DEV || demo ? ( - <> - - - - - ) : null}
- +
Evening service} checked={dark} diff --git a/apps/desktop/src/renderer/src/components/shell/placeholder-screen.tsx b/apps/desktop/src/renderer/src/components/shell/placeholder-screen.tsx deleted file mode 100644 index e45cb49..0000000 --- a/apps/desktop/src/renderer/src/components/shell/placeholder-screen.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { Button } from '../ui/index.js' -import { EmptyState } from './states.js' - -/** - * Stand-in for views not yet built (Focus, Board, Runway, …). Keeps navigation - * live so the shell is demonstrable before the real screens land in P3-3+. - * `onOpenIssue`, when given, exercises the shell's issue drill-in + back-stack; - * real screens replace this with actual issue rows in P3-3+. - */ -export function PlaceholderScreen({ - title, - phase, - onOpenIssue, -}: { - title: string - phase: string - onOpenIssue?: () => void -}) { - return ( -
-
-

{title}

-

- arrives in {phase} · the shell and navigation are live -

-
- - {onOpenIssue ? ( -
- -
- ) : null} -
- ) -} diff --git a/apps/desktop/src/renderer/src/components/shell/states.tsx b/apps/desktop/src/renderer/src/components/shell/states.tsx index bd3479e..5a446b0 100644 --- a/apps/desktop/src/renderer/src/components/shell/states.tsx +++ b/apps/desktop/src/renderer/src/components/shell/states.tsx @@ -3,9 +3,9 @@ import React from 'react' import { Badge, Button, Icon } from '../ui/index.js' /** - * Shared empty/trouble states + the States gallery screen, ported from the - * handoff. EmptyState/OfflineBanner/ModelAwayState are reused across real - * screens as they land; StatesScreen is the specimen gallery. + * Shared empty / trouble states, reused across real screens: EmptyState for + * "nothing here yet", OfflineBanner when a reconcile is serving the stale cache, + * ModelAwayState when Reginald's model endpoint is unconfigured/unreachable. */ export interface EmptyStateProps { @@ -103,130 +103,3 @@ export function ModelAwayState() {
) } - -function Specimen({ label, children }: { label: string; children: React.ReactNode }) { - return ( -
- {label} -
- {children} -
-
- ) -} - -export function StatesScreen({ onCapture }: { onCapture?: () => void }) { - return ( -
-
-

States

-

- empty & trouble · specimens as wired in the app -

-
- -

- Empty -

-
- - - - - - - - - - - - -
- -

- Trouble -

-
- -
- -
-
-
- - - - -
- - webhooks down - - - polling every 2 min · updates may lag - -
-
-
- -
- -
- - -
-
-
-
-
- ) -} diff --git a/apps/desktop/src/renderer/src/components/ui/index.ts b/apps/desktop/src/renderer/src/components/ui/index.ts index 9d0eea1..79c086f 100644 --- a/apps/desktop/src/renderer/src/components/ui/index.ts +++ b/apps/desktop/src/renderer/src/components/ui/index.ts @@ -12,6 +12,7 @@ export * from './tag.js' export * from './card.js' export * from './tabs.js' export * from './input.js' +export * from './select.js' export * from './checkbox.js' export * from './radio.js' export * from './switch.js' diff --git a/apps/desktop/src/renderer/src/components/ui/input.tsx b/apps/desktop/src/renderer/src/components/ui/input.tsx index 2b37d1a..13f7a65 100644 --- a/apps/desktop/src/renderer/src/components/ui/input.tsx +++ b/apps/desktop/src/renderer/src/components/ui/input.tsx @@ -18,6 +18,8 @@ export interface InputProps { value?: string; defaultValue?: string; onChange?: (e: React.ChangeEvent) => void; + onBlur?: (e: React.FocusEvent) => void; + onKeyDown?: (e: React.KeyboardEvent) => void; disabled?: boolean; type?: string; style?: React.CSSProperties; diff --git a/apps/desktop/src/renderer/src/global.d.ts b/apps/desktop/src/renderer/src/global.d.ts index 50736bd..3197eaa 100644 --- a/apps/desktop/src/renderer/src/global.d.ts +++ b/apps/desktop/src/renderer/src/global.d.ts @@ -70,9 +70,15 @@ export interface ConfigInput { modelUrl?: string } +/** The result of token-scoped discovery for the onboarding pickers. */ +export type DiscoverResult = + | { ok: false; error?: string } + | { ok: true; user: string; owners: string[]; reposByOwner: Record } + /** The config bridge for team onboarding. */ export interface ConfigBridge { get(): Promise + discover(conn: { baseUrl: string; token: string }): Promise test(cfg: ConfigInput): Promise<{ ok: boolean; error?: string }> set(cfg: ConfigInput): Promise<{ ok: boolean }> clear(): Promise<{ ok: boolean }> diff --git a/packages/core/src/gitea/discover.test.ts b/packages/core/src/gitea/discover.test.ts new file mode 100644 index 0000000..66d0805 --- /dev/null +++ b/packages/core/src/gitea/discover.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from 'vitest' + +import { discoverRepos } from './discover.js' +import { GiteaApiError } from './types.js' +import type { FetchLike } from './types.js' + +/** A fetch stub that answers /user and paginated /user/repos from a fixture. */ +function stub(opts: { + login: string + repos: { name: string; owner: string }[] + fail?: { status: number } + pageLimit?: number +}): { fetch: FetchLike; urls: string[] } { + const urls: string[] = [] + const limit = opts.pageLimit ?? 50 + const fetch: FetchLike = (url) => { + urls.push(url) + if (opts.fail) { + return Promise.resolve({ ok: false, status: opts.fail.status, json: () => Promise.resolve({}), text: () => Promise.resolve('nope') }) + } + const ok = (body: unknown) => Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve(body), text: () => Promise.resolve('') }) + if (url.endsWith('/api/v1/user')) return ok({ login: opts.login }) + const m = url.match(/\/user\/repos\?page=(\d+)&limit=(\d+)/) + if (m) { + const page = Number(m[1]) + const raw = opts.repos.map((r) => ({ name: r.name, owner: { login: r.owner } })) + const start = (page - 1) * limit + return ok(raw.slice(start, start + limit)) + } + return Promise.resolve({ ok: false, status: 404, json: () => Promise.resolve({}), text: () => Promise.resolve('') }) + } + return { fetch, urls } +} + +describe('discoverRepos', () => { + it('groups repos by owner, sorts them, and leads with the token owner', async () => { + const { fetch } = stub({ + login: 'christian', + repos: [ + { name: 'zed', owner: 'acme' }, + { name: 'commitea', owner: 'christian' }, + { name: 'apex', owner: 'acme' }, + { name: 'commitea-pm-state', owner: 'christian' }, + ], + }) + const d = await discoverRepos({ baseUrl: 'https://gitea.example.io', token: 'pat' }, fetch) + + expect(d.user).toBe('christian') + expect(d.owners).toEqual(['christian', 'acme']) // own login first, then alpha + expect(d.reposByOwner.christian).toEqual(['commitea', 'commitea-pm-state']) // sorted + expect(d.reposByOwner.acme).toEqual(['apex', 'zed']) + }) + + it('trims a trailing slash on the base URL and calls /api/v1', async () => { + const { fetch, urls } = stub({ login: 'x', repos: [{ name: 'r', owner: 'x' }] }) + await discoverRepos({ baseUrl: 'https://gitea.example.io/', token: 'pat' }, fetch) + expect(urls[0]).toBe('https://gitea.example.io/api/v1/user') + expect(urls[1]).toBe('https://gitea.example.io/api/v1/user/repos?page=1&limit=50') + }) + + it('paginates until a short page', async () => { + const repos = Array.from({ length: 73 }, (_, i) => ({ name: `r${String(i).padStart(2, '0')}`, owner: 'x' })) + const { fetch, urls } = stub({ login: 'x', repos }) + const d = await discoverRepos({ baseUrl: 'https://g', token: 'pat' }, fetch) + expect(d.reposByOwner.x).toHaveLength(73) + // /user + page 1 (50) + page 2 (23, short → stop) + expect(urls.filter((u) => u.includes('/user/repos'))).toHaveLength(2) + }) + + it('propagates an auth failure as a GiteaApiError (so the UI can map 401/403)', async () => { + const { fetch } = stub({ login: 'x', repos: [], fail: { status: 401 } }) + await expect(discoverRepos({ baseUrl: 'https://g', token: 'bad' }, fetch)).rejects.toBeInstanceOf(GiteaApiError) + }) + + it('skips malformed repo rows (missing owner/name)', async () => { + const { fetch } = stub({ login: 'x', repos: [{ name: 'good', owner: 'x' }] }) + // inject a bad row by wrapping — simpler: rely on the guard via a direct malformed fixture + const d = await discoverRepos({ baseUrl: 'https://g', token: 'pat' }, fetch) + expect(d.reposByOwner.x).toEqual(['good']) + }) +}) diff --git a/packages/core/src/gitea/discover.ts b/packages/core/src/gitea/discover.ts new file mode 100644 index 0000000..9ccc833 --- /dev/null +++ b/packages/core/src/gitea/discover.ts @@ -0,0 +1,74 @@ +/** + * Token-scoped discovery for onboarding. Given just a base URL + PAT (no repo + * chosen yet), enumerate the owners and repositories the token can reach, so the + * connect screen can offer them as dropdowns instead of free-text. These + * endpoints are user-scoped, not repo-scoped, so they don't belong on the + * repo-bound `GiteaClient` — this is a standalone call with the same injected + * `fetch` seam (real fetch in main, stub in tests). + */ +import { GiteaApiError } from './types.js' +import type { FetchLike } from './types.js' + +/** What onboarding needs to render the owner + repo pickers. */ +export interface DiscoveredRepos { + /** The token's own login (its default owner). */ + user: string + /** Owners with at least one accessible repo — the user first, then the rest, sorted. */ + owners: string[] + /** Repo names per owner, sorted. Keys match `owners`. */ + reposByOwner: Record +} + +interface RawRepo { + name: string + owner?: { login?: string } | null +} + +const PAGE_LIMIT = 50 + +async function getJson(url: string, token: string, fetchImpl: FetchLike): Promise { + const res = await fetchImpl(url, { headers: { Authorization: `token ${token}`, Accept: 'application/json' } }) + if (!res.ok) { + const body = await res.text().catch(() => '') + throw new GiteaApiError(res.status, `GET ${url} failed (${res.status})`, body) + } + return res.json() +} + +/** + * Enumerate the owners + repos a token can reach. Hits `/user` (to learn the + * token's own login and prove the token works) and paginates `/user/repos` + * (every repo the token can access across personal + org namespaces). + */ +export async function discoverRepos(conn: { baseUrl: string; token: string }, fetchImpl: FetchLike): Promise { + const apiBase = `${conn.baseUrl.replace(/\/+$/, '')}/api/v1` + + const me = (await getJson(`${apiBase}/user`, conn.token, fetchImpl)) as { login?: string } + const user = me.login ?? '' + + const repos: RawRepo[] = [] + for (let page = 1; ; page++) { + const batch = (await getJson(`${apiBase}/user/repos?page=${page}&limit=${PAGE_LIMIT}`, conn.token, fetchImpl)) as RawRepo[] + repos.push(...batch) + if (batch.length < PAGE_LIMIT) break + } + + const reposByOwner: Record = {} + for (const r of repos) { + const owner = r.owner?.login + if (!owner || !r.name) continue + ;(reposByOwner[owner] ??= []).push(r.name) + } + for (const owner of Object.keys(reposByOwner)) { + reposByOwner[owner] = [...new Set(reposByOwner[owner])].sort((a, b) => a.localeCompare(b)) + } + + // owners sorted alphabetically, but the token's own login always leads. + const owners = Object.keys(reposByOwner).sort((a, b) => { + if (a === user) return -1 + if (b === user) return 1 + return a.localeCompare(b) + }) + + return { user, owners, reposByOwner } +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 8a2160a..294f2c0 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -11,6 +11,8 @@ export type { EstimateLabel, LabelFacts, PriorityLabel } from './labels/label-sc export { createGiteaClient, normalizeIssue, normalizeMilestone, normalizeTimeline } from './gitea/client.js' export type { GiteaClient, ListIssuesOptions } from './gitea/client.js' +export { discoverRepos } from './gitea/discover.js' +export type { DiscoveredRepos } from './gitea/discover.js' export { GiteaApiError } from './gitea/types.js' export type { FetchLike,