Remove static design mocks — everything live, one demo snapshot for e2e #61
@@ -13,6 +13,7 @@ import { dirname, join } from 'node:path'
|
|||||||
import {
|
import {
|
||||||
appendDirective,
|
appendDirective,
|
||||||
createGiteaClient,
|
createGiteaClient,
|
||||||
|
discoverRepos,
|
||||||
GiteaApiError,
|
GiteaApiError,
|
||||||
type DirectiveEntry,
|
type DirectiveEntry,
|
||||||
type GiteaClient,
|
type GiteaClient,
|
||||||
@@ -359,6 +360,17 @@ export function registerGiteaIpc(): void {
|
|||||||
return { ok: true as const }
|
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.
|
// Validate a token + repo before saving: any authed read on the repo proves access.
|
||||||
ipcMain.handle('config:test', async (_event, cfg: AppConfig) => {
|
ipcMain.handle('config:test', async (_event, cfg: AppConfig) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ const api = {
|
|||||||
config: {
|
config: {
|
||||||
/** The saved config (never the token) — null when unset. */
|
/** The saved config (never the token) — null when unset. */
|
||||||
get: () => ipcRenderer.invoke('config:get'),
|
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. */
|
/** Validate a token + repo before saving. */
|
||||||
test: (cfg: unknown) => ipcRenderer.invoke('config:test', cfg),
|
test: (cfg: unknown) => ipcRenderer.invoke('config:test', cfg),
|
||||||
/** Save config (token encrypted in main); takes effect without restart. */
|
/** Save config (token encrypted in main); takes effect without restart. */
|
||||||
|
|||||||
@@ -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 (
|
|
||||||
<section className="mt-9">
|
|
||||||
<h2 className="mb-3 font-mono text-xs uppercase tracking-wider text-ink-3">{title}</h2>
|
|
||||||
<div className="flex flex-wrap items-start gap-3">{children}</div>
|
|
||||||
</section>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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 (
|
|
||||||
<main className="bg-paper-0 font-sans text-ink-1">
|
|
||||||
<div className="mx-auto max-w-[1120px]">
|
|
||||||
<header className="border-b-[3px] border-double border-ink-1 pb-4">
|
|
||||||
<h1 className="font-display text-4xl">Primitives</h1>
|
|
||||||
<p className="mt-1 font-mono text-sm text-ink-3">
|
|
||||||
15 components · toggle Evening service in the rail for dark
|
|
||||||
</p>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<Section title="Button">
|
|
||||||
<Button variant="primary" icon="sparkles">
|
|
||||||
Brew plan
|
|
||||||
</Button>
|
|
||||||
<Button variant="secondary">Amend</Button>
|
|
||||||
<Button variant="ghost" icon="clock">
|
|
||||||
Defer
|
|
||||||
</Button>
|
|
||||||
<Button variant="danger" icon="trash-2">
|
|
||||||
Withdraw
|
|
||||||
</Button>
|
|
||||||
<Button variant="primary" size="sm" iconRight="arrow-right">
|
|
||||||
Next
|
|
||||||
</Button>
|
|
||||||
<Button variant="secondary" disabled>
|
|
||||||
Disabled
|
|
||||||
</Button>
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
<Section title="IconButton">
|
|
||||||
<IconButton icon="pencil" label="Edit" />
|
|
||||||
<IconButton icon="copy" label="Copy" />
|
|
||||||
<IconButton icon="ellipsis" label="More" variant="outline" />
|
|
||||||
<IconButton icon="x" label="Close" size="sm" />
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
<Section title="Badge">
|
|
||||||
<Badge tone="ok" dot>
|
|
||||||
ahead
|
|
||||||
</Badge>
|
|
||||||
<Badge tone="warn" dot>
|
|
||||||
at risk
|
|
||||||
</Badge>
|
|
||||||
<Badge tone="danger" dot>
|
|
||||||
behind
|
|
||||||
</Badge>
|
|
||||||
<Badge tone="info">steeping</Badge>
|
|
||||||
<Badge tone="neutral">triage</Badge>
|
|
||||||
<Badge tone="jade">on track</Badge>
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
<Section title="Tag">
|
|
||||||
<Tag label="est/3d" />
|
|
||||||
<Tag label="est/8d" />
|
|
||||||
<Tag label="p/1" />
|
|
||||||
<Tag label="p/2" />
|
|
||||||
<Tag label="p/3" />
|
|
||||||
<Tag label="p/4" />
|
|
||||||
<Tag label="deadline/hard" />
|
|
||||||
<Tag label="p/1" onRemove={() => {}} />
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
<Section title="Card">
|
|
||||||
<Card
|
|
||||||
overline="Now"
|
|
||||||
title="Gitea read client"
|
|
||||||
jade
|
|
||||||
actions={<IconButton icon="external-link" label="Open" size="sm" />}
|
|
||||||
footer={
|
|
||||||
<>
|
|
||||||
<Button size="sm" variant="primary">
|
|
||||||
Start
|
|
||||||
</Button>
|
|
||||||
<Button size="sm" variant="ghost">
|
|
||||||
Defer
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
style={{ width: 340 }}
|
|
||||||
>
|
|
||||||
<p className="font-agent text-base leading-relaxed">
|
|
||||||
The tap-root. Everything sinks into it — grab it first.
|
|
||||||
</p>
|
|
||||||
</Card>
|
|
||||||
<Card title="Plain card" style={{ width: 260 }}>
|
|
||||||
<p className="text-sm text-ink-2">Hairline border, whispered shadow.</p>
|
|
||||||
</Card>
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
<Section title="Tabs">
|
|
||||||
<Tabs
|
|
||||||
items={[
|
|
||||||
{ id: 'board', label: 'Board', icon: 'square-kanban', count: 34 },
|
|
||||||
{ id: 'gantt', label: 'Gantt', icon: 'chart-no-axes-gantt' },
|
|
||||||
{ id: 'deps', label: 'Dependencies', icon: 'network', count: 51 },
|
|
||||||
]}
|
|
||||||
active={tab}
|
|
||||||
onChange={setTab}
|
|
||||||
/>
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
<Section title="Icon">
|
|
||||||
<Icon name="coffee" size={20} />
|
|
||||||
<Icon name="leaf" size={20} />
|
|
||||||
<Icon name="git-branch" size={20} />
|
|
||||||
<Icon name="milestone" size={20} />
|
|
||||||
<Icon name="sparkles" size={20} />
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
<Section title="Input & Select">
|
|
||||||
<Input label="Repository" placeholder="christian/commitea" style={{ width: 240 }} />
|
|
||||||
<Input label="Search" icon="search" placeholder="Filter issues" style={{ width: 220 }} />
|
|
||||||
<Input label="Token" mono error="Connection failed" placeholder="tok_xxx" style={{ width: 220 }} />
|
|
||||||
<Select
|
|
||||||
label="Priority"
|
|
||||||
options={[
|
|
||||||
{ value: 'p/1', label: 'p/1 — urgent' },
|
|
||||||
{ value: 'p/2', label: 'p/2' },
|
|
||||||
{ value: 'p/3', label: 'p/3' },
|
|
||||||
]}
|
|
||||||
style={{ width: 200 }}
|
|
||||||
/>
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
<Section title="Checkbox · Radio · Switch">
|
|
||||||
<Checkbox label="Hard deadline" checked={checked} onChange={(e) => setChecked(e.target.checked)} />
|
|
||||||
<Checkbox label="Disabled" checked disabled onChange={() => {}} />
|
|
||||||
<Radio label="Option A" name="g" value="a" checked={radio === 'a'} onChange={() => setRadio('a')} />
|
|
||||||
<Radio label="Option B" name="g" value="b" checked={radio === 'b'} onChange={() => setRadio('b')} />
|
|
||||||
<Switch label="Live sync" checked={on} onChange={(e) => setOn(e.target.checked)} />
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
<Section title="Feedback">
|
|
||||||
<Toast tone="ok" title="Plan brewed" onDismiss={() => {}}>
|
|
||||||
Milestone P2 lands Mar 3–12.
|
|
||||||
</Toast>
|
|
||||||
<Toast tone="warn" title="Stale blocker">
|
|
||||||
#9 has waited 6 days.
|
|
||||||
</Toast>
|
|
||||||
<Tooltip content="Writes to gitea, as you">
|
|
||||||
<Button variant="secondary" icon="info">
|
|
||||||
Hover me
|
|
||||||
</Button>
|
|
||||||
</Tooltip>
|
|
||||||
<Button variant="primary" onClick={() => setDialogOpen(true)}>
|
|
||||||
Open dialog
|
|
||||||
</Button>
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
<Dialog
|
|
||||||
open={dialogOpen}
|
|
||||||
onClose={() => setDialogOpen(false)}
|
|
||||||
title="Withdraw directive?"
|
|
||||||
footer={
|
|
||||||
<>
|
|
||||||
<Button variant="ghost" onClick={() => setDialogOpen(false)}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button variant="danger" onClick={() => setDialogOpen(false)}>
|
|
||||||
Withdraw
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<p>This removes the directive from the ledger. The schedule reverts to its prior state.</p>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,68 +1,81 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
|
||||||
import type { PublicConfig } from '../../global.js'
|
import type { DiscoverResult, PublicConfig } from '../../global.js'
|
||||||
import logoIcon from '../../design/assets/logo-icon.png'
|
import logoIcon from '../../design/assets/logo-icon.png'
|
||||||
import { Button, Card, Input } from '../ui/index.js'
|
import { Button, Card, Input, Select } from '../ui/index.js'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Real onboarding — each teammate connects their own Gitea with a personal access
|
* Real onboarding — each teammate connects their own Gitea with a personal access
|
||||||
* token. The token is validated (an authed read on the repo) then saved encrypted
|
* token. The flow is progressive: enter the instance URL + PAT, and the app
|
||||||
* in the main process (never exposed to this renderer). Optional model URL wires
|
* discovers every owner + repo that token can reach, then you pick them from
|
||||||
* Reginald; leave it blank and chat simply stays off.
|
* dropdowns (no guessing owner/repo strings). Discovery also proves the token
|
||||||
|
* works, so any repo you can pick is one you can access. The token is saved
|
||||||
|
* encrypted in the main process (never exposed to this renderer). Optional model
|
||||||
|
* URL wires Reginald; leave it blank and chat simply stays off.
|
||||||
*/
|
*/
|
||||||
export function ConnectScreen({ onConnected, existing }: { onConnected: () => void; existing?: PublicConfig | null }) {
|
export function ConnectScreen({ onConnected, existing }: { onConnected: () => void; existing?: PublicConfig | null }) {
|
||||||
const [baseUrl, setBaseUrl] = React.useState(existing?.baseUrl ?? 'https://gitea.stephenmann.io')
|
const [baseUrl, setBaseUrl] = React.useState(existing?.baseUrl ?? 'https://gitea.stephenmann.io')
|
||||||
const [owner, setOwner] = React.useState(existing?.owner ?? '')
|
|
||||||
const [repo, setRepo] = React.useState(existing?.repo ?? '')
|
|
||||||
const [token, setToken] = React.useState('')
|
const [token, setToken] = React.useState('')
|
||||||
const [modelUrl, setModelUrl] = React.useState(existing?.modelUrl ?? '')
|
const [modelUrl, setModelUrl] = React.useState(existing?.modelUrl ?? '')
|
||||||
const [state, setState] = React.useState<'idle' | 'testing' | 'saving'>('idle')
|
|
||||||
const [error, setError] = React.useState<string | null>(null)
|
|
||||||
|
|
||||||
const cfg = () => ({ baseUrl, owner, repo, token, modelUrl: modelUrl.trim() || undefined })
|
const [owner, setOwner] = React.useState(existing?.owner ?? '')
|
||||||
const ready = baseUrl.trim() && owner.trim() && repo.trim() && (token.trim() || existing?.hasToken)
|
const [repo, setRepo] = React.useState(existing?.repo ?? '')
|
||||||
|
|
||||||
|
const [discovery, setDiscovery] = React.useState<'idle' | 'discovering' | 'done'>('idle')
|
||||||
|
const [found, setFound] = React.useState<{ owners: string[]; reposByOwner: Record<string, string[]> } | null>(null)
|
||||||
|
const [error, setError] = React.useState<string | null>(null)
|
||||||
|
const [state, setState] = React.useState<'idle' | 'saving'>('idle')
|
||||||
|
|
||||||
|
const mapError = (e?: string) =>
|
||||||
|
e === '401' || e === '403'
|
||||||
|
? 'The token was rejected — check it has repo + issue scopes.'
|
||||||
|
: e === '404'
|
||||||
|
? "Couldn't reach that Gitea — check the URL."
|
||||||
|
: `Discovery failed${e ? ` (${e})` : ''}.`
|
||||||
|
|
||||||
|
// Discover the owners + repos this token can see. Triggered when the token
|
||||||
|
// field blurs (or on Enter), once a URL + token are both present.
|
||||||
|
const discover = async () => {
|
||||||
|
if (!baseUrl.trim() || !token.trim() || discovery === 'discovering') return
|
||||||
|
setError(null)
|
||||||
|
setDiscovery('discovering')
|
||||||
|
const res: DiscoverResult = await window.commitea.config
|
||||||
|
.discover({ baseUrl: baseUrl.trim(), token: token.trim() })
|
||||||
|
.catch(() => ({ ok: false as const, error: 'unreachable' }))
|
||||||
|
if (!res.ok) {
|
||||||
|
setDiscovery('idle')
|
||||||
|
setFound(null)
|
||||||
|
setError(mapError(res.error))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setFound({ owners: res.owners, reposByOwner: res.reposByOwner })
|
||||||
|
setDiscovery('done')
|
||||||
|
// keep the existing selection if it's still valid; else default to the token
|
||||||
|
// owner (res.user) and its first repo.
|
||||||
|
const nextOwner = res.owners.includes(owner) ? owner : (res.owners.includes(res.user) ? res.user : res.owners[0] ?? '')
|
||||||
|
setOwner(nextOwner)
|
||||||
|
const repos = res.reposByOwner[nextOwner] ?? []
|
||||||
|
setRepo(repos.includes(repo) ? repo : repos[0] ?? '')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Option lists. Before discovery, fall back to the existing config so editing
|
||||||
|
// stays functional without re-entering the token.
|
||||||
|
const ownerOptions = found?.owners ?? (existing?.owner ? [existing.owner] : [])
|
||||||
|
const repoOptions = found?.reposByOwner[owner] ?? (owner && owner === existing?.owner && existing?.repo ? [existing.repo] : [])
|
||||||
|
|
||||||
|
const pickerReady = discovery === 'done' || (!!existing && !token.trim())
|
||||||
|
const ready = baseUrl.trim() && owner && repo && (token.trim() || existing?.hasToken)
|
||||||
|
|
||||||
const connect = async () => {
|
const connect = async () => {
|
||||||
setError(null)
|
setError(null)
|
||||||
setState('testing')
|
|
||||||
// reuse the saved token when editing and the field is left blank
|
|
||||||
const payload = token.trim() ? cfg() : { ...cfg(), token: '' }
|
|
||||||
if (token.trim()) {
|
|
||||||
const test = await window.commitea.config.test(cfg()).catch(() => ({ ok: false, error: 'unreachable' }))
|
|
||||||
if (!test.ok) {
|
|
||||||
setState('idle')
|
|
||||||
setError(
|
|
||||||
test.error === '404'
|
|
||||||
? "Couldn't find that repo — check owner/name and that the token can see it."
|
|
||||||
: test.error === '401' || test.error === '403'
|
|
||||||
? 'The token was rejected — check it has repo + issue scopes.'
|
|
||||||
: `Connection failed${test.error ? ` (${test.error})` : ''}.`,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setState('saving')
|
setState('saving')
|
||||||
await window.commitea.config.set(payload)
|
// discovery already validated the token + that this repo is accessible; when
|
||||||
|
// editing without a new token, reuse the saved one (empty token = keep).
|
||||||
|
await window.commitea.config.set({ baseUrl: baseUrl.trim(), owner, repo, token: token.trim(), modelUrl: modelUrl.trim() || undefined })
|
||||||
onConnected()
|
onConnected()
|
||||||
}
|
}
|
||||||
|
|
||||||
const field = (
|
const placeholder = (label: string) => [{ value: '', label }]
|
||||||
label: string,
|
|
||||||
value: string,
|
|
||||||
set: (v: string) => void,
|
|
||||||
opts: { placeholder?: string; type?: string; hint?: string } = {},
|
|
||||||
) => (
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 5 }}>
|
|
||||||
<Input
|
|
||||||
label={label}
|
|
||||||
value={value}
|
|
||||||
type={opts.type}
|
|
||||||
placeholder={opts.placeholder}
|
|
||||||
onChange={(e) => set(e.target.value)}
|
|
||||||
/>
|
|
||||||
{opts.hint ? <span style={{ font: 'var(--text-caption)', color: 'var(--ink-3)' }}>{opts.hint}</span> : null}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -87,19 +100,76 @@ export function ConnectScreen({ onConnected, existing }: { onConnected: () => vo
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
{field('Gitea URL', baseUrl, setBaseUrl, { placeholder: 'https://gitea.example.com' })}
|
<Input
|
||||||
|
label="Gitea URL"
|
||||||
|
value={baseUrl}
|
||||||
|
placeholder="https://gitea.example.com"
|
||||||
|
onChange={(e) => {
|
||||||
|
setBaseUrl(e.target.value)
|
||||||
|
setDiscovery('idle')
|
||||||
|
setFound(null)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Access token"
|
||||||
|
type="password"
|
||||||
|
value={token}
|
||||||
|
placeholder={existing?.hasToken ? '•••••••• (saved — enter a new one to change repos)' : 'gitea PAT · scopes: repo + issue'}
|
||||||
|
hint={
|
||||||
|
discovery === 'discovering'
|
||||||
|
? 'Discovering your repositories…'
|
||||||
|
: discovery === 'done' && found
|
||||||
|
? `Found ${found.owners.reduce((n, o) => 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()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||||||
{field('Owner', owner, setOwner, { placeholder: 'your-org' })}
|
<Select
|
||||||
{field('Repository', repo, setRepo, { placeholder: 'your-repo' })}
|
label="Owner"
|
||||||
|
value={owner}
|
||||||
|
disabled={!pickerReady || ownerOptions.length === 0}
|
||||||
|
options={
|
||||||
|
ownerOptions.length
|
||||||
|
? ownerOptions.map((o) => ({ value: o, label: o }))
|
||||||
|
: placeholder(discovery === 'discovering' ? 'Loading…' : 'Connect token first')
|
||||||
|
}
|
||||||
|
onChange={(e) => {
|
||||||
|
const o = e.target.value
|
||||||
|
setOwner(o)
|
||||||
|
const repos = found?.reposByOwner[o] ?? []
|
||||||
|
setRepo(repos[0] ?? '')
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="Repository"
|
||||||
|
value={repo}
|
||||||
|
disabled={!pickerReady || repoOptions.length === 0}
|
||||||
|
options={
|
||||||
|
repoOptions.length ? repoOptions.map((r) => ({ value: r, label: r })) : placeholder('—')
|
||||||
|
}
|
||||||
|
onChange={(e) => setRepo(e.target.value)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
{field('Access token', token, setToken, {
|
|
||||||
type: 'password',
|
<Input
|
||||||
placeholder: existing?.hasToken ? '•••••••• (saved — leave blank to keep)' : 'gitea PAT · scopes: repo + issue',
|
label="Model URL"
|
||||||
})}
|
value={modelUrl}
|
||||||
{field('Model URL', modelUrl, setModelUrl, {
|
placeholder="http://localhost:1234/v1"
|
||||||
placeholder: 'http://localhost:1234/v1',
|
hint="Optional — an OpenAI-compatible endpoint for Reginald. Leave blank to keep chat off."
|
||||||
hint: 'Optional — an OpenAI-compatible endpoint for Reginald. Leave blank to keep chat off.',
|
onChange={(e) => setModelUrl(e.target.value)}
|
||||||
})}
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error ? (
|
{error ? (
|
||||||
@@ -108,7 +178,7 @@ export function ConnectScreen({ onConnected, existing }: { onConnected: () => vo
|
|||||||
|
|
||||||
<div style={{ display: 'flex', gap: 10, marginTop: 18 }}>
|
<div style={{ display: 'flex', gap: 10, marginTop: 18 }}>
|
||||||
<Button onClick={() => void connect()} disabled={!ready || state !== 'idle'}>
|
<Button onClick={() => void connect()} disabled={!ready || state !== 'idle'}>
|
||||||
{state === 'testing' ? 'Checking…' : state === 'saving' ? 'Connecting…' : existing ? 'Save' : 'Connect'}
|
{state === 'saving' ? 'Connecting…' : existing ? 'Save' : 'Connect'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ import { inboxView } from '../../lib/views/inbox-view.js'
|
|||||||
import { issueDetailView } from '../../lib/views/issue-detail.js'
|
import { issueDetailView } from '../../lib/views/issue-detail.js'
|
||||||
import type { ProjectData } from '../../lib/views/project-data.js'
|
import type { ProjectData } from '../../lib/views/project-data.js'
|
||||||
import { standupView } from '../../lib/views/standup-view.js'
|
import { standupView } from '../../lib/views/standup-view.js'
|
||||||
import { PrimitivesGallery } from '../gallery.js'
|
|
||||||
import { BoardScreen } from '../screens/board-screen.js'
|
import { BoardScreen } from '../screens/board-screen.js'
|
||||||
import { CalibrationScreen } from '../screens/calibration-screen.js'
|
import { CalibrationScreen } from '../screens/calibration-screen.js'
|
||||||
import { CaptureScreen } from '../screens/capture-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 { StandupScreen } from '../screens/standup-screen.js'
|
||||||
import { Icon, Switch } from '../ui/index.js'
|
import { Icon, Switch } from '../ui/index.js'
|
||||||
import { ChatPanel } from './chat-panel.js'
|
import { ChatPanel } from './chat-panel.js'
|
||||||
import { PlaceholderScreen } from './placeholder-screen.js'
|
import { OfflineBanner } from './states.js'
|
||||||
import { OfflineBanner, StatesScreen } from './states.js'
|
|
||||||
|
|
||||||
type View =
|
type View =
|
||||||
| 'standup'
|
| 'standup'
|
||||||
@@ -50,9 +48,7 @@ type View =
|
|||||||
| 'runway'
|
| 'runway'
|
||||||
| 'directives'
|
| 'directives'
|
||||||
| 'settings'
|
| 'settings'
|
||||||
| 'states'
|
|
||||||
| 'firstrun'
|
| 'firstrun'
|
||||||
| 'primitives'
|
|
||||||
| 'issue'
|
| 'issue'
|
||||||
| 'calibration'
|
| 'calibration'
|
||||||
| 'milestone'
|
| 'milestone'
|
||||||
@@ -64,52 +60,21 @@ interface NavEntry {
|
|||||||
count?: number | null
|
count?: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
// which phase builds each not-yet-real view (shown on its placeholder)
|
|
||||||
const PHASE: Partial<Record<View, string>> = {
|
|
||||||
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<Record<View, string>> = {
|
|
||||||
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() {
|
export function AppShell() {
|
||||||
const [view, setView] = useState<View>('focus')
|
const [view, setView] = useState<View>('focus')
|
||||||
const [prevView, setPrevView] = useState<View>('focus')
|
const [prevView, setPrevView] = useState<View>('focus')
|
||||||
const [dark, setDark] = useState(false)
|
const [dark, setDark] = useState(false)
|
||||||
const [offline, setOffline] = useState(false)
|
|
||||||
const [issue, setIssue] = useState<IssueRef | null>(null)
|
const [issue, setIssue] = useState<IssueRef | null>(null)
|
||||||
const [readIds, setReadIds] = useState<number[]>([])
|
const [readIds, setReadIds] = useState<number[]>([])
|
||||||
const [milestoneId, setMilestoneId] = useState<number | null>(null)
|
const [milestoneId, setMilestoneId] = useState<number | null>(null)
|
||||||
const [gate, setGate] = useState<'checking' | 'connect' | 'ready'>('checking')
|
const [gate, setGate] = useState<'checking' | 'connect' | 'ready'>('checking')
|
||||||
const [demo, setDemo] = useState(false)
|
|
||||||
const [pubConfig, setPubConfig] = useState<PublicConfig | null>(null)
|
const [pubConfig, setPubConfig] = useState<PublicConfig | null>(null)
|
||||||
const [collaborators, setCollaborators] = useState<{ login: string; name: string }[]>([])
|
const [collaborators, setCollaborators] = useState<{ login: string; name: string }[]>([])
|
||||||
const [backlog, refetchBacklog] = useBacklog()
|
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 capacityMembers = useCapacity()
|
||||||
const workers = capacityWorkers(capacityMembers)
|
const workers = capacityWorkers(capacityMembers)
|
||||||
const boardColumns =
|
const boardColumns =
|
||||||
@@ -161,10 +126,10 @@ export function AppShell() {
|
|||||||
const gantt = projectData ? ganttView(projectData) : undefined
|
const gantt = projectData ? ganttView(projectData) : undefined
|
||||||
const depsGraph = projectData ? depsGraphView(projectData) : undefined
|
const depsGraph = projectData ? depsGraphView(projectData) : undefined
|
||||||
// The rail's unread badge tracks the real inbox once reconciled (still-unread
|
// 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
|
const inboxUnread = inbox
|
||||||
? inbox.filter((n) => n.unread && !readIds.includes(n.id)).length
|
? 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.
|
// The real connected host (from the saved config), for the rail's status line.
|
||||||
const hostLabel = (() => {
|
const hostLabel = (() => {
|
||||||
@@ -185,7 +150,6 @@ export function AppShell() {
|
|||||||
window.commitea.gitea
|
window.commitea.gitea
|
||||||
.status()
|
.status()
|
||||||
.then((s) => {
|
.then((s) => {
|
||||||
setDemo(s.demo)
|
|
||||||
setGate(s.demo || s.configured ? 'ready' : 'connect')
|
setGate(s.demo || s.configured ? 'ready' : 'connect')
|
||||||
})
|
})
|
||||||
.catch(() => setGate('connect'))
|
.catch(() => setGate('connect'))
|
||||||
@@ -367,12 +331,8 @@ export function AppShell() {
|
|||||||
currentMilestoneId={currentIssue?.milestone?.id ?? null}
|
currentMilestoneId={currentIssue?.milestone?.id ?? null}
|
||||||
/>
|
/>
|
||||||
) : null
|
) : null
|
||||||
case 'states':
|
|
||||||
return <StatesScreen onCapture={() => setView('capture')} />
|
|
||||||
case 'primitives':
|
|
||||||
return <PrimitivesGallery />
|
|
||||||
default:
|
default:
|
||||||
return <PlaceholderScreen title={TITLE[view] ?? view} phase={PHASE[view] ?? 'a later phase'} />
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -435,33 +395,25 @@ export function AppShell() {
|
|||||||
|
|
||||||
<div style={{ borderTop: '1px solid var(--line-1)', margin: '10px 8px' }} />
|
<div style={{ borderTop: '1px solid var(--line-1)', margin: '10px 8px' }} />
|
||||||
<NavItem item={{ id: 'settings', label: 'Settings', icon: 'settings-2' }} />
|
<NavItem item={{ id: 'settings', label: 'Settings', icon: 'settings-2' }} />
|
||||||
{/* 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 ? (
|
|
||||||
<>
|
|
||||||
<NavItem item={{ id: 'firstrun', label: 'First run', icon: 'play' }} />
|
|
||||||
<NavItem item={{ id: 'states', label: 'States', icon: 'circle-dashed' }} />
|
|
||||||
<NavItem item={{ id: 'primitives', label: 'Primitives', icon: 'layers' }} />
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div style={{ marginTop: 'auto', padding: '0 12px', display: 'flex', flexDirection: 'column', gap: 12 }}>
|
<div style={{ marginTop: 'auto', padding: '0 12px', display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
<button
|
<div
|
||||||
type="button"
|
title={
|
||||||
onClick={() => setOffline((o) => !o)}
|
backlog.status === 'loading'
|
||||||
title="Toggle the connection (demo)"
|
? `Connecting to ${hostLabel}…`
|
||||||
aria-label={offline ? 'Connection: offline' : 'Connection: online'}
|
: offline
|
||||||
|
? `Offline — reading from cache${staleSince ? ` (since ${new Date(staleSince).toLocaleString()})` : ''}`
|
||||||
|
: `Connected to ${hostLabel}`
|
||||||
|
}
|
||||||
|
aria-label={
|
||||||
|
backlog.status === 'loading' ? 'Connection: connecting' : offline ? 'Connection: offline' : 'Connection: online'
|
||||||
|
}
|
||||||
style={{
|
style={{
|
||||||
font: '400 11px var(--font-mono)',
|
font: '400 11px var(--font-mono)',
|
||||||
color: 'var(--ink-3)',
|
color: 'var(--ink-3)',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: 6,
|
gap: 6,
|
||||||
background: 'none',
|
|
||||||
border: 'none',
|
|
||||||
padding: 0,
|
|
||||||
cursor: 'pointer',
|
|
||||||
textAlign: 'left',
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
@@ -469,12 +421,13 @@ export function AppShell() {
|
|||||||
width: 6,
|
width: 6,
|
||||||
height: 6,
|
height: 6,
|
||||||
borderRadius: '50%',
|
borderRadius: '50%',
|
||||||
background: offline ? 'var(--danger)' : 'var(--ok)',
|
background:
|
||||||
|
backlog.status === 'loading' ? 'var(--warn)' : offline ? 'var(--danger)' : 'var(--ok)',
|
||||||
display: 'inline-block',
|
display: 'inline-block',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{hostLabel}
|
{hostLabel}
|
||||||
</button>
|
</div>
|
||||||
<Switch
|
<Switch
|
||||||
label={<span style={{ font: 'var(--text-caption)' }}>Evening service</span>}
|
label={<span style={{ font: 'var(--text-caption)' }}>Evening service</span>}
|
||||||
checked={dark}
|
checked={dark}
|
||||||
|
|||||||
@@ -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 (
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
|
||||||
<header style={{ borderBottom: 'var(--rule-double)', paddingBottom: 14 }}>
|
|
||||||
<h1 style={{ font: 'var(--text-display)', color: 'var(--ink-1)', margin: 0 }}>{title}</h1>
|
|
||||||
<p style={{ font: 'var(--text-data)', color: 'var(--ink-3)', margin: '6px 0 0' }}>
|
|
||||||
arrives in {phase} · the shell and navigation are live
|
|
||||||
</p>
|
|
||||||
</header>
|
|
||||||
<EmptyState
|
|
||||||
icon="coffee"
|
|
||||||
title="Not yet poured"
|
|
||||||
line="This view is on the boil for a later phase. The rail, routing, theme, and Reginald’s panel are already working around it."
|
|
||||||
/>
|
|
||||||
{onOpenIssue ? (
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'center' }}>
|
|
||||||
<Button variant="ghost" iconRight="arrow-right" onClick={onOpenIssue}>
|
|
||||||
Preview an issue page
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -3,9 +3,9 @@ import React from 'react'
|
|||||||
import { Badge, Button, Icon } from '../ui/index.js'
|
import { Badge, Button, Icon } from '../ui/index.js'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shared empty/trouble states + the States gallery screen, ported from the
|
* Shared empty / trouble states, reused across real screens: EmptyState for
|
||||||
* handoff. EmptyState/OfflineBanner/ModelAwayState are reused across real
|
* "nothing here yet", OfflineBanner when a reconcile is serving the stale cache,
|
||||||
* screens as they land; StatesScreen is the specimen gallery.
|
* ModelAwayState when Reginald's model endpoint is unconfigured/unreachable.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export interface EmptyStateProps {
|
export interface EmptyStateProps {
|
||||||
@@ -103,130 +103,3 @@ export function ModelAwayState() {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function Specimen({ label, children }: { label: string; children: React.ReactNode }) {
|
|
||||||
return (
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
|
||||||
<span style={{ font: '400 11px var(--font-mono)', color: 'var(--ink-3)' }}>{label}</span>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
border: '1px dashed var(--line-2)',
|
|
||||||
borderRadius: 'var(--radius-3)',
|
|
||||||
background: 'var(--surface-card)',
|
|
||||||
overflow: 'hidden',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function StatesScreen({ onCapture }: { onCapture?: () => void }) {
|
|
||||||
return (
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
|
||||||
<header style={{ borderBottom: 'var(--rule-double)', paddingBottom: 14 }}>
|
|
||||||
<h1 style={{ font: 'var(--text-display)', color: 'var(--ink-1)', margin: 0 }}>States</h1>
|
|
||||||
<p style={{ font: 'var(--text-data)', color: 'var(--ink-3)', margin: '6px 0 0' }}>
|
|
||||||
empty & trouble · specimens as wired in the app
|
|
||||||
</p>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<p
|
|
||||||
style={{
|
|
||||||
font: 'var(--text-overline)',
|
|
||||||
letterSpacing: 'var(--letter-spacing-wide)',
|
|
||||||
textTransform: 'uppercase',
|
|
||||||
color: 'var(--ink-3)',
|
|
||||||
margin: '4px 0 0',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Empty
|
|
||||||
</p>
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 14 }}>
|
|
||||||
<Specimen label="the pot · no issues">
|
|
||||||
<EmptyState
|
|
||||||
compact
|
|
||||||
icon="inbox"
|
|
||||||
title="The pot is empty"
|
|
||||||
line="Tell me what you’re planning and I’ll draw up the tickets."
|
|
||||||
action="Start a capture"
|
|
||||||
onAction={onCapture}
|
|
||||||
/>
|
|
||||||
</Specimen>
|
|
||||||
<Specimen label="morning service · nothing scheduled">
|
|
||||||
<EmptyState
|
|
||||||
compact
|
|
||||||
icon="coffee"
|
|
||||||
title="Nothing to pour"
|
|
||||||
line="Capture some work, or enjoy the silence — it never lasts."
|
|
||||||
/>
|
|
||||||
</Specimen>
|
|
||||||
<Specimen label="directives · no entries">
|
|
||||||
<EmptyState
|
|
||||||
compact
|
|
||||||
icon="flag"
|
|
||||||
title="No directives yet"
|
|
||||||
line="When you overrule the scheduler, it goes on the record here — who, when, what, why."
|
|
||||||
/>
|
|
||||||
</Specimen>
|
|
||||||
<Specimen label="board search · no match">
|
|
||||||
<EmptyState
|
|
||||||
compact
|
|
||||||
icon="search"
|
|
||||||
title="Nothing by that name"
|
|
||||||
line="The pot holds 24 issues; none of them answer to that."
|
|
||||||
/>
|
|
||||||
</Specimen>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p
|
|
||||||
style={{
|
|
||||||
font: 'var(--text-overline)',
|
|
||||||
letterSpacing: 'var(--letter-spacing-wide)',
|
|
||||||
textTransform: 'uppercase',
|
|
||||||
color: 'var(--ink-3)',
|
|
||||||
margin: '8px 0 0',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Trouble
|
|
||||||
</p>
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
|
||||||
<Specimen label="gitea unreachable · banner above main content (toggle the rail’s connection dot to see it live)">
|
|
||||||
<div style={{ padding: 12 }}>
|
|
||||||
<OfflineBanner />
|
|
||||||
</div>
|
|
||||||
</Specimen>
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 14 }}>
|
|
||||||
<Specimen label="reginald’s panel · model offline">
|
|
||||||
<ModelAwayState />
|
|
||||||
</Specimen>
|
|
||||||
<Specimen label="webhooks down · poll fallback">
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '14px 16px' }}>
|
|
||||||
<Badge tone="warn" dot>
|
|
||||||
webhooks down
|
|
||||||
</Badge>
|
|
||||||
<span style={{ font: '400 11.5px var(--font-mono)', color: 'var(--ink-2)' }}>
|
|
||||||
polling every 2 min · updates may lag
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</Specimen>
|
|
||||||
</div>
|
|
||||||
<Specimen label="first reconcile failed · full-screen">
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', padding: '8px 0 20px' }}>
|
|
||||||
<EmptyState
|
|
||||||
compact
|
|
||||||
icon="refresh-cw"
|
|
||||||
title="The reconcile failed"
|
|
||||||
line="Gitea answered, then hung up mid-sentence. Your cache is intact; nothing human is lost."
|
|
||||||
/>
|
|
||||||
<div style={{ display: 'flex', gap: 8, marginTop: -6 }}>
|
|
||||||
<Button>Try again</Button>
|
|
||||||
<Button variant="ghost">Work from cache</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Specimen>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export * from './tag.js'
|
|||||||
export * from './card.js'
|
export * from './card.js'
|
||||||
export * from './tabs.js'
|
export * from './tabs.js'
|
||||||
export * from './input.js'
|
export * from './input.js'
|
||||||
|
export * from './select.js'
|
||||||
export * from './checkbox.js'
|
export * from './checkbox.js'
|
||||||
export * from './radio.js'
|
export * from './radio.js'
|
||||||
export * from './switch.js'
|
export * from './switch.js'
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ export interface InputProps {
|
|||||||
value?: string;
|
value?: string;
|
||||||
defaultValue?: string;
|
defaultValue?: string;
|
||||||
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||||
|
onBlur?: (e: React.FocusEvent<HTMLInputElement>) => void;
|
||||||
|
onKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
type?: string;
|
type?: string;
|
||||||
style?: React.CSSProperties;
|
style?: React.CSSProperties;
|
||||||
|
|||||||
6
apps/desktop/src/renderer/src/global.d.ts
vendored
6
apps/desktop/src/renderer/src/global.d.ts
vendored
@@ -70,9 +70,15 @@ export interface ConfigInput {
|
|||||||
modelUrl?: string
|
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<string, string[]> }
|
||||||
|
|
||||||
/** The config bridge for team onboarding. */
|
/** The config bridge for team onboarding. */
|
||||||
export interface ConfigBridge {
|
export interface ConfigBridge {
|
||||||
get(): Promise<PublicConfig | null>
|
get(): Promise<PublicConfig | null>
|
||||||
|
discover(conn: { baseUrl: string; token: string }): Promise<DiscoverResult>
|
||||||
test(cfg: ConfigInput): Promise<{ ok: boolean; error?: string }>
|
test(cfg: ConfigInput): Promise<{ ok: boolean; error?: string }>
|
||||||
set(cfg: ConfigInput): Promise<{ ok: boolean }>
|
set(cfg: ConfigInput): Promise<{ ok: boolean }>
|
||||||
clear(): Promise<{ ok: boolean }>
|
clear(): Promise<{ ok: boolean }>
|
||||||
|
|||||||
81
packages/core/src/gitea/discover.test.ts
Normal file
81
packages/core/src/gitea/discover.test.ts
Normal file
@@ -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'])
|
||||||
|
})
|
||||||
|
})
|
||||||
74
packages/core/src/gitea/discover.ts
Normal file
74
packages/core/src/gitea/discover.ts
Normal file
@@ -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<string, string[]>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RawRepo {
|
||||||
|
name: string
|
||||||
|
owner?: { login?: string } | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const PAGE_LIMIT = 50
|
||||||
|
|
||||||
|
async function getJson(url: string, token: string, fetchImpl: FetchLike): Promise<unknown> {
|
||||||
|
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<DiscoveredRepos> {
|
||||||
|
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<string, string[]> = {}
|
||||||
|
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 }
|
||||||
|
}
|
||||||
@@ -11,6 +11,8 @@ export type { EstimateLabel, LabelFacts, PriorityLabel } from './labels/label-sc
|
|||||||
|
|
||||||
export { createGiteaClient, normalizeIssue, normalizeMilestone, normalizeTimeline } from './gitea/client.js'
|
export { createGiteaClient, normalizeIssue, normalizeMilestone, normalizeTimeline } from './gitea/client.js'
|
||||||
export type { GiteaClient, ListIssuesOptions } 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 { GiteaApiError } from './gitea/types.js'
|
||||||
export type {
|
export type {
|
||||||
FetchLike,
|
FetchLike,
|
||||||
|
|||||||
Reference in New Issue
Block a user