Gate task providers by availability (#2189)
* Gate task providers by availability Implement provider availability gating documented in docs/task-provider-availability.md. * Remove task provider availability design doc * Restore task source after provider availability checks - Keep saved GitLab/Linear defaults from being lost while provider checks hydrate - Ignore stale Linear status responses after connect or workspace changes * fix: address review findings
This commit is contained in:
parent
8057cbdf78
commit
d88b62f115
|
|
@ -82,6 +82,7 @@ docs/**
|
|||
!docs/reference/**
|
||||
!docs/STYLEGUIDE.md
|
||||
!docs/configurable-open-in-menu.md
|
||||
!docs/task-provider-availability.md
|
||||
|
||||
# Stably CLI (only docs/ are tracked)
|
||||
.stably/*
|
||||
|
|
|
|||
|
|
@ -93,6 +93,7 @@ import type {
|
|||
import { shouldSuppressEnterSubmit } from '@/lib/new-workspace-enter-guard'
|
||||
import { linearCreateIssue, linearGetIssue } from '@/runtime/runtime-linear-client'
|
||||
import {
|
||||
filterAvailableTaskProviders,
|
||||
normalizeVisibleTaskProviders,
|
||||
resolveVisibleTaskProvider
|
||||
} from '../../../shared/task-providers'
|
||||
|
|
@ -528,6 +529,8 @@ export default function TaskPage(): React.JSX.Element {
|
|||
const workItemsInvalidationNonce = useAppStore((s) => s.workItemsInvalidationNonce)
|
||||
const linearStatus = useAppStore((s) => s.linearStatus)
|
||||
const linearStatusChecked = useAppStore((s) => s.linearStatusChecked)
|
||||
const preflightStatus = useAppStore((s) => s.preflightStatus)
|
||||
const preflightStatusChecked = useAppStore((s) => s.preflightStatusChecked)
|
||||
const connectLinear = useAppStore((s) => s.connectLinear)
|
||||
const selectLinearWorkspace = useAppStore((s) => s.selectLinearWorkspace)
|
||||
const searchLinearIssues = useAppStore((s) => s.searchLinearIssues)
|
||||
|
|
@ -536,6 +539,7 @@ export default function TaskPage(): React.JSX.Element {
|
|||
const getCachedLinearTeams = useAppStore((s) => s.getCachedLinearTeams)
|
||||
const listLinearTeams = useAppStore((s) => s.listLinearTeams)
|
||||
const checkLinearConnection = useAppStore((s) => s.checkLinearConnection)
|
||||
const refreshPreflightStatus = useAppStore((s) => s.refreshPreflightStatus)
|
||||
const eligibleRepos = useMemo(() => repos.filter((repo) => isGitRepoKind(repo)), [repos])
|
||||
|
||||
// Why: initial selection resolution honors (1) an explicit preselection from
|
||||
|
|
@ -612,10 +616,18 @@ export default function TaskPage(): React.JSX.Element {
|
|||
linearStatus.activeWorkspaceId ??
|
||||
linearWorkspaces[0]?.id ??
|
||||
null
|
||||
const visibleTaskProviders = useMemo(
|
||||
const preferredVisibleTaskProviders = useMemo(
|
||||
() => normalizeVisibleTaskProviders(settings?.visibleTaskProviders),
|
||||
[settings?.visibleTaskProviders]
|
||||
)
|
||||
const visibleTaskProviders = useMemo(
|
||||
() =>
|
||||
filterAvailableTaskProviders(preferredVisibleTaskProviders, {
|
||||
gitlabInstalled: preflightStatus?.glab?.installed === true,
|
||||
linearConnected: linearStatus.connected === true
|
||||
}),
|
||||
[linearStatus.connected, preferredVisibleTaskProviders, preflightStatus?.glab?.installed]
|
||||
)
|
||||
const visibleSourceOptions = useMemo(
|
||||
() => SOURCE_OPTIONS.filter((source) => visibleTaskProviders.includes(source.id)),
|
||||
[visibleTaskProviders]
|
||||
|
|
@ -630,9 +642,12 @@ export default function TaskPage(): React.JSX.Element {
|
|||
const initialTaskQuery = getTaskPresetQuery(defaultTaskViewPreset)
|
||||
|
||||
const defaultTaskSource = settings?.defaultTaskSource ?? 'github'
|
||||
const preferredTaskSource = pageData.taskSource ?? defaultTaskSource
|
||||
const [taskSource, setTaskSource] = useState<TaskSource>(
|
||||
resolveVisibleTaskProvider(pageData.taskSource ?? defaultTaskSource, visibleTaskProviders)
|
||||
resolveVisibleTaskProvider(preferredTaskSource, visibleTaskProviders)
|
||||
)
|
||||
const taskSourceManuallyChangedRef = useRef(false)
|
||||
const lastPageTaskSourceRef = useRef(pageData.taskSource)
|
||||
const taskResumeAppliedRef = useRef(false)
|
||||
const githubSearchPersistReadyRef = useRef(false)
|
||||
const linearSearchPersistReadyRef = useRef(false)
|
||||
|
|
@ -642,11 +657,30 @@ export default function TaskPage(): React.JSX.Element {
|
|||
// icon in the sidebar while the task page is already open. useState only
|
||||
// initializes once, so sync from the store when the value changes.
|
||||
useEffect(() => {
|
||||
const pageTaskSourceChanged = lastPageTaskSourceRef.current !== pageData.taskSource
|
||||
lastPageTaskSourceRef.current = pageData.taskSource
|
||||
if (pageData.taskSource) {
|
||||
if (pageTaskSourceChanged) {
|
||||
taskSourceManuallyChangedRef.current = false
|
||||
} else if (taskSourceManuallyChangedRef.current) {
|
||||
return
|
||||
}
|
||||
setTaskSource(resolveVisibleTaskProvider(pageData.taskSource, visibleTaskProviders))
|
||||
}
|
||||
}, [pageData.taskSource, visibleTaskProviders])
|
||||
|
||||
useEffect(() => {
|
||||
if (taskSourceManuallyChangedRef.current) {
|
||||
return
|
||||
}
|
||||
// Why: GitLab/Linear availability hydrates after mount. If the saved
|
||||
// default was unavailable during the first render, restore it once the
|
||||
// relevant check proves the provider can be shown.
|
||||
if (visibleTaskProviders.includes(preferredTaskSource) && taskSource !== preferredTaskSource) {
|
||||
setTaskSource(preferredTaskSource)
|
||||
}
|
||||
}, [preferredTaskSource, taskSource, visibleTaskProviders])
|
||||
|
||||
useEffect(() => {
|
||||
if (!visibleTaskProviders.includes(taskSource)) {
|
||||
setTaskSource(resolveVisibleTaskProvider(settings?.defaultTaskSource, visibleTaskProviders))
|
||||
|
|
@ -1782,12 +1816,14 @@ export default function TaskPage(): React.JSX.Element {
|
|||
selectedLinearIssue
|
||||
])
|
||||
|
||||
// Why: check Linear connection status on mount so the UI can show the
|
||||
// correct connected/disconnected state without requiring a settings visit.
|
||||
useEffect(() => {
|
||||
void checkLinearConnection()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
if (!preflightStatusChecked) {
|
||||
void refreshPreflightStatus()
|
||||
}
|
||||
if (!linearStatusChecked) {
|
||||
void checkLinearConnection()
|
||||
}
|
||||
}, [checkLinearConnection, linearStatusChecked, preflightStatusChecked, refreshPreflightStatus])
|
||||
|
||||
// Why: debounce the Linear search input so we don't fire a request on every
|
||||
// keystroke — matches the 300ms cadence used for GitHub search.
|
||||
|
|
@ -2031,6 +2067,7 @@ export default function TaskPage(): React.JSX.Element {
|
|||
type="button"
|
||||
disabled={source.disabled}
|
||||
onClick={() => {
|
||||
taskSourceManuallyChangedRef.current = true
|
||||
setTaskSource(source.id)
|
||||
void updateSettings({ defaultTaskSource: source.id }).catch(() => {
|
||||
toast.error('Failed to save default task source.')
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ import { parseGitLabIssueOrMRLink } from '@/lib/gitlab-links'
|
|||
import { cn } from '@/lib/utils'
|
||||
import { LinearIcon } from '@/components/icons/LinearIcon'
|
||||
import { searchRuntimeRepoBaseRefDetails } from '@/runtime/runtime-repo-client'
|
||||
import { filterAvailableTaskProviders } from '../../../../shared/task-providers'
|
||||
import type {
|
||||
BaseRefSearchResult,
|
||||
GitHubWorkItem,
|
||||
|
|
@ -156,6 +157,9 @@ export default function SmartWorkspaceNameField({
|
|||
linearStatus,
|
||||
linearStatusChecked,
|
||||
listLinearIssues,
|
||||
preflightStatus,
|
||||
preflightStatusChecked,
|
||||
refreshPreflightStatus,
|
||||
searchLinearIssues,
|
||||
settings
|
||||
} = useAppStore(
|
||||
|
|
@ -167,6 +171,9 @@ export default function SmartWorkspaceNameField({
|
|||
linearStatus: s.linearStatus,
|
||||
linearStatusChecked: s.linearStatusChecked,
|
||||
listLinearIssues: s.listLinearIssues,
|
||||
preflightStatus: s.preflightStatus,
|
||||
preflightStatusChecked: s.preflightStatusChecked,
|
||||
refreshPreflightStatus: s.refreshPreflightStatus,
|
||||
searchLinearIssues: s.searchLinearIssues,
|
||||
settings: s.settings
|
||||
}))
|
||||
|
|
@ -196,6 +203,29 @@ export default function SmartWorkspaceNameField({
|
|||
link: NonNullable<ReturnType<typeof parseGitHubIssueOrPRLink>>
|
||||
matchingRepo: RepoOption | null
|
||||
} | null>(null)
|
||||
const availableTaskProviders = useMemo(
|
||||
() =>
|
||||
filterAvailableTaskProviders(['github', 'gitlab', 'linear'], {
|
||||
gitlabInstalled: preflightStatus?.glab?.installed === true,
|
||||
linearConnected: linearStatus.connected === true
|
||||
}),
|
||||
[linearStatus.connected, preflightStatus?.glab?.installed]
|
||||
)
|
||||
const gitlabAvailable = availableTaskProviders.includes('gitlab')
|
||||
const linearAvailable = availableTaskProviders.includes('linear')
|
||||
const availableModes = useMemo(
|
||||
() =>
|
||||
MODES.filter((item) => {
|
||||
if (item.id === 'gitlab') {
|
||||
return gitlabAvailable
|
||||
}
|
||||
if (item.id === 'linear') {
|
||||
return linearAvailable
|
||||
}
|
||||
return true
|
||||
}),
|
||||
[gitlabAvailable, linearAvailable]
|
||||
)
|
||||
|
||||
const setInputNode = useCallback(
|
||||
(node: HTMLInputElement | null) => {
|
||||
|
|
@ -211,14 +241,34 @@ export default function SmartWorkspaceNameField({
|
|||
if (disabled) {
|
||||
return
|
||||
}
|
||||
// Why: the composer can be opened before any other Linear-aware surface
|
||||
// (TaskPage, IntegrationsPane) has had a chance to refresh status, leaving
|
||||
// `linearStatus.connected=false` even when the user is actually connected.
|
||||
// Trigger a check on mount if it hasn't run this session.
|
||||
if (!preflightStatusChecked) {
|
||||
void refreshPreflightStatus()
|
||||
}
|
||||
if (!linearStatusChecked) {
|
||||
void checkLinearConnection()
|
||||
}
|
||||
}, [checkLinearConnection, disabled, linearStatusChecked])
|
||||
}, [
|
||||
checkLinearConnection,
|
||||
disabled,
|
||||
linearStatusChecked,
|
||||
preflightStatusChecked,
|
||||
refreshPreflightStatus
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
if ((mode === 'gitlab' && gitlabAvailable) || (mode === 'linear' && linearAvailable)) {
|
||||
return
|
||||
}
|
||||
if (mode !== 'gitlab' && mode !== 'linear') {
|
||||
return
|
||||
}
|
||||
setMode('smart')
|
||||
setGitlabItems([])
|
||||
setLinearIssues([])
|
||||
setGitlabLoading(false)
|
||||
setLinearLoading(false)
|
||||
setCommandValue('')
|
||||
}, [gitlabAvailable, linearAvailable, mode])
|
||||
|
||||
useEffect(() => {
|
||||
if (!disabled) {
|
||||
|
|
@ -255,7 +305,7 @@ export default function SmartWorkspaceNameField({
|
|||
const parsedGhLink = useMemo(() => parseGitHubIssueOrPRLink(debouncedQuery), [debouncedQuery])
|
||||
const shouldQueryGithub = mode === 'smart' || mode === 'github'
|
||||
const shouldQueryBranches = mode === 'smart' || mode === 'branches'
|
||||
const shouldQueryLinear = mode === 'smart' || mode === 'linear'
|
||||
const shouldQueryLinear = linearAvailable && (mode === 'smart' || mode === 'linear')
|
||||
|
||||
useEffect(() => {
|
||||
if (disabled || !shouldQueryGithub || !selectedRepo?.path) {
|
||||
|
|
@ -462,7 +512,7 @@ export default function SmartWorkspaceNameField({
|
|||
// GitLabWorkItem via the IPC. Skipped silently when the host hook
|
||||
// hasn't supplied an onGitLabItemSelect handler.
|
||||
const parsedGlLink = useMemo(() => parseGitLabIssueOrMRLink(debouncedQuery), [debouncedQuery])
|
||||
const shouldQueryGitlab = mode === 'smart' || mode === 'gitlab'
|
||||
const shouldQueryGitlab = gitlabAvailable && (mode === 'smart' || mode === 'gitlab')
|
||||
useEffect(() => {
|
||||
if (
|
||||
!shouldQueryGitlab ||
|
||||
|
|
@ -473,7 +523,7 @@ export default function SmartWorkspaceNameField({
|
|||
) {
|
||||
// Why: don't clobber list-mode items here — the listMRs effect below
|
||||
// is the sole writer when the user is in 'gitlab' mode without a URL.
|
||||
if (parsedGlLink === null && mode !== 'gitlab') {
|
||||
if (!shouldQueryGitlab || (parsedGlLink === null && mode !== 'gitlab')) {
|
||||
setGitlabItems([])
|
||||
}
|
||||
setGitlabLoading(false)
|
||||
|
|
@ -529,7 +579,11 @@ export default function SmartWorkspaceNameField({
|
|||
// MR list view. Smart mode includes GitLab MRs alongside GitHub
|
||||
// items so the unified picker actually surfaces both providers.
|
||||
useEffect(() => {
|
||||
if (disabled || (mode !== 'gitlab' && mode !== 'smart') || !onGitLabItemSelect) {
|
||||
if (!shouldQueryGitlab || disabled || !onGitLabItemSelect) {
|
||||
if (!shouldQueryGitlab) {
|
||||
setGitlabItems([])
|
||||
setGitlabLoading(false)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (!selectedRepo?.path || selectedRepo.connectionId) {
|
||||
|
|
@ -576,7 +630,15 @@ export default function SmartWorkspaceNameField({
|
|||
return () => {
|
||||
stale = true
|
||||
}
|
||||
}, [disabled, mode, mrStateFilter, onGitLabItemSelect, parsedGlLink, selectedRepo])
|
||||
}, [
|
||||
disabled,
|
||||
mode,
|
||||
mrStateFilter,
|
||||
onGitLabItemSelect,
|
||||
parsedGlLink,
|
||||
selectedRepo,
|
||||
shouldQueryGitlab
|
||||
])
|
||||
|
||||
const rows = useMemo<RowEntry[]>(() => {
|
||||
const trimmed = value.trim()
|
||||
|
|
@ -617,7 +679,7 @@ export default function SmartWorkspaceNameField({
|
|||
}))
|
||||
)
|
||||
}
|
||||
if (mode === 'smart' || mode === 'gitlab') {
|
||||
if (gitlabAvailable && (mode === 'smart' || mode === 'gitlab')) {
|
||||
nextRows.push(
|
||||
...gitlabItems.map((item) => ({
|
||||
kind: 'gitlab' as const,
|
||||
|
|
@ -639,7 +701,7 @@ export default function SmartWorkspaceNameField({
|
|||
}))
|
||||
)
|
||||
}
|
||||
if (mode === 'smart' || mode === 'linear') {
|
||||
if (linearAvailable && (mode === 'smart' || mode === 'linear')) {
|
||||
nextRows.push(
|
||||
...linearIssues.map((issue) => ({
|
||||
kind: 'linear' as const,
|
||||
|
|
@ -649,7 +711,16 @@ export default function SmartWorkspaceNameField({
|
|||
)
|
||||
}
|
||||
return nextRows.slice(0, RESULT_LIMIT + 1)
|
||||
}, [branches, githubItems, gitlabItems, linearIssues, mode, value])
|
||||
}, [
|
||||
branches,
|
||||
githubItems,
|
||||
gitlabAvailable,
|
||||
gitlabItems,
|
||||
linearIssues,
|
||||
linearAvailable,
|
||||
mode,
|
||||
value
|
||||
])
|
||||
|
||||
// Why: source rows (GitHub/branches/Linear) are driven by debouncedQuery,
|
||||
// so they're stale until the user pauses typing for SEARCH_DEBOUNCE_MS.
|
||||
|
|
@ -677,11 +748,11 @@ export default function SmartWorkspaceNameField({
|
|||
if (/^#\d+$/.test(trimmed) || parseGitHubIssueOrPRLink(trimmed) !== null) {
|
||||
return 'github'
|
||||
}
|
||||
if (/^[A-Za-z][A-Za-z0-9_]*-\d+$/.test(trimmed)) {
|
||||
if (linearAvailable && /^[A-Za-z][A-Za-z0-9_]*-\d+$/.test(trimmed)) {
|
||||
return 'linear'
|
||||
}
|
||||
return null
|
||||
}, [value])
|
||||
}, [linearAvailable, value])
|
||||
|
||||
useEffect(() => {
|
||||
if (rows.length === 0) {
|
||||
|
|
@ -801,7 +872,9 @@ export default function SmartWorkspaceNameField({
|
|||
const placeholder = disabled
|
||||
? (disabledPlaceholder ?? 'Unavailable')
|
||||
: mode === 'smart'
|
||||
? 'Type a name, #1234, branch, GitHub or Linear URL'
|
||||
? linearAvailable
|
||||
? 'Type a name, #1234, branch, GitHub or Linear URL'
|
||||
: 'Type a name, #1234, branch, or GitHub URL'
|
||||
: mode === 'github'
|
||||
? 'Search GitHub PRs and issues'
|
||||
: mode === 'branches'
|
||||
|
|
@ -851,7 +924,7 @@ export default function SmartWorkspaceNameField({
|
|||
input.focus({ preventScroll: true })
|
||||
}}
|
||||
>
|
||||
{MODES.map(({ id, label, Icon }) => (
|
||||
{availableModes.map(({ id, label, Icon }) => (
|
||||
<TabsTrigger
|
||||
key={id}
|
||||
value={id}
|
||||
|
|
|
|||
|
|
@ -121,10 +121,12 @@ function giteaStatusFromPreflight(status: GiteaPreflightStatus | undefined): Git
|
|||
|
||||
export function IntegrationsPane(): React.JSX.Element {
|
||||
const linearStatus = useAppStore((s) => s.linearStatus)
|
||||
const preflightStatus = useAppStore((s) => s.preflightStatus)
|
||||
const connectLinear = useAppStore((s) => s.connectLinear)
|
||||
const disconnectLinear = useAppStore((s) => s.disconnectLinear)
|
||||
const disconnectLinearWorkspace = useAppStore((s) => s.disconnectLinearWorkspace)
|
||||
const checkLinearConnection = useAppStore((s) => s.checkLinearConnection)
|
||||
const refreshPreflightStatus = useAppStore((s) => s.refreshPreflightStatus)
|
||||
const testLinearConnection = useAppStore((s) => s.testLinearConnection)
|
||||
const linearWorkspaces = linearStatus.workspaces ?? []
|
||||
|
||||
|
|
@ -151,45 +153,46 @@ export function IntegrationsPane(): React.JSX.Element {
|
|||
|
||||
useEffect(() => {
|
||||
void checkLinearConnection()
|
||||
void window.api.preflight.check().then((status) => {
|
||||
if (!status.gh.installed) {
|
||||
setGhStatus('not-installed')
|
||||
} else if (!status.gh.authenticated) {
|
||||
setGhStatus('not-authenticated')
|
||||
} else {
|
||||
setGhStatus('connected')
|
||||
}
|
||||
// Why: glab is optional on PreflightStatus — older preload payloads
|
||||
// may not carry it. Fall through to 'not-installed' in that case so
|
||||
// the card still renders something actionable.
|
||||
const glab = status.glab
|
||||
if (!glab || !glab.installed) {
|
||||
setGlabStatus('not-installed')
|
||||
} else if (!glab.authenticated) {
|
||||
setGlabStatus('not-authenticated')
|
||||
} else {
|
||||
setGlabStatus('connected')
|
||||
}
|
||||
const bitbucket = status.bitbucket
|
||||
setBitbucketAccount(bitbucket?.account ?? null)
|
||||
if (!bitbucket?.configured) {
|
||||
setBitbucketStatus('not-configured')
|
||||
} else if (!bitbucket.authenticated) {
|
||||
setBitbucketStatus('not-authenticated')
|
||||
} else {
|
||||
setBitbucketStatus('connected')
|
||||
}
|
||||
const azureDevOps = status.azureDevOps
|
||||
setAzureDevOpsAccount(azureDevOps?.account ?? null)
|
||||
setAzureDevOpsBaseUrl(azureDevOps?.baseUrl ?? null)
|
||||
setAzureDevOpsStatus(tokenApiStatusFromPreflight(azureDevOps))
|
||||
const gitea = status.gitea
|
||||
setGiteaAccount(gitea?.account ?? null)
|
||||
setGiteaBaseUrl(gitea?.baseUrl ?? null)
|
||||
setGiteaStatus(giteaStatusFromPreflight(gitea))
|
||||
})
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- one-shot mount check
|
||||
}, [])
|
||||
void refreshPreflightStatus()
|
||||
}, [checkLinearConnection, refreshPreflightStatus])
|
||||
|
||||
useEffect(() => {
|
||||
if (!preflightStatus) {
|
||||
return
|
||||
}
|
||||
if (!preflightStatus.gh.installed) {
|
||||
setGhStatus('not-installed')
|
||||
} else if (!preflightStatus.gh.authenticated) {
|
||||
setGhStatus('not-authenticated')
|
||||
} else {
|
||||
setGhStatus('connected')
|
||||
}
|
||||
const glab = preflightStatus.glab
|
||||
if (!glab || !glab.installed) {
|
||||
setGlabStatus('not-installed')
|
||||
} else if (!glab.authenticated) {
|
||||
setGlabStatus('not-authenticated')
|
||||
} else {
|
||||
setGlabStatus('connected')
|
||||
}
|
||||
const bitbucket = preflightStatus.bitbucket
|
||||
setBitbucketAccount(bitbucket?.account ?? null)
|
||||
if (!bitbucket?.configured) {
|
||||
setBitbucketStatus('not-configured')
|
||||
} else if (!bitbucket.authenticated) {
|
||||
setBitbucketStatus('not-authenticated')
|
||||
} else {
|
||||
setBitbucketStatus('connected')
|
||||
}
|
||||
const azureDevOps = preflightStatus.azureDevOps
|
||||
setAzureDevOpsAccount(azureDevOps?.account ?? null)
|
||||
setAzureDevOpsBaseUrl(azureDevOps?.baseUrl ?? null)
|
||||
setAzureDevOpsStatus(tokenApiStatusFromPreflight(azureDevOps))
|
||||
const gitea = preflightStatus.gitea
|
||||
setGiteaAccount(gitea?.account ?? null)
|
||||
setGiteaBaseUrl(gitea?.baseUrl ?? null)
|
||||
setGiteaStatus(giteaStatusFromPreflight(gitea))
|
||||
}, [preflightStatus])
|
||||
|
||||
const handleLinearConnect = async (): Promise<void> => {
|
||||
if (!linearApiKeyDraft.trim()) {
|
||||
|
|
@ -249,64 +252,27 @@ export function IntegrationsPane(): React.JSX.Element {
|
|||
|
||||
const handleRefreshGlab = (): void => {
|
||||
setGlabStatus('checking')
|
||||
void window.api.preflight.check({ force: true }).then((status) => {
|
||||
const glab = status.glab
|
||||
if (!glab || !glab.installed) {
|
||||
setGlabStatus('not-installed')
|
||||
} else if (!glab.authenticated) {
|
||||
setGlabStatus('not-authenticated')
|
||||
} else {
|
||||
setGlabStatus('connected')
|
||||
}
|
||||
})
|
||||
void refreshPreflightStatus({ force: true })
|
||||
}
|
||||
|
||||
const handleRefreshGh = (): void => {
|
||||
setGhStatus('checking')
|
||||
void window.api.preflight.check({ force: true }).then((status) => {
|
||||
if (!status.gh.installed) {
|
||||
setGhStatus('not-installed')
|
||||
} else if (!status.gh.authenticated) {
|
||||
setGhStatus('not-authenticated')
|
||||
} else {
|
||||
setGhStatus('connected')
|
||||
}
|
||||
})
|
||||
void refreshPreflightStatus({ force: true })
|
||||
}
|
||||
|
||||
const handleRefreshBitbucket = (): void => {
|
||||
setBitbucketStatus('checking')
|
||||
void window.api.preflight.check({ force: true }).then((status) => {
|
||||
const bitbucket = status.bitbucket
|
||||
setBitbucketAccount(bitbucket?.account ?? null)
|
||||
if (!bitbucket?.configured) {
|
||||
setBitbucketStatus('not-configured')
|
||||
} else if (!bitbucket.authenticated) {
|
||||
setBitbucketStatus('not-authenticated')
|
||||
} else {
|
||||
setBitbucketStatus('connected')
|
||||
}
|
||||
})
|
||||
void refreshPreflightStatus({ force: true })
|
||||
}
|
||||
|
||||
const handleRefreshAzureDevOps = (): void => {
|
||||
setAzureDevOpsStatus('checking')
|
||||
void window.api.preflight.check({ force: true }).then((status) => {
|
||||
const azureDevOps = status.azureDevOps
|
||||
setAzureDevOpsAccount(azureDevOps?.account ?? null)
|
||||
setAzureDevOpsBaseUrl(azureDevOps?.baseUrl ?? null)
|
||||
setAzureDevOpsStatus(tokenApiStatusFromPreflight(azureDevOps))
|
||||
})
|
||||
void refreshPreflightStatus({ force: true })
|
||||
}
|
||||
|
||||
const handleRefreshGitea = (): void => {
|
||||
setGiteaStatus('checking')
|
||||
void window.api.preflight.check({ force: true }).then((status) => {
|
||||
const gitea = status.gitea
|
||||
setGiteaAccount(gitea?.account ?? null)
|
||||
setGiteaBaseUrl(gitea?.baseUrl ?? null)
|
||||
setGiteaStatus(giteaStatusFromPreflight(gitea))
|
||||
})
|
||||
void refreshPreflightStatus({ force: true })
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { getTaskPresetQuery, PER_REPO_FETCH_LIMIT } from '@/lib/new-workspace'
|
|||
import { LinearIcon } from '@/components/icons/LinearIcon'
|
||||
import { migrationUnsupportedToAgentStatusEntry } from '@/lib/migration-unsupported-agent-entry'
|
||||
import {
|
||||
filterAvailableTaskProviders,
|
||||
normalizeVisibleTaskProviders,
|
||||
resolveVisibleTaskProvider
|
||||
} from '../../../../shared/task-providers'
|
||||
|
|
@ -28,15 +29,38 @@ const SidebarNav = React.memo(function SidebarNav() {
|
|||
const showTasksButton = useAppStore((s) => s.settings?.showTasksButton !== false)
|
||||
const rawVisibleTaskProviders = useAppStore((s) => s.settings?.visibleTaskProviders)
|
||||
const defaultTaskSource = useAppStore((s) => s.settings?.defaultTaskSource ?? 'github')
|
||||
const visibleTaskProviders = React.useMemo(
|
||||
const preflightStatus = useAppStore((s) => s.preflightStatus)
|
||||
const preflightStatusChecked = useAppStore((s) => s.preflightStatusChecked)
|
||||
const refreshPreflightStatus = useAppStore((s) => s.refreshPreflightStatus)
|
||||
const linearStatus = useAppStore((s) => s.linearStatus)
|
||||
const linearStatusChecked = useAppStore((s) => s.linearStatusChecked)
|
||||
const checkLinearConnection = useAppStore((s) => s.checkLinearConnection)
|
||||
const preferredVisibleTaskProviders = React.useMemo(
|
||||
() => normalizeVisibleTaskProviders(rawVisibleTaskProviders),
|
||||
[rawVisibleTaskProviders]
|
||||
)
|
||||
const visibleTaskProviders = React.useMemo(
|
||||
() =>
|
||||
filterAvailableTaskProviders(preferredVisibleTaskProviders, {
|
||||
gitlabInstalled: preflightStatus?.glab?.installed === true,
|
||||
linearConnected: linearStatus.connected === true
|
||||
}),
|
||||
[linearStatus.connected, preferredVisibleTaskProviders, preflightStatus?.glab?.installed]
|
||||
)
|
||||
const resolvedDefaultTaskSource = React.useMemo(
|
||||
() => resolveVisibleTaskProvider(defaultTaskSource, visibleTaskProviders),
|
||||
[defaultTaskSource, visibleTaskProviders]
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!preflightStatusChecked) {
|
||||
void refreshPreflightStatus()
|
||||
}
|
||||
if (!linearStatusChecked) {
|
||||
void checkLinearConnection()
|
||||
}
|
||||
}, [checkLinearConnection, linearStatusChecked, preflightStatusChecked, refreshPreflightStatus])
|
||||
|
||||
// Why: warm the GitHub work-item cache on hover/focus so by the time the
|
||||
// user's click finishes the round-trip has either completed or is already
|
||||
// in-flight. Shaves ~200–600ms off perceived page-load latency.
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { createSettingsSlice } from './slices/settings'
|
|||
import { createGitHubSlice } from './slices/github'
|
||||
import { createHostedReviewSlice } from './slices/hosted-review'
|
||||
import { createLinearSlice } from './slices/linear'
|
||||
import { createPreflightSlice } from './slices/preflight'
|
||||
import { createEditorSlice } from './slices/editor'
|
||||
import { createStatsSlice } from './slices/stats'
|
||||
import { createMemorySlice } from './slices/memory'
|
||||
|
|
@ -40,6 +41,7 @@ export const useAppStore = create<AppState>()((...a) => ({
|
|||
...createGitHubSlice(...a),
|
||||
...createHostedReviewSlice(...a),
|
||||
...createLinearSlice(...a),
|
||||
...createPreflightSlice(...a),
|
||||
...createEditorSlice(...a),
|
||||
...createStatsSlice(...a),
|
||||
...createMemorySlice(...a),
|
||||
|
|
|
|||
|
|
@ -113,6 +113,7 @@ import { createSettingsSlice } from './settings'
|
|||
import { createGitHubSlice } from './github'
|
||||
import { createHostedReviewSlice } from './hosted-review'
|
||||
import { createLinearSlice } from './linear'
|
||||
import { createPreflightSlice } from './preflight'
|
||||
import { createEditorSlice } from './editor'
|
||||
import { createStatsSlice } from './stats'
|
||||
import { createMemorySlice } from './memory'
|
||||
|
|
@ -142,6 +143,7 @@ function createTestStore() {
|
|||
...createGitHubSlice(...a),
|
||||
...createHostedReviewSlice(...a),
|
||||
...createLinearSlice(...a),
|
||||
...createPreflightSlice(...a),
|
||||
...createEditorSlice(...a),
|
||||
...createStatsSlice(...a),
|
||||
...createMemorySlice(...a),
|
||||
|
|
|
|||
|
|
@ -1,17 +1,25 @@
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { create } from 'zustand'
|
||||
import type { AppState } from '../types'
|
||||
import type { LinearIssue, LinearTeam } from '../../../../shared/types'
|
||||
import type {
|
||||
LinearConnectionStatus,
|
||||
LinearIssue,
|
||||
LinearTeam,
|
||||
LinearViewer
|
||||
} from '../../../../shared/types'
|
||||
import { createLinearSlice } from './linear'
|
||||
|
||||
const linearStatus = vi.fn()
|
||||
const linearConnect = vi.fn()
|
||||
const linearDisconnect = vi.fn()
|
||||
const linearListIssues = vi.fn()
|
||||
const linearSearchIssues = vi.fn()
|
||||
const linearListTeams = vi.fn()
|
||||
const linearTestConnection = vi.fn()
|
||||
|
||||
vi.mock('@/runtime/runtime-linear-client', () => ({
|
||||
linearConnect: vi.fn(),
|
||||
linearDisconnect: vi.fn(),
|
||||
linearConnect: (...args: unknown[]) => linearConnect(...args),
|
||||
linearDisconnect: (...args: unknown[]) => linearDisconnect(...args),
|
||||
linearDisconnectWorkspace: vi.fn(),
|
||||
linearGetIssue: vi.fn(),
|
||||
linearListIssues: (...args: unknown[]) => linearListIssues(...args),
|
||||
|
|
@ -19,7 +27,7 @@ vi.mock('@/runtime/runtime-linear-client', () => ({
|
|||
linearSearchIssues: (...args: unknown[]) => linearSearchIssues(...args),
|
||||
linearSelectWorkspace: vi.fn(),
|
||||
linearStatus: (...args: unknown[]) => linearStatus(...args),
|
||||
linearTestConnection: vi.fn()
|
||||
linearTestConnection: (...args: unknown[]) => linearTestConnection(...args)
|
||||
}))
|
||||
|
||||
vi.mock('../../hooks/useIssueMetadata', () => ({
|
||||
|
|
@ -242,3 +250,88 @@ describe('createLinearSlice caching', () => {
|
|||
expect(store.getState().linearIssueCache['workspace-1::issue-id'].fetchedAt).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('createLinearSlice', () => {
|
||||
beforeEach(() => {
|
||||
linearStatus.mockReset()
|
||||
linearConnect.mockReset()
|
||||
linearDisconnect.mockReset()
|
||||
linearTestConnection.mockReset()
|
||||
})
|
||||
|
||||
it('dedupes concurrent connection checks', async () => {
|
||||
const pending = deferred<LinearConnectionStatus>()
|
||||
linearStatus.mockReturnValueOnce(pending.promise)
|
||||
const store = createTestStore()
|
||||
|
||||
const first = store.getState().checkLinearConnection()
|
||||
const second = store.getState().checkLinearConnection()
|
||||
|
||||
expect(linearStatus).toHaveBeenCalledTimes(1)
|
||||
pending.resolve({
|
||||
connected: true,
|
||||
viewer: {
|
||||
displayName: 'Test User',
|
||||
email: 'test@example.com',
|
||||
organizationName: 'Test Org'
|
||||
}
|
||||
})
|
||||
await Promise.all([first, second])
|
||||
|
||||
expect(store.getState().linearStatus.connected).toBe(true)
|
||||
expect(store.getState().linearStatusChecked).toBe(true)
|
||||
})
|
||||
|
||||
it('ignores stale status checks after a successful connect', async () => {
|
||||
const staleMountCheck = deferred<LinearConnectionStatus>()
|
||||
const freshConnectCheck = deferred<LinearConnectionStatus>()
|
||||
const viewer = {
|
||||
displayName: 'Test User',
|
||||
email: 'test@example.com',
|
||||
organizationName: 'Test Org'
|
||||
}
|
||||
linearStatus.mockReturnValueOnce(staleMountCheck.promise).mockReturnValueOnce(freshConnectCheck.promise)
|
||||
linearConnect.mockResolvedValueOnce({ ok: true, viewer })
|
||||
const store = createTestStore()
|
||||
|
||||
const mountCheck = store.getState().checkLinearConnection()
|
||||
await store.getState().connectLinear('linear-key')
|
||||
|
||||
expect(linearStatus).toHaveBeenCalledTimes(2)
|
||||
expect(store.getState().linearStatus.connected).toBe(true)
|
||||
|
||||
freshConnectCheck.resolve({ connected: true, viewer })
|
||||
await Promise.resolve()
|
||||
|
||||
staleMountCheck.resolve({ connected: false, viewer: null })
|
||||
await mountCheck
|
||||
|
||||
expect(store.getState().linearStatus.connected).toBe(true)
|
||||
expect(store.getState().linearStatus.viewer?.email).toBe('test@example.com')
|
||||
})
|
||||
|
||||
it('ignores stale direct status writes after a newer mutation', async () => {
|
||||
const testResult = deferred<{ ok: true; viewer: LinearViewer }>()
|
||||
const staleStatus = deferred<LinearConnectionStatus>()
|
||||
const viewer = {
|
||||
displayName: 'Test User',
|
||||
email: 'test@example.com',
|
||||
organizationName: 'Test Org'
|
||||
}
|
||||
linearTestConnection.mockReturnValueOnce(testResult.promise)
|
||||
linearStatus.mockReturnValueOnce(staleStatus.promise)
|
||||
linearDisconnect.mockResolvedValueOnce(undefined)
|
||||
const store = createTestStore()
|
||||
|
||||
const testPromise = store.getState().testLinearConnection()
|
||||
testResult.resolve({ ok: true, viewer })
|
||||
await Promise.resolve()
|
||||
|
||||
await store.getState().disconnectLinear()
|
||||
staleStatus.resolve({ connected: true, viewer })
|
||||
await testPromise
|
||||
|
||||
expect(store.getState().linearStatus.connected).toBe(false)
|
||||
expect(store.getState().linearStatus.viewer).toBeNull()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -63,6 +63,8 @@ type InflightLinearListRequest = {
|
|||
const inflightSearchRequests = new Map<string, InflightLinearListRequest>()
|
||||
const inflightListRequests = new Map<string, InflightLinearListRequest>()
|
||||
const inflightTeamRequests = new Map<string, Promise<LinearTeam[]>>()
|
||||
let inflightStatusRequest: Promise<void> | null = null
|
||||
let statusRequestGeneration = 0
|
||||
|
||||
function getSelectedWorkspaceId(status: LinearConnectionStatus): LinearWorkspaceSelection | null {
|
||||
return status.selectedWorkspaceId ?? status.activeWorkspaceId ?? null
|
||||
|
|
@ -94,6 +96,16 @@ type LinearIssueReadArgs =
|
|||
|
||||
type LinearFetchOptions = { force?: boolean }
|
||||
|
||||
function beginStatusOperation(): number {
|
||||
statusRequestGeneration += 1
|
||||
inflightStatusRequest = null
|
||||
return statusRequestGeneration
|
||||
}
|
||||
|
||||
function isCurrentStatusOperation(generation: number): boolean {
|
||||
return generation === statusRequestGeneration
|
||||
}
|
||||
|
||||
export type LinearSlice = {
|
||||
linearStatus: LinearConnectionStatus
|
||||
linearStatusChecked: boolean
|
||||
|
|
@ -101,7 +113,7 @@ export type LinearSlice = {
|
|||
linearSearchCache: Record<string, CacheEntry<LinearIssue[]>>
|
||||
linearTeamCache: Record<string, CacheEntry<LinearTeam[]>>
|
||||
|
||||
checkLinearConnection: () => Promise<void>
|
||||
checkLinearConnection: (force?: boolean) => Promise<void>
|
||||
connectLinear: (
|
||||
apiKey: string
|
||||
) => Promise<{ ok: true; viewer: LinearViewer } | { ok: false; error: string }>
|
||||
|
|
@ -139,36 +151,59 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s
|
|||
linearSearchCache: {},
|
||||
linearTeamCache: {},
|
||||
|
||||
checkLinearConnection: async () => {
|
||||
try {
|
||||
const status = (await linearStatus(get().settings)) as LinearConnectionStatus
|
||||
const prev = get().linearStatus
|
||||
if (
|
||||
prev.connected !== status.connected ||
|
||||
prev.viewer?.email !== status.viewer?.email ||
|
||||
getSelectedWorkspaceId(prev) !== getSelectedWorkspaceId(status) ||
|
||||
(prev.workspaces?.length ?? 0) !== (status.workspaces?.length ?? 0)
|
||||
) {
|
||||
set({ linearStatus: status, linearStatusChecked: true })
|
||||
} else if (!get().linearStatusChecked) {
|
||||
set({ linearStatusChecked: true })
|
||||
}
|
||||
} catch {
|
||||
if (get().linearStatus.connected) {
|
||||
set({ linearStatus: { connected: false, viewer: null }, linearStatusChecked: true })
|
||||
} else if (!get().linearStatusChecked) {
|
||||
set({ linearStatusChecked: true })
|
||||
}
|
||||
checkLinearConnection: async (force = false) => {
|
||||
if (inflightStatusRequest && !force) {
|
||||
return inflightStatusRequest
|
||||
}
|
||||
|
||||
const requestGeneration = beginStatusOperation()
|
||||
inflightStatusRequest = linearStatus(get().settings)
|
||||
.then((status) => {
|
||||
if (!isCurrentStatusOperation(requestGeneration)) {
|
||||
return
|
||||
}
|
||||
const typedStatus = status as LinearConnectionStatus
|
||||
const prev = get().linearStatus
|
||||
if (
|
||||
prev.connected !== typedStatus.connected ||
|
||||
prev.viewer?.email !== typedStatus.viewer?.email ||
|
||||
getSelectedWorkspaceId(prev) !== getSelectedWorkspaceId(typedStatus) ||
|
||||
(prev.workspaces?.length ?? 0) !== (typedStatus.workspaces?.length ?? 0)
|
||||
) {
|
||||
set({ linearStatus: typedStatus, linearStatusChecked: true })
|
||||
} else if (!get().linearStatusChecked) {
|
||||
set({ linearStatusChecked: true })
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!isCurrentStatusOperation(requestGeneration)) {
|
||||
return
|
||||
}
|
||||
if (get().linearStatus.connected) {
|
||||
set({ linearStatus: { connected: false, viewer: null }, linearStatusChecked: true })
|
||||
} else if (!get().linearStatusChecked) {
|
||||
set({ linearStatusChecked: true })
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (isCurrentStatusOperation(requestGeneration)) {
|
||||
inflightStatusRequest = null
|
||||
}
|
||||
})
|
||||
|
||||
return inflightStatusRequest
|
||||
},
|
||||
|
||||
testLinearConnection: async (workspaceId) => {
|
||||
const requestGeneration = beginStatusOperation()
|
||||
try {
|
||||
const result = (await linearTestConnection(get().settings, workspaceId)) as
|
||||
| { ok: true; viewer: LinearViewer }
|
||||
| { ok: false; error: string }
|
||||
const status = await linearStatus(get().settings)
|
||||
set({ linearStatus: status, linearStatusChecked: true })
|
||||
if (isCurrentStatusOperation(requestGeneration)) {
|
||||
set({ linearStatus: status, linearStatusChecked: true })
|
||||
}
|
||||
return result
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Test failed'
|
||||
|
|
@ -177,16 +212,17 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s
|
|||
},
|
||||
|
||||
connectLinear: async (apiKey: string) => {
|
||||
const requestGeneration = beginStatusOperation()
|
||||
try {
|
||||
const result = await linearConnect(get().settings, apiKey)
|
||||
if (result.ok) {
|
||||
if (result.ok && isCurrentStatusOperation(requestGeneration)) {
|
||||
set({
|
||||
linearStatus: {
|
||||
connected: true,
|
||||
viewer: result.viewer as LinearViewer
|
||||
}
|
||||
})
|
||||
void get().checkLinearConnection()
|
||||
void get().checkLinearConnection(true)
|
||||
}
|
||||
return result as { ok: true; viewer: LinearViewer } | { ok: false; error: string }
|
||||
} catch (error) {
|
||||
|
|
@ -196,7 +232,11 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s
|
|||
},
|
||||
|
||||
selectLinearWorkspace: async (workspaceId) => {
|
||||
const requestGeneration = beginStatusOperation()
|
||||
const status = await linearSelectWorkspace(get().settings, workspaceId)
|
||||
if (!isCurrentStatusOperation(requestGeneration)) {
|
||||
return
|
||||
}
|
||||
inflightIssueRequests.clear()
|
||||
inflightSearchRequests.clear()
|
||||
inflightListRequests.clear()
|
||||
|
|
@ -212,7 +252,11 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s
|
|||
},
|
||||
|
||||
disconnectLinear: async () => {
|
||||
const requestGeneration = beginStatusOperation()
|
||||
await linearDisconnect(get().settings)
|
||||
if (!isCurrentStatusOperation(requestGeneration)) {
|
||||
return
|
||||
}
|
||||
inflightIssueRequests.clear()
|
||||
inflightSearchRequests.clear()
|
||||
inflightListRequests.clear()
|
||||
|
|
@ -227,13 +271,17 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s
|
|||
},
|
||||
|
||||
disconnectLinearWorkspace: async (workspaceId) => {
|
||||
const requestGeneration = beginStatusOperation()
|
||||
await linearDisconnectWorkspace(get().settings, workspaceId)
|
||||
const status = await linearStatus(get().settings)
|
||||
if (!isCurrentStatusOperation(requestGeneration)) {
|
||||
return
|
||||
}
|
||||
inflightIssueRequests.clear()
|
||||
inflightSearchRequests.clear()
|
||||
inflightListRequests.clear()
|
||||
inflightTeamRequests.clear()
|
||||
clearLinearMetadataCache()
|
||||
const status = await linearStatus(get().settings)
|
||||
set({
|
||||
linearStatus: status,
|
||||
linearIssueCache: {},
|
||||
|
|
|
|||
|
|
@ -0,0 +1,108 @@
|
|||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { create } from 'zustand'
|
||||
import type { PreflightStatus } from '../../../../preload/api-types'
|
||||
import type { AppState } from '../types'
|
||||
import { createPreflightSlice } from './preflight'
|
||||
|
||||
const preflightCheck = vi.fn()
|
||||
|
||||
globalThis.window = {
|
||||
api: {
|
||||
preflight: {
|
||||
check: preflightCheck,
|
||||
detectAgents: vi.fn().mockResolvedValue([]),
|
||||
refreshAgents: vi.fn().mockResolvedValue({
|
||||
agents: [],
|
||||
addedPathSegments: [],
|
||||
shellHydrationOk: false,
|
||||
pathSource: 'sync_seed_only',
|
||||
pathFailureReason: 'spawn_error'
|
||||
}),
|
||||
detectRemoteAgents: vi.fn().mockResolvedValue([])
|
||||
}
|
||||
} as unknown as Window['api']
|
||||
} as Window & typeof globalThis
|
||||
|
||||
function createTestStore() {
|
||||
return create<AppState>()(
|
||||
(...a) =>
|
||||
({
|
||||
...createPreflightSlice(...a)
|
||||
}) as AppState
|
||||
)
|
||||
}
|
||||
|
||||
function makeStatus(glabInstalled: boolean): PreflightStatus {
|
||||
return {
|
||||
git: { installed: true },
|
||||
gh: { installed: true, authenticated: true },
|
||||
glab: { installed: glabInstalled, authenticated: glabInstalled }
|
||||
}
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (error: unknown) => void
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res
|
||||
reject = rej
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
describe('createPreflightSlice', () => {
|
||||
it('dedupes concurrent non-forced checks', async () => {
|
||||
preflightCheck.mockReset()
|
||||
const pending = deferred<PreflightStatus>()
|
||||
preflightCheck.mockReturnValueOnce(pending.promise)
|
||||
const store = createTestStore()
|
||||
|
||||
const first = store.getState().refreshPreflightStatus()
|
||||
const second = store.getState().refreshPreflightStatus()
|
||||
|
||||
expect(preflightCheck).toHaveBeenCalledTimes(1)
|
||||
pending.resolve(makeStatus(true))
|
||||
await Promise.all([first, second])
|
||||
|
||||
expect(store.getState().preflightStatus?.glab?.installed).toBe(true)
|
||||
expect(store.getState().preflightStatusChecked).toBe(true)
|
||||
expect(store.getState().preflightStatusLoading).toBe(false)
|
||||
})
|
||||
|
||||
it('lets forced checks bypass non-forced dedupe and win stale races', async () => {
|
||||
preflightCheck.mockReset()
|
||||
const stale = deferred<PreflightStatus>()
|
||||
const fresh = deferred<PreflightStatus>()
|
||||
preflightCheck.mockReturnValueOnce(stale.promise).mockReturnValueOnce(fresh.promise)
|
||||
const store = createTestStore()
|
||||
|
||||
const normal = store.getState().refreshPreflightStatus()
|
||||
const forced = store.getState().refreshPreflightStatus({ force: true })
|
||||
|
||||
expect(preflightCheck).toHaveBeenNthCalledWith(1, undefined)
|
||||
expect(preflightCheck).toHaveBeenNthCalledWith(2, { force: true })
|
||||
|
||||
fresh.resolve(makeStatus(true))
|
||||
await forced
|
||||
stale.resolve(makeStatus(false))
|
||||
await normal
|
||||
|
||||
expect(store.getState().preflightStatus?.glab?.installed).toBe(true)
|
||||
})
|
||||
|
||||
it('dedupes lazy checks onto an in-flight forced refresh', async () => {
|
||||
preflightCheck.mockReset()
|
||||
const fresh = deferred<PreflightStatus>()
|
||||
preflightCheck.mockReturnValueOnce(fresh.promise)
|
||||
const store = createTestStore()
|
||||
|
||||
const forced = store.getState().refreshPreflightStatus({ force: true })
|
||||
const lazy = store.getState().refreshPreflightStatus()
|
||||
|
||||
expect(preflightCheck).toHaveBeenCalledTimes(1)
|
||||
fresh.resolve(makeStatus(true))
|
||||
await Promise.all([forced, lazy])
|
||||
|
||||
expect(store.getState().preflightStatus?.glab?.installed).toBe(true)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
import type { StateCreator } from 'zustand'
|
||||
import type { PreflightStatus } from '../../../../preload/api-types'
|
||||
import type { AppState } from '../types'
|
||||
|
||||
export type PreflightSlice = {
|
||||
preflightStatus: PreflightStatus | null
|
||||
preflightStatusChecked: boolean
|
||||
preflightStatusLoading: boolean
|
||||
preflightStatusError: string | null
|
||||
|
||||
refreshPreflightStatus: (options?: { force?: boolean }) => Promise<void>
|
||||
}
|
||||
|
||||
let nonForcedPreflightRequest: Promise<void> | null = null
|
||||
let forcedPreflightRequest: Promise<void> | null = null
|
||||
let latestPreflightRequestId = 0
|
||||
|
||||
function getErrorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : 'Failed to check integrations.'
|
||||
}
|
||||
|
||||
export const createPreflightSlice: StateCreator<AppState, [], [], PreflightSlice> = (set) => ({
|
||||
preflightStatus: null,
|
||||
preflightStatusChecked: false,
|
||||
preflightStatusLoading: false,
|
||||
preflightStatusError: null,
|
||||
|
||||
refreshPreflightStatus: async (options) => {
|
||||
const force = options?.force === true
|
||||
if (!force && forcedPreflightRequest) {
|
||||
return forcedPreflightRequest
|
||||
}
|
||||
if (!force && nonForcedPreflightRequest) {
|
||||
return nonForcedPreflightRequest
|
||||
}
|
||||
|
||||
const requestId = ++latestPreflightRequestId
|
||||
set({ preflightStatusLoading: true, preflightStatusError: null })
|
||||
|
||||
const request = window.api.preflight
|
||||
.check(force ? { force: true } : undefined)
|
||||
.then((status) => {
|
||||
if (requestId !== latestPreflightRequestId) {
|
||||
return
|
||||
}
|
||||
set({
|
||||
preflightStatus: status,
|
||||
preflightStatusChecked: true,
|
||||
preflightStatusLoading: false,
|
||||
preflightStatusError: null
|
||||
})
|
||||
})
|
||||
.catch((error) => {
|
||||
if (requestId !== latestPreflightRequestId) {
|
||||
return
|
||||
}
|
||||
set({
|
||||
preflightStatusChecked: true,
|
||||
preflightStatusLoading: false,
|
||||
preflightStatusError: getErrorMessage(error)
|
||||
})
|
||||
})
|
||||
.finally(() => {
|
||||
if (!force && nonForcedPreflightRequest === request) {
|
||||
nonForcedPreflightRequest = null
|
||||
}
|
||||
if (force && forcedPreflightRequest === request) {
|
||||
forcedPreflightRequest = null
|
||||
}
|
||||
})
|
||||
|
||||
if (!force) {
|
||||
nonForcedPreflightRequest = request
|
||||
} else {
|
||||
forcedPreflightRequest = request
|
||||
}
|
||||
|
||||
return request
|
||||
}
|
||||
})
|
||||
|
|
@ -116,6 +116,7 @@ import { createSettingsSlice } from './settings'
|
|||
import { createGitHubSlice } from './github'
|
||||
import { createHostedReviewSlice } from './hosted-review'
|
||||
import { createLinearSlice } from './linear'
|
||||
import { createPreflightSlice } from './preflight'
|
||||
import { createEditorSlice } from './editor'
|
||||
import { createStatsSlice } from './stats'
|
||||
import { createMemorySlice } from './memory'
|
||||
|
|
@ -145,6 +146,7 @@ function createTestStore() {
|
|||
...createGitHubSlice(...a),
|
||||
...createHostedReviewSlice(...a),
|
||||
...createLinearSlice(...a),
|
||||
...createPreflightSlice(...a),
|
||||
...createEditorSlice(...a),
|
||||
...createStatsSlice(...a),
|
||||
...createMemorySlice(...a),
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import { createSettingsSlice } from './settings'
|
|||
import { createGitHubSlice } from './github'
|
||||
import { createHostedReviewSlice } from './hosted-review'
|
||||
import { createLinearSlice } from './linear'
|
||||
import { createPreflightSlice } from './preflight'
|
||||
import { createEditorSlice } from './editor'
|
||||
import { createStatsSlice } from './stats'
|
||||
import { createMemorySlice } from './memory'
|
||||
|
|
@ -55,6 +56,7 @@ export function createTestStore() {
|
|||
...createGitHubSlice(...a),
|
||||
...createHostedReviewSlice(...a),
|
||||
...createLinearSlice(...a),
|
||||
...createPreflightSlice(...a),
|
||||
...createEditorSlice(...a),
|
||||
...createStatsSlice(...a),
|
||||
...createMemorySlice(...a),
|
||||
|
|
|
|||
|
|
@ -110,6 +110,7 @@ import { createSettingsSlice } from './settings'
|
|||
import { createGitHubSlice } from './github'
|
||||
import { createHostedReviewSlice } from './hosted-review'
|
||||
import { createLinearSlice } from './linear'
|
||||
import { createPreflightSlice } from './preflight'
|
||||
import { createEditorSlice } from './editor'
|
||||
import { createStatsSlice } from './stats'
|
||||
import { createMemorySlice } from './memory'
|
||||
|
|
@ -141,6 +142,7 @@ function createTestStore() {
|
|||
...createGitHubSlice(...a),
|
||||
...createHostedReviewSlice(...a),
|
||||
...createLinearSlice(...a),
|
||||
...createPreflightSlice(...a),
|
||||
...createEditorSlice(...a),
|
||||
...createStatsSlice(...a),
|
||||
...createMemorySlice(...a),
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import type { SettingsSlice } from './slices/settings'
|
|||
import type { GitHubSlice } from './slices/github'
|
||||
import type { HostedReviewSlice } from './slices/hosted-review'
|
||||
import type { LinearSlice } from './slices/linear'
|
||||
import type { PreflightSlice } from './slices/preflight'
|
||||
import type { EditorSlice } from './slices/editor'
|
||||
import type { StatsSlice } from './slices/stats'
|
||||
import type { MemorySlice } from './slices/memory'
|
||||
|
|
@ -35,6 +36,7 @@ export type AppState = RepoSlice &
|
|||
GitHubSlice &
|
||||
HostedReviewSlice &
|
||||
LinearSlice &
|
||||
PreflightSlice &
|
||||
EditorSlice &
|
||||
StatsSlice &
|
||||
MemorySlice &
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { normalizeVisibleTaskProviders, resolveVisibleTaskProvider } from './task-providers'
|
||||
import {
|
||||
filterAvailableTaskProviders,
|
||||
normalizeVisibleTaskProviders,
|
||||
resolveVisibleTaskProvider
|
||||
} from './task-providers'
|
||||
|
||||
describe('task providers', () => {
|
||||
it('normalizes provider lists while preserving supported order', () => {
|
||||
|
|
@ -16,4 +20,22 @@ describe('task providers', () => {
|
|||
it('resolves hidden preferred providers to the first visible provider', () => {
|
||||
expect(resolveVisibleTaskProvider('github', ['linear'])).toBe('linear')
|
||||
})
|
||||
|
||||
it('filters runtime-unavailable providers without changing preference normalization', () => {
|
||||
expect(
|
||||
filterAvailableTaskProviders(['github', 'gitlab', 'linear'], {
|
||||
gitlabInstalled: false,
|
||||
linearConnected: true
|
||||
})
|
||||
).toEqual(['github', 'linear'])
|
||||
})
|
||||
|
||||
it('falls back to GitHub when every preferred provider is unavailable', () => {
|
||||
expect(
|
||||
filterAvailableTaskProviders(['gitlab', 'linear'], {
|
||||
gitlabInstalled: false,
|
||||
linearConnected: false
|
||||
})
|
||||
).toEqual(['github'])
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -24,6 +24,28 @@ export function normalizeVisibleTaskProviders(value: unknown): TaskProvider[] {
|
|||
return normalized.length > 0 ? normalized : [...TASK_PROVIDERS]
|
||||
}
|
||||
|
||||
export type TaskProviderAvailability = {
|
||||
gitlabInstalled: boolean
|
||||
linearConnected: boolean
|
||||
}
|
||||
|
||||
export function filterAvailableTaskProviders(
|
||||
visibleProviders: readonly TaskProvider[],
|
||||
availability: TaskProviderAvailability
|
||||
): TaskProvider[] {
|
||||
const available = visibleProviders.filter((provider) => {
|
||||
if (provider === 'github') {
|
||||
return true
|
||||
}
|
||||
if (provider === 'gitlab') {
|
||||
return availability.gitlabInstalled
|
||||
}
|
||||
return availability.linearConnected
|
||||
})
|
||||
|
||||
return available.length > 0 ? available : ['github']
|
||||
}
|
||||
|
||||
export function resolveVisibleTaskProvider(
|
||||
preferred: TaskProvider | null | undefined,
|
||||
visibleProviders: readonly TaskProvider[]
|
||||
|
|
|
|||
Loading…
Reference in New Issue