mirror of
https://github.com/stablyai/orca.git
synced 2026-10-19 00:03:20 +00:00
871 lines
31 KiB
TypeScript
871 lines
31 KiB
TypeScript
/* eslint-disable max-lines -- Why: project picker handles pinned, recent, browse-all listing, paste-to-add, view selection, and accessibility-related orchestration in one place to keep the entry-point flow coherent. */
|
|
// Why: the picker is the only v1 entry point for switching projects (no
|
|
// header tab strip). Pinned + Recent come from settings; Browse all lazy-loads
|
|
// from `listAccessibleProjects` and is cached for 5 minutes. Paste-to-add
|
|
// accepts org/user project URLs and `owner/number` shorthand.
|
|
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
|
import { AlertTriangle, ChevronDown, Loader, Pin, Search } from 'lucide-react'
|
|
import { toast } from 'sonner'
|
|
import { GhAuthErrorHelp } from '@/components/github-project/GhAuthErrorHelp'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Input } from '@/components/ui/input'
|
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
|
import { cn } from '@/lib/utils'
|
|
import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client'
|
|
import { useAppStore } from '@/store'
|
|
import { useMountedRef } from '@/hooks/useMountedRef'
|
|
import type {
|
|
GitHubProjectOwnerType,
|
|
GitHubProjectSettings,
|
|
GitHubProjectSummary,
|
|
GitHubProjectViewError,
|
|
GitHubProjectViewSummary,
|
|
ListAccessibleProjectsResult,
|
|
ListProjectViewsResult,
|
|
ResolveProjectRefResult
|
|
} from '../../../../shared/github-project-types'
|
|
import {
|
|
GITHUB_PROJECT_REF_INPUT_TOO_LARGE_ERROR,
|
|
hasBoundedGitHubProjectRefInputText,
|
|
isGitHubProjectRefInputTooLarge
|
|
} from '../../../../shared/github-project-ref-input'
|
|
import { filterGitHubProjectPickerProjects } from './github-project-picker-filter'
|
|
import {
|
|
getProjectPickerBrowseCacheEntry,
|
|
peekProjectPickerBrowseCacheEntry,
|
|
rememberProjectPickerBrowseCacheEntry
|
|
} from './project-picker-browse-cache'
|
|
import { translate } from '@/i18n/i18n'
|
|
import {
|
|
githubProjectHost,
|
|
githubProjectIdentityKey
|
|
} from '../../../../shared/github-project-identity'
|
|
|
|
export type ResolvedProjectSelection = {
|
|
owner: string
|
|
ownerType: GitHubProjectOwnerType
|
|
projectNumber: number
|
|
host?: string
|
|
viewId?: string
|
|
}
|
|
|
|
type Props = {
|
|
activeProject: {
|
|
owner: string
|
|
ownerType: GitHubProjectOwnerType
|
|
number: number
|
|
host?: string
|
|
title?: string
|
|
} | null
|
|
onSelect: (selection: ResolvedProjectSelection) => void
|
|
}
|
|
|
|
function getProjectPickerRuntimeScope(
|
|
settings: Parameters<typeof getActiveRuntimeTarget>[0]
|
|
): string {
|
|
const target = getActiveRuntimeTarget(settings)
|
|
return target.kind === 'environment' ? `runtime:${target.environmentId}` : 'local'
|
|
}
|
|
|
|
async function listAccessibleProjectsForRuntime(
|
|
settings: Parameters<typeof getActiveRuntimeTarget>[0]
|
|
): Promise<ListAccessibleProjectsResult> {
|
|
const target = getActiveRuntimeTarget(settings)
|
|
const args = { host: 'github.com' }
|
|
return target.kind === 'environment'
|
|
? callRuntimeRpc<ListAccessibleProjectsResult>(target, 'github.project.listAccessible', args, {
|
|
timeoutMs: 60_000
|
|
})
|
|
: window.api.gh.listAccessibleProjects(args)
|
|
}
|
|
|
|
async function listProjectViewsForRuntime(
|
|
settings: Parameters<typeof getActiveRuntimeTarget>[0],
|
|
args: {
|
|
owner: string
|
|
ownerType: GitHubProjectOwnerType
|
|
projectNumber: number
|
|
host?: string
|
|
}
|
|
): Promise<ListProjectViewsResult> {
|
|
const target = getActiveRuntimeTarget(settings)
|
|
return target.kind === 'environment'
|
|
? callRuntimeRpc<ListProjectViewsResult>(target, 'github.project.listViews', args, {
|
|
timeoutMs: 30_000
|
|
})
|
|
: window.api.gh.listProjectViews(args)
|
|
}
|
|
|
|
async function resolveProjectRefForRuntime(
|
|
settings: Parameters<typeof getActiveRuntimeTarget>[0],
|
|
input: string,
|
|
host?: string
|
|
): Promise<ResolveProjectRefResult> {
|
|
const target = getActiveRuntimeTarget(settings)
|
|
return target.kind === 'environment'
|
|
? callRuntimeRpc<ResolveProjectRefResult>(
|
|
target,
|
|
'github.project.resolveRef',
|
|
{ input, ...(host ? { host } : {}) },
|
|
{ timeoutMs: 30_000 }
|
|
)
|
|
: window.api.gh.resolveProjectRef({ input, ...(host ? { host } : {}) })
|
|
}
|
|
|
|
export default function ProjectPicker({ activeProject, onSelect }: Props): React.JSX.Element {
|
|
const settings = useAppStore((s) => s.settings)
|
|
const updateSettings = useAppStore((s) => s.updateSettings)
|
|
const mountedRef = useMountedRef()
|
|
const projectSettings: GitHubProjectSettings = useMemo(
|
|
() =>
|
|
settings?.githubProjects ?? {
|
|
pinned: [],
|
|
recent: [],
|
|
lastViewByProject: {},
|
|
activeProject: null
|
|
},
|
|
[settings?.githubProjects]
|
|
)
|
|
|
|
const [open, setOpen] = useState(false)
|
|
const [query, setQuery] = useState('')
|
|
const [browseLoading, setBrowseLoading] = useState(false)
|
|
const [browseError, setBrowseError] = useState<GitHubProjectViewError | null>(null)
|
|
const browseCache = peekProjectPickerBrowseCacheEntry(getProjectPickerRuntimeScope(settings))
|
|
const [browseProjects, setBrowseProjects] = useState<GitHubProjectSummary[]>(
|
|
() => browseCache?.projects ?? []
|
|
)
|
|
// Why: partial-failures are cached alongside projects so dismissing the
|
|
// popover and reopening within the 5min window doesn't flicker the
|
|
// banner back. Populated only when discovery succeeded but a subset of
|
|
// orgs failed (the 504 path the user reported).
|
|
const [partialFailures, setPartialFailures] = useState<{ owner: string; message: string }[]>(
|
|
() => browseCache?.partialFailures ?? []
|
|
)
|
|
const [pasteInput, setPasteInput] = useState('')
|
|
const [pasteError, setPasteError] = useState<string | null>(null)
|
|
const [pasteBusy, setPasteBusy] = useState(false)
|
|
|
|
// View-pick step state.
|
|
const [viewPickFor, setViewPickFor] = useState<ResolvedProjectSelection | null>(null)
|
|
const [viewList, setViewList] = useState<GitHubProjectViewSummary[]>([])
|
|
const [viewLoading, setViewLoading] = useState(false)
|
|
|
|
const loadBrowse = useCallback(async () => {
|
|
const cacheKey = getProjectPickerRuntimeScope(settings)
|
|
const cached = getProjectPickerBrowseCacheEntry(cacheKey)
|
|
if (cached) {
|
|
setBrowseProjects(cached.projects)
|
|
setPartialFailures(cached.partialFailures ?? [])
|
|
return
|
|
}
|
|
setBrowseLoading(true)
|
|
setBrowseError(null)
|
|
try {
|
|
const res = await listAccessibleProjectsForRuntime(settings)
|
|
if (res.ok) {
|
|
rememberProjectPickerBrowseCacheEntry(cacheKey, {
|
|
projects: res.projects,
|
|
partialFailures: res.partialFailures
|
|
})
|
|
if (!mountedRef.current) {
|
|
return
|
|
}
|
|
setBrowseProjects(res.projects)
|
|
setPartialFailures(res.partialFailures ?? [])
|
|
} else {
|
|
if (!mountedRef.current) {
|
|
return
|
|
}
|
|
setBrowseError(res.error)
|
|
}
|
|
} catch (err) {
|
|
if (mountedRef.current) {
|
|
setBrowseError({
|
|
type: 'unknown',
|
|
message: err instanceof Error ? err.message : 'Failed to list projects'
|
|
})
|
|
}
|
|
} finally {
|
|
if (mountedRef.current) {
|
|
setBrowseLoading(false)
|
|
}
|
|
}
|
|
}, [mountedRef, settings])
|
|
|
|
useEffect(() => {
|
|
if (open && !viewPickFor) {
|
|
void loadBrowse()
|
|
}
|
|
}, [open, viewPickFor, loadBrowse])
|
|
|
|
const updateProjectSettings = useCallback(
|
|
async (mutate: (prev: GitHubProjectSettings) => GitHubProjectSettings) => {
|
|
const prev = projectSettings
|
|
const next = mutate(prev)
|
|
// Why: settings deep-merges only notifications; write the full
|
|
// githubProjects object so sibling fields (pinned/recent/lastView/active)
|
|
// are not clobbered by a partial write.
|
|
await updateSettings({ githubProjects: next })
|
|
},
|
|
[projectSettings, updateSettings]
|
|
)
|
|
|
|
const commitSelection = useCallback(
|
|
async (selection: ResolvedProjectSelection, title: string | null) => {
|
|
const key = githubProjectIdentityKey({
|
|
owner: selection.owner,
|
|
ownerType: selection.ownerType,
|
|
number: selection.projectNumber,
|
|
host: selection.host
|
|
})
|
|
await updateProjectSettings((prev) => {
|
|
const recent = [
|
|
{
|
|
owner: selection.owner,
|
|
ownerType: selection.ownerType,
|
|
number: selection.projectNumber,
|
|
host: githubProjectHost(selection.host),
|
|
lastOpenedAt: new Date().toISOString()
|
|
},
|
|
...prev.recent.filter((r) => githubProjectIdentityKey(r) !== key)
|
|
].slice(0, 10)
|
|
const lastViewByProject = { ...prev.lastViewByProject }
|
|
if (selection.viewId) {
|
|
lastViewByProject[key] = { viewId: selection.viewId }
|
|
}
|
|
return {
|
|
...prev,
|
|
recent,
|
|
lastViewByProject,
|
|
activeProject: {
|
|
owner: selection.owner,
|
|
ownerType: selection.ownerType,
|
|
number: selection.projectNumber,
|
|
host: githubProjectHost(selection.host)
|
|
}
|
|
}
|
|
})
|
|
if (!mountedRef.current) {
|
|
return
|
|
}
|
|
onSelect(selection)
|
|
setOpen(false)
|
|
setQuery('')
|
|
setViewPickFor(null)
|
|
void title
|
|
},
|
|
[mountedRef, onSelect, updateProjectSettings]
|
|
)
|
|
|
|
const handleChooseProject = useCallback(
|
|
async (selection: {
|
|
owner: string
|
|
ownerType: GitHubProjectOwnerType
|
|
number: number
|
|
host?: string
|
|
title?: string
|
|
// Why: when the paste resolver parsed a /views/{n} URL, the caller
|
|
// passes the view number through so we can skip the view-pick step
|
|
// and commit directly once listProjectViews returns the matching id.
|
|
viewNumber?: number
|
|
}) => {
|
|
const key = githubProjectIdentityKey(selection)
|
|
const lastView = projectSettings.lastViewByProject[key]?.viewId
|
|
// Why: an explicit viewNumber from the URL takes precedence over the
|
|
// remembered last view — the user's intent (paste this exact view) wins
|
|
// over the heuristic (re-open the last view they used).
|
|
if (lastView && selection.viewNumber === undefined) {
|
|
await commitSelection(
|
|
{
|
|
owner: selection.owner,
|
|
ownerType: selection.ownerType,
|
|
projectNumber: selection.number,
|
|
host: githubProjectHost(selection.host),
|
|
viewId: lastView
|
|
},
|
|
selection.title ?? null
|
|
)
|
|
return
|
|
}
|
|
// No prior view (or explicit viewNumber from URL) — load views.
|
|
setViewPickFor({
|
|
owner: selection.owner,
|
|
ownerType: selection.ownerType,
|
|
projectNumber: selection.number,
|
|
host: githubProjectHost(selection.host)
|
|
})
|
|
setViewLoading(true)
|
|
try {
|
|
const res = await listProjectViewsForRuntime(settings, {
|
|
owner: selection.owner,
|
|
ownerType: selection.ownerType,
|
|
projectNumber: selection.number,
|
|
host: githubProjectHost(selection.host)
|
|
})
|
|
if (!mountedRef.current) {
|
|
return
|
|
}
|
|
if (res.ok) {
|
|
setViewList(res.views)
|
|
if (selection.viewNumber !== undefined) {
|
|
// Why: the URL pinned a specific view number — find its id and
|
|
// commit directly, bypassing the view-pick step. If the number
|
|
// doesn't match any view (deleted/renumbered), fall through to
|
|
// the picker so the user can choose another view.
|
|
const match = res.views.find((v) => v.number === selection.viewNumber)
|
|
if (match) {
|
|
await commitSelection(
|
|
{
|
|
owner: selection.owner,
|
|
ownerType: selection.ownerType,
|
|
projectNumber: selection.number,
|
|
host: githubProjectHost(selection.host),
|
|
viewId: match.id
|
|
},
|
|
selection.title ?? null
|
|
)
|
|
}
|
|
}
|
|
} else {
|
|
setViewList([])
|
|
toast.error(res.error.message)
|
|
}
|
|
} catch (err) {
|
|
// Why: IPC transport errors (channel disconnect, serialization
|
|
// failure) propagate as rejected promises and would otherwise become
|
|
// unhandled rejections — leaving the picker stuck on the view-pick
|
|
// step with a perpetual spinner. Treat as an empty result and toast
|
|
// a transport-level message so the user can retry or paste again.
|
|
if (mountedRef.current) {
|
|
setViewList([])
|
|
toast.error(
|
|
translate(
|
|
'auto.components.github.project.ProjectPicker.44b2c6326b',
|
|
'Failed to load views: {{value0}}',
|
|
{ value0: err instanceof Error ? err.message : String(err) }
|
|
)
|
|
)
|
|
}
|
|
} finally {
|
|
if (mountedRef.current) {
|
|
setViewLoading(false)
|
|
}
|
|
}
|
|
},
|
|
[commitSelection, mountedRef, projectSettings.lastViewByProject, settings]
|
|
)
|
|
|
|
const handlePaste = useCallback(async () => {
|
|
if (isGitHubProjectRefInputTooLarge(pasteInput)) {
|
|
setPasteError(GITHUB_PROJECT_REF_INPUT_TOO_LARGE_ERROR)
|
|
return
|
|
}
|
|
const input = pasteInput.trim()
|
|
const parsed = parseProjectInput(input)
|
|
if (!parsed) {
|
|
setPasteError('Expected a project URL or owner/number')
|
|
return
|
|
}
|
|
setPasteError(null)
|
|
setPasteBusy(true)
|
|
try {
|
|
const res = await resolveProjectRefForRuntime(settings, input, parsed.host)
|
|
if (!mountedRef.current) {
|
|
return
|
|
}
|
|
if (!res.ok) {
|
|
setPasteError(res.error.message)
|
|
return
|
|
}
|
|
setPasteInput('')
|
|
await handleChooseProject({
|
|
owner: res.owner,
|
|
ownerType: res.ownerType,
|
|
number: res.number,
|
|
host: githubProjectHost(res.host ?? parsed.host),
|
|
title: res.title,
|
|
// Why: forward the parsed view number from /views/{n} URLs so the
|
|
// chooser can skip the view-pick step and commit directly.
|
|
...(res.viewNumber !== undefined ? { viewNumber: res.viewNumber } : {})
|
|
})
|
|
} finally {
|
|
if (mountedRef.current) {
|
|
setPasteBusy(false)
|
|
}
|
|
}
|
|
}, [handleChooseProject, mountedRef, pasteInput, settings])
|
|
|
|
const canSubmitPasteInput = !pasteBusy && hasBoundedGitHubProjectRefInputText(pasteInput)
|
|
|
|
const filteredBrowse = useMemo(() => {
|
|
return filterGitHubProjectPickerProjects({
|
|
projects: browseProjects,
|
|
pinned: projectSettings.pinned,
|
|
recent: projectSettings.recent,
|
|
query
|
|
})
|
|
}, [browseProjects, projectSettings.pinned, projectSettings.recent, query])
|
|
|
|
const buttonLabel = activeProject
|
|
? `${activeProject.owner} / ${activeProject.title ?? `#${activeProject.number}`}`
|
|
: 'Choose a project'
|
|
|
|
return (
|
|
<Popover open={open} onOpenChange={setOpen}>
|
|
<PopoverTrigger asChild>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-8 gap-1 border-border/50 bg-transparent text-xs"
|
|
>
|
|
<span className="truncate">{buttonLabel}</span>
|
|
<ChevronDown className="size-3.5" />
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="w-[360px] p-0" align="start">
|
|
{viewPickFor ? (
|
|
<ViewPickStep
|
|
loading={viewLoading}
|
|
views={viewList}
|
|
onPick={async (view) => {
|
|
await commitSelection({ ...viewPickFor, viewId: view.id }, null)
|
|
}}
|
|
onBack={() => setViewPickFor(null)}
|
|
/>
|
|
) : (
|
|
<div className="flex flex-col">
|
|
<div className="border-b border-border/50 p-2">
|
|
<div className="relative">
|
|
<Search className="pointer-events-none absolute left-2 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
|
|
<Input
|
|
value={query}
|
|
onChange={(e) => setQuery(e.target.value)}
|
|
placeholder={translate(
|
|
'auto.components.github.project.ProjectPicker.f492e1b539',
|
|
'Search projects'
|
|
)}
|
|
className="h-8 pl-7 text-xs"
|
|
/>
|
|
</div>
|
|
</div>
|
|
{browseError ? <AuthErrorBanner error={browseError} /> : null}
|
|
{!browseError && partialFailures.length > 0 ? (
|
|
<PartialFailuresBanner failures={partialFailures} />
|
|
) : null}
|
|
<div className="max-h-[340px] overflow-y-auto p-1 scrollbar-sleek">
|
|
{projectSettings.pinned.length > 0 ? (
|
|
<Section
|
|
label={translate(
|
|
'auto.components.github.project.ProjectPicker.707843206c',
|
|
'Pinned'
|
|
)}
|
|
>
|
|
{projectSettings.pinned.map((p) => {
|
|
const key = githubProjectIdentityKey(p)
|
|
const knownGood = projectSettings.lastViewByProject[key]?.viewId != null
|
|
const match = browseProjects.find((bp) => githubProjectIdentityKey(bp) === key)
|
|
return (
|
|
<PickerRow
|
|
key={key}
|
|
title={match?.title ?? `#${p.number}`}
|
|
subtitle={`${p.owner}`}
|
|
zombie={!knownGood}
|
|
onClick={() =>
|
|
handleChooseProject({
|
|
owner: p.owner,
|
|
ownerType: p.ownerType,
|
|
number: p.number,
|
|
host: githubProjectHost(p.host),
|
|
title: match?.title
|
|
})
|
|
}
|
|
onRemovePin={async () => {
|
|
await updateProjectSettings((prev) => ({
|
|
...prev,
|
|
pinned: prev.pinned.filter((x) => githubProjectIdentityKey(x) !== key)
|
|
}))
|
|
}}
|
|
/>
|
|
)
|
|
})}
|
|
</Section>
|
|
) : null}
|
|
{projectSettings.recent.length > 0 ? (
|
|
<Section
|
|
label={translate(
|
|
'auto.components.github.project.ProjectPicker.b3044b7a25',
|
|
'Recent'
|
|
)}
|
|
>
|
|
{projectSettings.recent
|
|
.filter(
|
|
(r) =>
|
|
!projectSettings.pinned.some(
|
|
(p) => githubProjectIdentityKey(p) === githubProjectIdentityKey(r)
|
|
)
|
|
)
|
|
.map((r) => {
|
|
const key = githubProjectIdentityKey(r)
|
|
const match = browseProjects.find(
|
|
(bp) => githubProjectIdentityKey(bp) === key
|
|
)
|
|
const pinnable = projectSettings.lastViewByProject[key]?.viewId != null
|
|
return (
|
|
<PickerRow
|
|
key={key}
|
|
title={match?.title ?? `#${r.number}`}
|
|
subtitle={r.owner}
|
|
canPin={pinnable}
|
|
onPin={async () => {
|
|
await updateProjectSettings((prev) => ({
|
|
...prev,
|
|
pinned: [
|
|
...prev.pinned,
|
|
{
|
|
owner: r.owner,
|
|
ownerType: r.ownerType,
|
|
number: r.number,
|
|
host: githubProjectHost(r.host)
|
|
}
|
|
].slice(0, 20)
|
|
}))
|
|
}}
|
|
onClick={() =>
|
|
handleChooseProject({
|
|
owner: r.owner,
|
|
ownerType: r.ownerType,
|
|
number: r.number,
|
|
host: githubProjectHost(r.host),
|
|
title: match?.title
|
|
})
|
|
}
|
|
/>
|
|
)
|
|
})}
|
|
</Section>
|
|
) : null}
|
|
<Section
|
|
label={
|
|
browseLoading
|
|
? translate(
|
|
'auto.components.github.project.ProjectPicker.ba0ab9a117',
|
|
'Browse all (loading…)'
|
|
)
|
|
: translate(
|
|
'auto.components.github.project.ProjectPicker.b787682111',
|
|
'Browse all'
|
|
)
|
|
}
|
|
>
|
|
{browseLoading ? (
|
|
<div className="flex items-center gap-2 px-2 py-2 text-xs text-muted-foreground">
|
|
<Loader className="size-3 animate-spin" />{' '}
|
|
{translate(
|
|
'auto.components.github.project.ProjectPicker.7b6d39627e',
|
|
'Loading…'
|
|
)}
|
|
</div>
|
|
) : null}
|
|
{filteredBrowse.map((p) => (
|
|
<PickerRow
|
|
key={githubProjectIdentityKey(p)}
|
|
title={p.title}
|
|
subtitle={p.owner}
|
|
onClick={() =>
|
|
handleChooseProject({
|
|
owner: p.owner,
|
|
ownerType: p.ownerType,
|
|
number: p.number,
|
|
host: githubProjectHost(p.host),
|
|
title: p.title
|
|
})
|
|
}
|
|
/>
|
|
))}
|
|
</Section>
|
|
</div>
|
|
<div className="border-t border-border/50 p-2">
|
|
<div className="flex gap-2">
|
|
<Input
|
|
value={pasteInput}
|
|
onChange={(e) => {
|
|
const nextInput = e.target.value
|
|
setPasteInput(nextInput)
|
|
setPasteError(
|
|
isGitHubProjectRefInputTooLarge(nextInput)
|
|
? GITHUB_PROJECT_REF_INPUT_TOO_LARGE_ERROR
|
|
: null
|
|
)
|
|
}}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter') {
|
|
void handlePaste()
|
|
}
|
|
}}
|
|
placeholder={translate(
|
|
'auto.components.github.project.ProjectPicker.5113ecc298',
|
|
'Add by URL or owner/number'
|
|
)}
|
|
className="h-8 text-xs"
|
|
/>
|
|
<Button
|
|
size="sm"
|
|
onClick={() => void handlePaste()}
|
|
disabled={!canSubmitPasteInput}
|
|
className="h-8"
|
|
>
|
|
{translate('auto.components.github.project.ProjectPicker.fce99a24a7', 'Add')}
|
|
</Button>
|
|
</div>
|
|
{pasteError ? (
|
|
<div className="mt-1 text-[11px] text-destructive">{pasteError}</div>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</PopoverContent>
|
|
</Popover>
|
|
)
|
|
}
|
|
|
|
function Section({
|
|
label,
|
|
children
|
|
}: {
|
|
label: string
|
|
children: React.ReactNode
|
|
}): React.JSX.Element {
|
|
return (
|
|
<div className="py-1">
|
|
<div className="px-2 pb-0.5 text-[10px] uppercase tracking-wide text-muted-foreground">
|
|
{label}
|
|
</div>
|
|
{children}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function PickerRow({
|
|
title,
|
|
subtitle,
|
|
onClick,
|
|
zombie,
|
|
canPin,
|
|
onPin,
|
|
onRemovePin
|
|
}: {
|
|
title: string
|
|
subtitle: string
|
|
onClick: () => void
|
|
zombie?: boolean
|
|
canPin?: boolean
|
|
onPin?: () => void
|
|
onRemovePin?: () => void
|
|
}): React.JSX.Element {
|
|
return (
|
|
<div className="group flex items-center gap-2 rounded px-2 py-1 hover:bg-muted/50">
|
|
<button type="button" onClick={onClick} className="flex flex-1 min-w-0 flex-col text-left">
|
|
<span className="truncate text-sm">{title}</span>
|
|
<span className="truncate text-[10px] text-muted-foreground">{subtitle}</span>
|
|
</button>
|
|
{zombie ? (
|
|
<div className="flex items-center gap-1">
|
|
<AlertTriangle className="size-3.5 text-amber-500" />
|
|
<button
|
|
type="button"
|
|
className="text-[10px] text-muted-foreground hover:text-foreground"
|
|
onClick={onRemovePin}
|
|
>
|
|
{translate('auto.components.github.project.ProjectPicker.5009ffc2f3', 'Remove pin')}
|
|
</button>
|
|
</div>
|
|
) : null}
|
|
{canPin ? (
|
|
<button
|
|
type="button"
|
|
title={translate('auto.components.github.project.ProjectPicker.8ab5447c64', 'Pin')}
|
|
className="can-hover:opacity-0 group-hover:opacity-100"
|
|
onClick={onPin}
|
|
>
|
|
<Pin className="size-3.5" />
|
|
</button>
|
|
) : null}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function ViewPickStep({
|
|
loading,
|
|
views,
|
|
onPick,
|
|
onBack
|
|
}: {
|
|
loading: boolean
|
|
views: GitHubProjectViewSummary[]
|
|
onPick: (view: GitHubProjectViewSummary) => void | Promise<void>
|
|
onBack: () => void
|
|
}): React.JSX.Element {
|
|
return (
|
|
<div className="flex flex-col">
|
|
<div className="flex items-center justify-between border-b border-border/50 p-2">
|
|
<button
|
|
type="button"
|
|
onClick={onBack}
|
|
className="text-xs text-muted-foreground hover:text-foreground"
|
|
>
|
|
{translate('auto.components.github.project.ProjectPicker.a51b3337ab', '← Back')}
|
|
</button>
|
|
<span className="text-xs font-medium">
|
|
{translate('auto.components.github.project.ProjectPicker.9bf55fa1e8', 'Choose a view')}
|
|
</span>
|
|
<span />
|
|
</div>
|
|
<div className="max-h-[340px] overflow-y-auto p-1 scrollbar-sleek">
|
|
{loading ? (
|
|
<div className="flex items-center gap-2 px-2 py-2 text-xs text-muted-foreground">
|
|
<Loader className="size-3 animate-spin" />{' '}
|
|
{translate('auto.components.github.project.ProjectPicker.72a05c04a6', 'Loading views…')}
|
|
</div>
|
|
) : views.length === 0 ? (
|
|
<div className="px-2 py-2 text-xs text-muted-foreground">
|
|
{translate(
|
|
'auto.components.github.project.ProjectPicker.9b36829267',
|
|
'No views found.'
|
|
)}
|
|
</div>
|
|
) : (
|
|
views.map((v) => {
|
|
const supported = v.layout === 'TABLE_LAYOUT'
|
|
return (
|
|
<button
|
|
key={v.id}
|
|
type="button"
|
|
disabled={!supported}
|
|
onClick={() => void onPick(v)}
|
|
className={cn(
|
|
'flex w-full flex-col items-start rounded px-2 py-1 text-left',
|
|
supported ? 'hover:bg-muted/50' : 'cursor-not-allowed opacity-50'
|
|
)}
|
|
>
|
|
<span className="text-sm">{v.name}</span>
|
|
<span className="text-[10px] text-muted-foreground">
|
|
{v.layout === 'TABLE_LAYOUT'
|
|
? translate('auto.components.github.project.ProjectPicker.1a2b8e512e', 'Table')
|
|
: v.layout === 'BOARD_LAYOUT'
|
|
? translate(
|
|
'auto.components.github.project.ProjectPicker.d34ef9b554',
|
|
'Board (unsupported)'
|
|
)
|
|
: translate(
|
|
'auto.components.github.project.ProjectPicker.ab1a2c357d',
|
|
'Roadmap (unsupported)'
|
|
)}
|
|
</span>
|
|
</button>
|
|
)
|
|
})
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function PartialFailuresBanner({
|
|
failures
|
|
}: {
|
|
failures: { owner: string; message: string }[]
|
|
}): React.JSX.Element {
|
|
// Why: a single generic sentence is preferable to enumerating every failed
|
|
// owner inline — the list is unbounded and the user only needs to know
|
|
// (1) their list is incomplete and (2) paste-to-add is the escape hatch.
|
|
// Hover exposes the underlying error messages for debugging.
|
|
const summary =
|
|
failures.length === 1 && failures[0].owner !== '*'
|
|
? `Couldn't load projects from ${failures[0].owner}.`
|
|
: `Some organizations didn't load (${failures.length}).`
|
|
const detail = failures
|
|
.map((f) => `${f.owner === '*' ? 'orgs' : f.owner}: ${f.message}`)
|
|
.join('\n')
|
|
return (
|
|
<div
|
|
className="border-b border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs text-amber-800 dark:text-amber-200"
|
|
title={detail}
|
|
>
|
|
<div className="flex items-start gap-1.5">
|
|
<AlertTriangle className="mt-0.5 size-3 shrink-0" />
|
|
<div>
|
|
<div>{summary}</div>
|
|
<div className="mt-0.5 text-[11px] opacity-80">
|
|
{translate(
|
|
'auto.components.github.project.ProjectPicker.96739284c3',
|
|
'Paste a project URL below to reach missing ones.'
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function AuthErrorBanner({ error }: { error: GitHubProjectViewError }): React.JSX.Element {
|
|
if (error.type === 'auth_required' || error.type === 'scope_missing') {
|
|
return (
|
|
<GhAuthErrorHelp
|
|
error={error as GitHubProjectViewError & { type: 'auth_required' | 'scope_missing' }}
|
|
variant="banner"
|
|
/>
|
|
)
|
|
}
|
|
// Non-auth errors keep the legacy single-line banner.
|
|
return (
|
|
<div className="border-b border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs text-amber-800 dark:text-amber-200">
|
|
<div>{error.message}</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function parseProjectInput(
|
|
input: string
|
|
): { owner: string; number: number; host?: string; viewNumber?: number } | null {
|
|
if (!input) {
|
|
return null
|
|
}
|
|
if (isGitHubProjectRefInputTooLarge(input)) {
|
|
return null
|
|
}
|
|
// owner/number
|
|
const short = /^([A-Za-z0-9][A-Za-z0-9-]*)\/(\d+)$/.exec(input)
|
|
if (short) {
|
|
return { owner: short[1], number: Number(short[2]) }
|
|
}
|
|
try {
|
|
const url = new URL(input)
|
|
if ((url.protocol !== 'https:' && url.protocol !== 'http:') || !url.host) {
|
|
return null
|
|
}
|
|
const parts = url.pathname.split('/').filter(Boolean)
|
|
// /orgs/{owner}/projects/{n} or /users/{owner}/projects/{n}[/views/{viewNumber}]
|
|
if ((parts[0] === 'orgs' || parts[0] === 'users') && parts[2] === 'projects' && parts[3]) {
|
|
const owner = parts[1]
|
|
const number = Number(parts[3])
|
|
if (Number.isNaN(number)) {
|
|
return null
|
|
}
|
|
let viewNumber: number | undefined
|
|
if (parts[4] === 'views' && parts[5]) {
|
|
const v = Number(parts[5])
|
|
if (!Number.isNaN(v)) {
|
|
viewNumber = v
|
|
}
|
|
}
|
|
// Why: URL.host preserves non-default GHES ports; URL.hostname would
|
|
// silently route a project on :8443 to the server's default port.
|
|
return { owner, number, host: url.host, viewNumber }
|
|
}
|
|
} catch {
|
|
return null
|
|
}
|
|
return null
|
|
}
|