15153 lines
540 KiB
TypeScript
15153 lines
540 KiB
TypeScript
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
|
import type { ReactNode } from 'react'
|
|
import {
|
|
ActivityIndicator,
|
|
FlatList,
|
|
Linking,
|
|
Platform,
|
|
Pressable,
|
|
ScrollView,
|
|
StyleSheet,
|
|
Text,
|
|
TextInput,
|
|
View
|
|
} from 'react-native'
|
|
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'
|
|
import * as Clipboard from 'expo-clipboard'
|
|
import { useLocalSearchParams, useRouter } from 'expo-router'
|
|
import {
|
|
AlertTriangle,
|
|
Check,
|
|
ChevronDown,
|
|
ChevronLeft,
|
|
ChevronRight,
|
|
ChevronUp,
|
|
Copy,
|
|
ExternalLink,
|
|
GitBranch,
|
|
Lock,
|
|
Pencil,
|
|
Plus,
|
|
RefreshCw,
|
|
Send,
|
|
X
|
|
} from 'lucide-react-native'
|
|
import type { RpcClient } from '../../../src/transport/rpc-client'
|
|
import type { RpcSuccess } from '../../../src/transport/types'
|
|
import { useHostClient } from '../../../src/transport/client-context'
|
|
import {
|
|
useLastConnectedAt,
|
|
useReconnectAttempt
|
|
} from '../../../src/transport/client-context-connection-metrics'
|
|
import { classifyConnection } from '../../../src/transport/connection-health'
|
|
import { StatusDot } from '../../../src/components/StatusDot'
|
|
import { ActionSheetModal } from '../../../src/components/ActionSheetModal'
|
|
import { BottomDrawer } from '../../../src/components/BottomDrawer'
|
|
import { ConfirmModal } from '../../../src/components/ConfirmModal'
|
|
import { MobileMarkdown } from '../../../src/components/MobileMarkdown'
|
|
import { MobileAgentIcon } from '../../../src/components/MobileAgentIcon'
|
|
import { MobileWorkspaceNameInput } from '../../../src/components/MobileWorkspaceNameInput'
|
|
import { MobileSearchField } from '../../../src/components/MobileSearchField'
|
|
import { MobileSyntaxSegments } from '../../../src/components/MobileSyntaxSegments'
|
|
import { PickerModal, type PickerOption } from '../../../src/components/PickerModal'
|
|
import { TaskProviderLogo } from '../../../src/components/TaskProviderLogo'
|
|
import {
|
|
buildGitHubPrFileDiffPreview,
|
|
type GitHubPrFileDiffLine
|
|
} from '../../../src/tasks/github-pr-file-diff'
|
|
import {
|
|
highlightMobileDiffLines,
|
|
resolveMobileSyntaxLanguage
|
|
} from '../../../src/session/mobile-file-syntax'
|
|
import { buildGitHubCheckSummary } from '../../../src/tasks/github-check-summary'
|
|
import { buildGitLabCheckSummary } from '../../../src/tasks/gitlab-check-summary'
|
|
import {
|
|
getHostedMergeLabel,
|
|
getHostedReviewLabel,
|
|
getHostedReviewSignalTone,
|
|
getHostedChecksLabel
|
|
} from '../../../src/tasks/mobile-hosted-check-status'
|
|
import { buildTaskWorkspaceCreateParams } from '../../../src/tasks/workspace-create-params'
|
|
import { MOBILE_TASKS_CAPABILITY } from '../../../src/tasks/mobile-tasks-capability'
|
|
import {
|
|
filterWorkspaceAgents,
|
|
isWorkspaceAgentEnabled,
|
|
pickWorkspaceAgent,
|
|
resolveWorkspaceAgentSelection,
|
|
workspaceAgentLabel,
|
|
type WorkspaceAgentChoice
|
|
} from '../../../src/tasks/workspace-agent-selection'
|
|
import { shouldResolveHostedReviewStartPoint } from '../../../src/tasks/hosted-review-start-point'
|
|
import { getLinkedWorkItemSuggestedName } from '../../../src/tasks/mobile-workspace-name'
|
|
import {
|
|
filterGitHubProjectRowsForRepos,
|
|
findRepoForGitHubProjectRepository,
|
|
type GitHubRepoSlugCacheEntry
|
|
} from '../../../src/tasks/github-project-repo-match'
|
|
import {
|
|
parseGitHubProjectInput as parseProjectInput,
|
|
type GitHubProjectOwnerType,
|
|
type GitHubProjectPartialFailure,
|
|
type GitHubProjectRef,
|
|
type GitHubProjectSettings,
|
|
type GitHubProjectSummary,
|
|
type GitHubProjectViewSummary
|
|
} from '../../../src/tasks/github-project-reference'
|
|
import {
|
|
extractGitHubIssueSourceFallback,
|
|
extractGitHubIssueSourceError,
|
|
type GitHubIssueSourceFallback,
|
|
type GitHubIssueSourceError
|
|
} from '../../../src/tasks/github-work-item-source-errors'
|
|
import { parseSparsePresetDirectories } from '../../../src/tasks/sparse-preset-draft'
|
|
import {
|
|
deriveWorkspaceSshGate,
|
|
workspaceSshStatusLabel
|
|
} from '../../../src/tasks/workspace-ssh-gate'
|
|
import { WORKTREE_CREATE_TIMEOUT_MS } from '../../../src/tasks/workspace-create-timeout'
|
|
import {
|
|
isSetupHookTrusted,
|
|
normalizeSetupHookTrust,
|
|
trustedOrcaHooksWithSetupApproval,
|
|
wasSetupHookPreviouslyApproved
|
|
} from '../../../src/tasks/setup-hook-trust'
|
|
import { colors, radii, spacing, typography } from '../../../src/theme/mobile-theme'
|
|
import { triggerMediumImpact } from '../../../src/platform/haptics'
|
|
import {
|
|
type GitHubProjectSortDirection,
|
|
type GitHubProjectTable as SharedGitHubProjectTable,
|
|
groupRows,
|
|
isIterationCurrent,
|
|
sortRows,
|
|
type ProjectGroup
|
|
} from '../../../src/tasks/mobile-github-project-group-sort'
|
|
import {
|
|
CROSS_REPO_DISPLAY_LIMIT,
|
|
isGitHubWorkItemsSshRemoteRequiredError,
|
|
PER_REPO_FETCH_LIMIT
|
|
} from '../../../src/tasks/mobile-work-items'
|
|
import {
|
|
filterAvailableTaskProviders,
|
|
normalizeVisibleTaskProviders,
|
|
resolveVisibleTaskProvider,
|
|
type TaskProvider
|
|
} from '../../../src/tasks/mobile-task-providers'
|
|
import {
|
|
extractLinearIssueReadItems,
|
|
type LinearMobileIssue
|
|
} from '../../../src/tasks/linear-mobile-issue-read'
|
|
import { MOBILE_TUI_AGENT_AUTO_PICK_ORDER } from '../../../src/tasks/mobile-tui-agents'
|
|
import { resolveComposerBranchSelection } from '../../../src/tasks/mobile-composer-branch-selection'
|
|
import {
|
|
clearMobileTaskCopyFeedbackTimer,
|
|
scheduleMobileTaskCopyFeedbackReset
|
|
} from '../../../src/tasks/mobile-task-copy-feedback-timer'
|
|
import type {
|
|
BaseRefSearchResult,
|
|
GitHubOwnerRepo,
|
|
ProviderCheckSummary,
|
|
PersistedTrustedOrcaHooks,
|
|
SparsePreset,
|
|
TuiAgent
|
|
} from '../../../../src/shared/types'
|
|
import type { SshConnectionState } from '../../../../src/shared/ssh-types'
|
|
import type { HostedReviewDecision } from '../../../../src/shared/hosted-review'
|
|
import {
|
|
githubProjectHost,
|
|
githubProjectIdentityKey as githubProjectKey
|
|
} from '../../../../src/shared/github-project-identity'
|
|
|
|
type RepoSummary = {
|
|
id: string
|
|
displayName: string
|
|
path: string
|
|
badgeColor?: string
|
|
kind?: 'git' | 'folder'
|
|
connectionId?: string | null
|
|
issueSourcePreference?: IssueSourcePreference
|
|
/** Fork parent resolved by the host; drives upstream Project row matching. */
|
|
upstream?: { owner: string; repo: string; host?: string } | null
|
|
}
|
|
|
|
type IssueSourcePreference = 'upstream' | 'origin' | 'auto'
|
|
|
|
type GitHubWorkItem = {
|
|
id: string
|
|
type: 'issue' | 'pr'
|
|
number: number
|
|
title: string
|
|
state: 'open' | 'closed' | 'merged' | 'draft'
|
|
url: string
|
|
labels: string[]
|
|
updatedAt: string
|
|
author: string | null
|
|
branchName?: string
|
|
baseRefName?: string
|
|
isCrossRepository?: boolean
|
|
additions?: number
|
|
deletions?: number
|
|
changedFiles?: number
|
|
repoId: string
|
|
repoName: string
|
|
reviewDecision?: string | null
|
|
reviewRequests?: GitHubAssignableUser[]
|
|
latestReviews?: GitHubPRReviewSummary[]
|
|
checksSummary?: ProviderCheckSummary
|
|
mergeable?: GitHubPRMergeableState
|
|
mergeStateStatus?: string | null
|
|
}
|
|
type GitHubAssignableUser = {
|
|
login: string
|
|
name?: string | null
|
|
avatarUrl?: string | null
|
|
}
|
|
type GitHubPRReviewSummary = {
|
|
login: string
|
|
state?: string | null
|
|
avatarUrl?: string | null
|
|
}
|
|
type GitHubPRMergeableState = 'MERGEABLE' | 'CONFLICTING' | 'UNKNOWN'
|
|
|
|
type GitHubPRReviewerRow = {
|
|
login: string
|
|
name?: string | null
|
|
avatarUrl?: string | null
|
|
stateLabel: string
|
|
}
|
|
type GitHubRepoSources = {
|
|
issues: GitHubOwnerRepo | null
|
|
prs: GitHubOwnerRepo | null
|
|
upstreamCandidate: GitHubOwnerRepo | null
|
|
}
|
|
type TaskRuntimeStatus = {
|
|
capabilities?: string[]
|
|
}
|
|
|
|
type TasksSupportState =
|
|
| { kind: 'unknown'; client: RpcClient | null }
|
|
| { kind: 'supported'; client: RpcClient }
|
|
| { kind: 'unsupported'; client: RpcClient }
|
|
type GitLabWorkItem = {
|
|
id: string
|
|
type: 'issue' | 'mr'
|
|
number: number
|
|
title: string
|
|
state: 'opened' | 'closed' | 'merged' | 'locked' | 'draft'
|
|
url: string
|
|
labels: string[]
|
|
updatedAt: string
|
|
author: string | null
|
|
branchName?: string
|
|
baseRefName?: string
|
|
isCrossRepository?: boolean
|
|
projectRef?: { host: string; path: string }
|
|
checksSummary?: ProviderCheckSummary
|
|
mergeable?: 'MERGEABLE' | 'CONFLICTING' | 'UNKNOWN'
|
|
reviewDecision?: HostedReviewDecision
|
|
reviewerCount?: number
|
|
repoId: string
|
|
repoName: string
|
|
}
|
|
|
|
type GitLabTodo = {
|
|
id: number
|
|
actionName: string
|
|
targetType: string
|
|
targetIid: number | null
|
|
targetTitle: string
|
|
targetUrl: string
|
|
projectPath: string
|
|
authorUsername: string
|
|
updatedAt: string
|
|
state: 'pending' | 'done'
|
|
}
|
|
|
|
type GitPushTarget = {
|
|
remoteName: string
|
|
branchName: string
|
|
remoteUrl?: string
|
|
}
|
|
|
|
type SetupDecision = 'inherit' | 'run' | 'skip'
|
|
type SetupRunPolicy = 'ask' | 'run-by-default' | 'skip-by-default'
|
|
|
|
type RepoHooksResponse = {
|
|
hooks: { scripts?: { setup?: string } } | null
|
|
source: string | null
|
|
setupRunPolicy?: SetupRunPolicy
|
|
setupTrust?: {
|
|
contentHash: string
|
|
scriptContent: string
|
|
}
|
|
}
|
|
|
|
type LinearProject = {
|
|
id: string
|
|
name: string
|
|
url?: string
|
|
color?: string
|
|
}
|
|
|
|
type LinearIssueChild = {
|
|
id: string
|
|
identifier: string
|
|
title: string
|
|
url: string
|
|
}
|
|
|
|
type LinearIssue = LinearMobileIssue
|
|
|
|
type LinearState = {
|
|
id: string
|
|
name: string
|
|
type: string
|
|
color?: string
|
|
}
|
|
|
|
type LinearTeam = {
|
|
id: string
|
|
workspaceId?: string
|
|
workspaceName?: string
|
|
name: string
|
|
key: string
|
|
}
|
|
|
|
type DetailComment = {
|
|
id: string | number
|
|
author?: string
|
|
authorAvatarUrl?: string
|
|
user?: { displayName?: string }
|
|
isBot?: boolean
|
|
body: string
|
|
createdAt?: string
|
|
url?: string
|
|
reactions?: Array<{
|
|
content:
|
|
| 'thumbs_up'
|
|
| 'thumbs_down'
|
|
| 'laugh'
|
|
| 'confused'
|
|
| 'heart'
|
|
| 'hooray'
|
|
| 'rocket'
|
|
| 'eyes'
|
|
count: number
|
|
}>
|
|
path?: string
|
|
line?: number
|
|
startLine?: number
|
|
threadId?: string
|
|
isResolved?: boolean
|
|
}
|
|
|
|
type GitHubDetailFile = {
|
|
path: string
|
|
oldPath?: string
|
|
status?: 'added' | 'modified' | 'removed' | 'renamed' | 'copied' | 'changed' | 'unchanged'
|
|
additions?: number
|
|
deletions?: number
|
|
isBinary?: boolean
|
|
viewerViewedState?: 'DISMISSED' | 'VIEWED' | 'UNVIEWED'
|
|
}
|
|
|
|
type GitHubDetailCheck = {
|
|
name: string
|
|
status: string
|
|
conclusion?: string | null
|
|
url?: string | null
|
|
}
|
|
|
|
type GitHubPRFileContents = {
|
|
original: string
|
|
modified: string
|
|
originalIsBinary: boolean
|
|
modifiedIsBinary: boolean
|
|
}
|
|
|
|
type DetailPayload =
|
|
| {
|
|
provider: 'github'
|
|
body: string
|
|
comments: DetailComment[]
|
|
labels: string[]
|
|
assignees: string[]
|
|
reviewDecision?: string | null
|
|
reviewRequests: GitHubAssignableUser[]
|
|
latestReviews: GitHubPRReviewSummary[]
|
|
headSha?: string
|
|
baseSha?: string
|
|
pullRequestId?: string
|
|
checks: GitHubDetailCheck[]
|
|
files: GitHubDetailFile[]
|
|
}
|
|
| {
|
|
provider: 'gitlab'
|
|
body: string
|
|
comments: DetailComment[]
|
|
labels: string[]
|
|
assignees: string[]
|
|
pipelineJobs: Array<{
|
|
id?: number
|
|
name: string
|
|
stage: string
|
|
status: string
|
|
webUrl?: string | null
|
|
duration?: number | null
|
|
}>
|
|
}
|
|
| {
|
|
provider: 'linear'
|
|
description: string
|
|
comments: DetailComment[]
|
|
labels: string[]
|
|
assignee?: string
|
|
project?: LinearProject
|
|
children: LinearIssueChild[]
|
|
}
|
|
|
|
type GitHubTaskKind = 'issues' | 'prs'
|
|
type GitHubMode = GitHubTaskKind | 'project'
|
|
type GitHubPreset = 'issues' | 'my-issues' | 'prs' | 'my-prs' | 'review'
|
|
type GitLabView = 'project' | 'todos'
|
|
type GitLabFilter = 'opened' | 'merged' | 'closed' | 'all'
|
|
type LinearFilter = 'assigned' | 'created' | 'all' | 'completed'
|
|
type LinearViewMode = 'list' | 'board'
|
|
type LinearGroupBy = 'none' | 'status' | 'assignee' | 'priority' | 'team'
|
|
type LinearOrderBy = 'priority' | 'updated' | 'identifier'
|
|
type LinearDisplayProperty = 'state' | 'priority' | 'assignee' | 'team' | 'labels' | 'updated'
|
|
type TaskSort = 'updated' | 'repository'
|
|
type DetailCommentGroup =
|
|
| { kind: 'standalone'; comment: DetailComment }
|
|
| { kind: 'thread'; threadId: string; root: DetailComment; replies: DetailComment[] }
|
|
type TaskResumeState = {
|
|
githubMode?: 'items' | 'project'
|
|
githubItemsPreset?: GitHubPreset | 'all' | null
|
|
githubItemsQuery?: string
|
|
githubProjectHiddenFieldIdsByView?: Record<string, string[]>
|
|
linearPreset?: LinearFilter
|
|
linearQuery?: string
|
|
}
|
|
type RuntimeTaskSettings = {
|
|
defaultTuiAgent?: TuiAgent | 'blank' | null
|
|
disabledTuiAgents?: TuiAgent[]
|
|
agentCmdOverrides?: Record<string, string>
|
|
defaultTaskSource?: TaskProvider
|
|
defaultTaskViewPreset?: GitHubPreset | 'all'
|
|
visibleTaskProviders?: TaskProvider[]
|
|
defaultRepoSelection?: string[] | null
|
|
defaultLinearTeamSelection?: string[] | null
|
|
githubProjects?: GitHubProjectSettings
|
|
}
|
|
|
|
type LinearWorkspace = {
|
|
id: string
|
|
organizationName?: string
|
|
displayName?: string
|
|
}
|
|
|
|
type LinearStatusResponse = {
|
|
connected?: boolean
|
|
workspaces?: LinearWorkspace[]
|
|
selectedWorkspaceId?: string | 'all' | null
|
|
activeWorkspaceId?: string | null
|
|
}
|
|
|
|
type GitHubIssueType = {
|
|
id: string
|
|
name: string
|
|
color: string | null
|
|
description: string | null
|
|
}
|
|
type GitHubProjectField =
|
|
| {
|
|
kind: 'field'
|
|
id: string
|
|
name: string
|
|
dataType: string
|
|
}
|
|
| {
|
|
kind: 'single-select'
|
|
id: string
|
|
name: string
|
|
dataType: 'SINGLE_SELECT'
|
|
options: Array<{ id: string; name: string; color: string }>
|
|
}
|
|
| {
|
|
kind: 'iteration'
|
|
id: string
|
|
name: string
|
|
dataType: 'ITERATION'
|
|
iterations: Array<{
|
|
id: string
|
|
title: string
|
|
startDate: string
|
|
duration: number
|
|
completed?: boolean
|
|
}>
|
|
}
|
|
type GitHubProjectSort = {
|
|
direction: GitHubProjectSortDirection
|
|
field: GitHubProjectField
|
|
}
|
|
type GitHubProjectFieldValue =
|
|
| { kind: 'single-select'; fieldId: string; optionId: string; name: string; color: string }
|
|
| {
|
|
kind: 'iteration'
|
|
fieldId: string
|
|
iterationId: string
|
|
title: string
|
|
startDate: string
|
|
duration: number
|
|
}
|
|
| { kind: 'text'; fieldId: string; text: string }
|
|
| { kind: 'number'; fieldId: string; number: number }
|
|
| { kind: 'date'; fieldId: string; date: string }
|
|
| { kind: 'labels'; fieldId: string; labels: Array<{ name: string; color: string }> }
|
|
| { kind: 'users'; fieldId: string; users: Array<{ login: string; name: string | null }> }
|
|
type GitHubProjectFieldMutationValue =
|
|
| { kind: 'text'; text: string }
|
|
| { kind: 'number'; number: number }
|
|
| { kind: 'date'; date: string }
|
|
| { kind: 'single-select'; optionId: string }
|
|
| { kind: 'iteration'; iterationId: string }
|
|
type GitHubProjectRow = {
|
|
id: string
|
|
itemType: 'ISSUE' | 'PULL_REQUEST' | 'DRAFT_ISSUE' | 'REDACTED'
|
|
content: {
|
|
number: number | null
|
|
title: string
|
|
body: string | null
|
|
url: string | null
|
|
state: string | null
|
|
stateReason?: string | null
|
|
isDraft: boolean | null
|
|
repository: string | null
|
|
issueType?: GitHubIssueType | null
|
|
labels: Array<{ name: string; color: string }>
|
|
assignees: Array<{ login: string; name: string | null }>
|
|
parentIssue?: { number: number; title: string; url: string } | null
|
|
}
|
|
fieldValuesByFieldId?: Record<string, GitHubProjectFieldValue>
|
|
updatedAt: string
|
|
position?: number
|
|
}
|
|
type GitHubProjectTable = {
|
|
project: GitHubProjectRef & {
|
|
id: string
|
|
title: string
|
|
url: string
|
|
}
|
|
selectedView: {
|
|
id: string
|
|
number: number
|
|
name: string
|
|
filter: string
|
|
layout: 'TABLE_LAYOUT' | 'BOARD_LAYOUT' | 'ROADMAP_LAYOUT'
|
|
fields?: GitHubProjectField[]
|
|
groupByFields?: GitHubProjectField[]
|
|
sortByFields?: GitHubProjectSort[]
|
|
}
|
|
rows: GitHubProjectRow[]
|
|
totalCount: number
|
|
parentFieldDropped?: boolean
|
|
}
|
|
|
|
type TaskItem =
|
|
| {
|
|
key: string
|
|
provider: 'github'
|
|
title: string
|
|
subtitle: string
|
|
status: string
|
|
updatedAt: string
|
|
source: GitHubWorkItem
|
|
}
|
|
| {
|
|
key: string
|
|
provider: 'gitlab'
|
|
title: string
|
|
subtitle: string
|
|
status: string
|
|
updatedAt: string
|
|
source: GitLabWorkItem
|
|
}
|
|
| {
|
|
key: string
|
|
provider: 'gitlabTodo'
|
|
title: string
|
|
subtitle: string
|
|
status: string
|
|
updatedAt: string
|
|
source: GitLabTodo
|
|
}
|
|
| {
|
|
key: string
|
|
provider: 'linear'
|
|
title: string
|
|
subtitle: string
|
|
status: string
|
|
updatedAt: string
|
|
source: LinearIssue
|
|
}
|
|
|
|
type ActionableTaskItem = Exclude<TaskItem, { provider: 'gitlabTodo' }>
|
|
type HostedReviewMergeMethod = 'merge' | 'squash' | 'rebase'
|
|
type HostedReviewItem =
|
|
| Extract<TaskItem, { provider: 'github' }>
|
|
| Extract<TaskItem, { provider: 'gitlab' }>
|
|
type PendingHostedMerge = {
|
|
item: HostedReviewItem
|
|
method: HostedReviewMergeMethod
|
|
}
|
|
type PendingProjectGitHubMerge = {
|
|
row: GitHubProjectRow
|
|
method: HostedReviewMergeMethod
|
|
}
|
|
type PendingHostedStateChange =
|
|
| {
|
|
source: 'task'
|
|
item: Extract<TaskItem, { provider: 'github' }> | Extract<TaskItem, { provider: 'gitlab' }>
|
|
nextState: 'open' | 'opened' | 'closed'
|
|
}
|
|
| {
|
|
source: 'project'
|
|
row: GitHubProjectRow
|
|
nextState: 'open' | 'closed'
|
|
}
|
|
|
|
type SetupPrompt = {
|
|
item: ActionableTaskItem
|
|
repoIdOverride?: string
|
|
agentOverride?: WorkspaceAgentChoice
|
|
workspaceNameOverride?: string
|
|
noteOverride?: string
|
|
baseBranchOverride?: string
|
|
branchNameOverride?: string
|
|
sparseCheckoutOverride?: { directories: string[]; presetId?: string }
|
|
repoName: string
|
|
command: string
|
|
source: string | null
|
|
}
|
|
|
|
type WorkspaceCreateArgs = {
|
|
item: ActionableTaskItem
|
|
repoIdOverride?: string
|
|
setupOverride?: Exclude<SetupDecision, 'inherit'>
|
|
agentOverride?: WorkspaceAgentChoice
|
|
workspaceNameOverride?: string
|
|
noteOverride?: string
|
|
baseBranchOverride?: string
|
|
branchNameOverride?: string
|
|
sparseCheckoutOverride?: { directories: string[]; presetId?: string }
|
|
}
|
|
|
|
type OrcaYamlTrustPrompt = WorkspaceCreateArgs & {
|
|
repoId: string
|
|
repoName: string
|
|
scriptContent: string
|
|
contentHash: string
|
|
previouslyApproved: boolean
|
|
}
|
|
|
|
type WorkspaceCreateDraft = {
|
|
item: ActionableTaskItem
|
|
repoIdOverride?: string
|
|
}
|
|
|
|
type WorkspaceSparseDraft = {
|
|
mode: 'new' | 'edit'
|
|
presetId?: string
|
|
name: string
|
|
directoriesText: string
|
|
}
|
|
|
|
function sortSparsePresetsByName(presets: SparsePreset[]): SparsePreset[] {
|
|
return [...presets].sort((left, right) => left.name.localeCompare(right.name))
|
|
}
|
|
|
|
function workspaceAgentIconId(agent: WorkspaceAgentChoice): string {
|
|
return agent === 'blank' ? '__blank__' : agent
|
|
}
|
|
|
|
type ProjectRepoNotInOrcaPrompt = {
|
|
owner: string
|
|
repo: string
|
|
url: string | null
|
|
}
|
|
|
|
type TaskListEntry =
|
|
| { type: 'section'; key: string; label: string; color: string }
|
|
| { type: 'item'; key: string; item: TaskItem }
|
|
|
|
const PROVIDER_OPTIONS: PickerOption<TaskProvider>[] = [
|
|
{
|
|
value: 'github',
|
|
label: 'GitHub',
|
|
subtitle: 'Issues and pull requests',
|
|
renderIcon: (selected) => (
|
|
<TaskProviderLogo
|
|
provider="github"
|
|
size={16}
|
|
color={selected ? colors.textPrimary : colors.textSecondary}
|
|
/>
|
|
)
|
|
},
|
|
{
|
|
value: 'gitlab',
|
|
label: 'GitLab',
|
|
subtitle: 'Issues and merge requests',
|
|
renderIcon: (selected) => (
|
|
<TaskProviderLogo
|
|
provider="gitlab"
|
|
size={16}
|
|
color={selected ? colors.textPrimary : colors.textSecondary}
|
|
/>
|
|
)
|
|
},
|
|
{
|
|
value: 'linear',
|
|
label: 'Linear',
|
|
subtitle: 'Assigned and team issues',
|
|
renderIcon: (selected) => (
|
|
<TaskProviderLogo
|
|
provider="linear"
|
|
size={16}
|
|
color={selected ? colors.textPrimary : colors.textSecondary}
|
|
/>
|
|
)
|
|
}
|
|
]
|
|
|
|
const GITLAB_FILTER_OPTIONS: PickerOption<GitLabFilter>[] = [
|
|
{ value: 'opened', label: 'Open', subtitle: 'Open issues and merge requests' },
|
|
{ value: 'merged', label: 'Merged', subtitle: 'Merged merge requests' },
|
|
{ value: 'closed', label: 'Closed', subtitle: 'Closed issues and merge requests' },
|
|
{ value: 'all', label: 'All', subtitle: 'Any GitLab state' }
|
|
]
|
|
|
|
const LINEAR_FILTER_OPTIONS: PickerOption<LinearFilter>[] = [
|
|
{ value: 'all', label: 'All', subtitle: 'Open issues across connected workspaces' },
|
|
{ value: 'assigned', label: 'My Issues', subtitle: 'Issues assigned to you' },
|
|
{ value: 'created', label: 'Created', subtitle: 'Issues created by you' },
|
|
{ value: 'completed', label: 'Completed', subtitle: 'Recently completed issues' }
|
|
]
|
|
|
|
const LINEAR_VIEW_OPTIONS: PickerOption<LinearViewMode>[] = [
|
|
{ value: 'list', label: 'List', subtitle: 'Compact issue rows' },
|
|
{ value: 'board', label: 'Board', subtitle: 'Grouped columns' }
|
|
]
|
|
|
|
function taskWorkspaceFallback(item: ActionableTaskItem): string {
|
|
if (item.provider === 'github' || item.provider === 'gitlab') {
|
|
return `${item.source.type}-${item.source.number}`
|
|
}
|
|
return item.source.identifier.toLowerCase()
|
|
}
|
|
|
|
function taskWorkspaceSuggestedName(item: ActionableTaskItem): string {
|
|
return getLinkedWorkItemSuggestedName(item) || taskWorkspaceFallback(item)
|
|
}
|
|
|
|
const COMMENT_REACTION_EMOJI: Record<
|
|
NonNullable<DetailComment['reactions']>[number]['content'],
|
|
string
|
|
> = {
|
|
thumbs_up: '+1',
|
|
thumbs_down: '-1',
|
|
laugh: 'laugh',
|
|
confused: 'confused',
|
|
heart: 'heart',
|
|
hooray: 'hooray',
|
|
rocket: 'rocket',
|
|
eyes: 'eyes'
|
|
}
|
|
|
|
const LINEAR_GROUP_OPTIONS: PickerOption<LinearGroupBy>[] = [
|
|
{ value: 'none', label: 'No grouping' },
|
|
{ value: 'status', label: 'Status' },
|
|
{ value: 'assignee', label: 'Assignee' },
|
|
{ value: 'priority', label: 'Priority' },
|
|
{ value: 'team', label: 'Team' }
|
|
]
|
|
|
|
const LINEAR_ORDER_OPTIONS: PickerOption<LinearOrderBy>[] = [
|
|
{ value: 'priority', label: 'Priority' },
|
|
{ value: 'updated', label: 'Updated' },
|
|
{ value: 'identifier', label: 'Identifier' }
|
|
]
|
|
|
|
const LINEAR_DISPLAY_OPTIONS: PickerOption<LinearDisplayProperty>[] = [
|
|
{ value: 'state', label: 'Status' },
|
|
{ value: 'priority', label: 'Priority' },
|
|
{ value: 'assignee', label: 'Assignee' },
|
|
{ value: 'team', label: 'Team' },
|
|
{ value: 'labels', label: 'Labels' },
|
|
{ value: 'updated', label: 'Updated' }
|
|
]
|
|
|
|
const DEFAULT_LINEAR_DISPLAY_PROPERTIES: LinearDisplayProperty[] = [
|
|
'state',
|
|
'priority',
|
|
'assignee',
|
|
'team',
|
|
'labels',
|
|
'updated'
|
|
]
|
|
|
|
const GITHUB_KIND_OPTIONS: PickerOption<GitHubMode>[] = [
|
|
{ value: 'issues', label: 'Issues', subtitle: 'GitHub issues' },
|
|
{ value: 'prs', label: 'PRs', subtitle: 'GitHub pull requests' },
|
|
{ value: 'project', label: 'Projects', subtitle: 'GitHub Projects views' }
|
|
]
|
|
|
|
const ISSUE_PRESETS: PickerOption<GitHubPreset>[] = [
|
|
{ value: 'issues', label: 'Open', subtitle: 'Open GitHub issues' },
|
|
{ value: 'my-issues', label: 'Assigned to me', subtitle: 'Open issues assigned to you' }
|
|
]
|
|
|
|
const PR_PRESETS: PickerOption<GitHubPreset>[] = [
|
|
{ value: 'prs', label: 'Open', subtitle: 'Open pull requests' },
|
|
{ value: 'my-prs', label: 'Mine', subtitle: 'Pull requests authored by you' },
|
|
{ value: 'review', label: 'Needs review', subtitle: 'Review requests assigned to you' }
|
|
]
|
|
|
|
const GITLAB_VIEW_OPTIONS: PickerOption<GitLabView>[] = [
|
|
{ value: 'project', label: 'Project MRs', subtitle: 'Merge requests and issues by repository' },
|
|
{ value: 'todos', label: 'My Todos', subtitle: 'Pending GitLab todos' }
|
|
]
|
|
|
|
const SORT_OPTIONS: PickerOption<TaskSort>[] = [
|
|
{ value: 'updated', label: 'Updated', subtitle: 'Newest activity first' },
|
|
{
|
|
value: 'repository',
|
|
label: 'Repository',
|
|
subtitle: 'Group by repository, then newest activity'
|
|
}
|
|
]
|
|
|
|
type ProjectSortOverride = { fieldId: string; direction: GitHubProjectSortDirection }
|
|
type ProjectListEntry =
|
|
| { type: 'group'; group: ProjectGroup; collapsed: boolean }
|
|
| { type: 'row'; row: GitHubProjectRow }
|
|
type LinearIssueSection = { key: string; label: string; color: string; issues: LinearIssue[] }
|
|
type LinearListEntry =
|
|
| { type: 'section'; section: LinearIssueSection }
|
|
| { type: 'issue'; issue: LinearIssue }
|
|
|
|
const PROJECT_VIEW_DEFAULT_SORT = '__view_default__'
|
|
const GITHUB_REPO_CONCURRENCY = 3
|
|
const MAX_RENDERED_PR_DIFF_LINES = 400
|
|
const GITLAB_PER_PAGE = 50
|
|
const LINEAR_LIMIT = 50
|
|
// Why: task detail drawers can launch child sheets; children must layer above
|
|
// the still-mounted parent while its dismissal animation/state remains alive.
|
|
const TASK_SECONDARY_DRAWER_Z_INDEX = 1100
|
|
// Why: the mobile detail drawer should support quick triage and core actions.
|
|
// Desktop keeps the broad metadata editing surface for dense issue/PR work.
|
|
const SHOW_MOBILE_DETAIL_LABEL_CHIPS = false
|
|
const SHOW_MOBILE_DETAIL_METADATA_EDITORS = false
|
|
const SHOW_MOBILE_DETAIL_REVIEW_PANELS = false
|
|
const SHOW_MOBILE_LINEAR_DETAIL_TOOLS = false
|
|
const SHOW_MOBILE_COMMENT_THREAD_TOOLS = false
|
|
const SHOW_MOBILE_PROJECT_METADATA_EDITORS = false
|
|
const SHOW_MOBILE_PROJECT_REVIEW_PANELS = false
|
|
const EMPTY_GITHUB_PROJECT_SETTINGS: GitHubProjectSettings = {
|
|
pinned: [],
|
|
recent: [],
|
|
lastViewByProject: {},
|
|
activeProject: null
|
|
}
|
|
|
|
function isSuccess(response: unknown): response is RpcSuccess {
|
|
return Boolean(response && typeof response === 'object' && (response as RpcSuccess).ok)
|
|
}
|
|
|
|
function taskTime(value: string): number {
|
|
const time = Date.parse(value)
|
|
return Number.isFinite(time) ? time : 0
|
|
}
|
|
|
|
function formatUpdatedAt(value: string): string {
|
|
const time = taskTime(value)
|
|
if (!time) {
|
|
return ''
|
|
}
|
|
const minutes = Math.max(0, Math.floor((Date.now() - time) / 60_000))
|
|
if (minutes < 60) {
|
|
return `${minutes}m`
|
|
}
|
|
const hours = Math.floor(minutes / 60)
|
|
if (hours < 24) {
|
|
return `${hours}h`
|
|
}
|
|
return `${Math.floor(hours / 24)}d`
|
|
}
|
|
|
|
function getTaskPresetQuery(preset: GitHubPreset): string {
|
|
switch (preset) {
|
|
case 'my-issues':
|
|
return 'assignee:@me is:issue is:open'
|
|
case 'prs':
|
|
return 'is:pr is:open'
|
|
case 'my-prs':
|
|
return 'author:@me is:pr is:open'
|
|
case 'review':
|
|
return 'review-requested:@me is:pr is:open'
|
|
case 'issues':
|
|
default:
|
|
return 'is:issue is:open'
|
|
}
|
|
}
|
|
|
|
function isTaskProvider(value: unknown): value is TaskProvider {
|
|
return value === 'github' || value === 'gitlab' || value === 'linear'
|
|
}
|
|
|
|
function normalizeGitHubPreset(value: unknown): GitHubPreset {
|
|
return value === 'my-issues' ||
|
|
value === 'prs' ||
|
|
value === 'my-prs' ||
|
|
value === 'review' ||
|
|
value === 'issues'
|
|
? value
|
|
: 'issues'
|
|
}
|
|
|
|
function normalizeLinearFilter(value: unknown): LinearFilter {
|
|
return value === 'assigned' || value === 'created' || value === 'completed' || value === 'all'
|
|
? value
|
|
: 'all'
|
|
}
|
|
|
|
function githubKindFromQuery(query: string, fallbackPreset: GitHubPreset): GitHubTaskKind {
|
|
if (/\bis:pr\b/i.test(query)) {
|
|
return 'prs'
|
|
}
|
|
if (/\bis:issue\b/i.test(query)) {
|
|
return 'issues'
|
|
}
|
|
return fallbackPreset === 'prs' || fallbackPreset === 'my-prs' || fallbackPreset === 'review'
|
|
? 'prs'
|
|
: 'issues'
|
|
}
|
|
|
|
function projectRowType(row: GitHubProjectRow): 'issue' | 'pr' | null {
|
|
if (row.itemType === 'ISSUE') {
|
|
return 'issue'
|
|
}
|
|
if (row.itemType === 'PULL_REQUEST') {
|
|
return 'pr'
|
|
}
|
|
return null
|
|
}
|
|
|
|
function canCreateWorkspaceFromProjectRow(row: GitHubProjectRow): boolean {
|
|
// Why: desktop only exposes Project "Start work" for backed issue/PR rows
|
|
// with enough GitHub identity to build the linked work item.
|
|
return projectRowType(row) !== null && row.content.number != null && Boolean(row.content.url)
|
|
}
|
|
|
|
function splitRepositorySlug(slug: string | null): { owner: string; repo: string } | null {
|
|
const [owner, repo] = slug?.split('/') ?? []
|
|
return owner && repo ? { owner, repo } : null
|
|
}
|
|
|
|
function projectRowGitHubRepository(row: GitHubProjectRow, host: string): GitHubOwnerRepo | null {
|
|
const slug = splitRepositorySlug(row.content.repository)
|
|
return slug ? { ...slug, host } : null
|
|
}
|
|
|
|
const GITHUB_PROJECT_OPTION_COLORS: Record<string, string> = {
|
|
GRAY: '#8b949e',
|
|
RED: '#f85149',
|
|
ORANGE: '#db6d28',
|
|
YELLOW: '#d29922',
|
|
GREEN: '#3fb950',
|
|
BLUE: '#58a6ff',
|
|
PURPLE: '#bc8cff',
|
|
PINK: '#db61a2'
|
|
}
|
|
|
|
function githubProjectOptionColor(color: string | null | undefined): string {
|
|
if (!color) {
|
|
return colors.textMuted
|
|
}
|
|
const upper = color.toUpperCase()
|
|
const mapped = GITHUB_PROJECT_OPTION_COLORS[upper]
|
|
if (mapped) {
|
|
return mapped
|
|
}
|
|
const hex = color.startsWith('#') ? color : `#${color}`
|
|
return /^#[0-9a-fA-F]{6}$/.test(hex) ? hex : colors.textMuted
|
|
}
|
|
|
|
function projectRowStatusLabel(row: GitHubProjectRow): string {
|
|
if (row.itemType === 'DRAFT_ISSUE') {
|
|
return 'Draft'
|
|
}
|
|
if (row.itemType === 'REDACTED') {
|
|
return 'Redacted'
|
|
}
|
|
if (row.content.isDraft) {
|
|
return 'Draft'
|
|
}
|
|
if (row.content.state === 'MERGED') {
|
|
return 'Merged'
|
|
}
|
|
if (row.content.state === 'CLOSED') {
|
|
return 'Closed'
|
|
}
|
|
return 'Open'
|
|
}
|
|
|
|
function scopeGitHubTaskSearch(query: string, kind: GitHubTaskKind): string {
|
|
const trimmed = query.trim()
|
|
if (!trimmed) {
|
|
return getTaskPresetQuery(kind === 'prs' ? 'prs' : 'issues')
|
|
}
|
|
if (/\bis:(?:issue|pr)\b/i.test(trimmed)) {
|
|
return trimmed
|
|
}
|
|
return `${kind === 'prs' ? 'is:pr' : 'is:issue'} ${trimmed}`
|
|
}
|
|
|
|
function gitHubStatusLabel(item: GitHubWorkItem): string {
|
|
if (item.state === 'merged') {
|
|
return 'Merged'
|
|
}
|
|
if (item.state === 'draft') {
|
|
return 'Draft'
|
|
}
|
|
return item.state === 'closed' ? 'Closed' : 'Open'
|
|
}
|
|
|
|
function gitHubTaskSubtitle(item: GitHubWorkItem): string {
|
|
return `${item.repoName} ${item.type === 'pr' ? '#' : '#'}${item.number}`
|
|
}
|
|
|
|
function createGitHubTask(repo: RepoSummary, item: Omit<GitHubWorkItem, 'repoId' | 'repoName'>) {
|
|
const source: GitHubWorkItem = { ...item, repoId: repo.id, repoName: repo.displayName }
|
|
return {
|
|
key: `github:${repo.id}:${item.type}:${item.number}`,
|
|
provider: 'github' as const,
|
|
title: item.title,
|
|
subtitle: gitHubTaskSubtitle(source),
|
|
status: gitHubStatusLabel(source),
|
|
updatedAt: item.updatedAt,
|
|
source
|
|
}
|
|
}
|
|
|
|
function gitLabStatusLabel(item: GitLabWorkItem): string {
|
|
if (item.state === 'opened') {
|
|
return 'Open'
|
|
}
|
|
if (item.state === 'merged') {
|
|
return 'Merged'
|
|
}
|
|
if (item.state === 'draft') {
|
|
return 'Draft'
|
|
}
|
|
return item.state === 'closed' ? 'Closed' : 'Locked'
|
|
}
|
|
|
|
function createGitLabTask(repo: RepoSummary, item: Omit<GitLabWorkItem, 'repoId' | 'repoName'>) {
|
|
const source: GitLabWorkItem = { ...item, repoId: repo.id, repoName: repo.displayName }
|
|
return {
|
|
key: `gitlab:${repo.id}:${item.type}:${item.number}`,
|
|
provider: 'gitlab' as const,
|
|
title: item.title,
|
|
subtitle: `${repo.displayName} ${item.type === 'mr' ? '!' : '#'}${item.number}`,
|
|
status: gitLabStatusLabel(source),
|
|
updatedAt: item.updatedAt,
|
|
source
|
|
}
|
|
}
|
|
|
|
function gitLabTodoTargetLabel(todo: Pick<GitLabTodo, 'targetType'>): string {
|
|
if (todo.targetType === 'MergeRequest') {
|
|
return 'Merge request'
|
|
}
|
|
if (todo.targetType === 'Issue') {
|
|
return 'Issue'
|
|
}
|
|
return 'GitLab todo'
|
|
}
|
|
|
|
function gitLabTodoTargetRef(todo: Pick<GitLabTodo, 'targetType' | 'targetIid'>): string {
|
|
if (!todo.targetIid) {
|
|
return ''
|
|
}
|
|
if (todo.targetType === 'MergeRequest') {
|
|
return `!${todo.targetIid}`
|
|
}
|
|
if (todo.targetType === 'Issue') {
|
|
return `#${todo.targetIid}`
|
|
}
|
|
return String(todo.targetIid)
|
|
}
|
|
|
|
function createGitLabTodoTask(todo: GitLabTodo): TaskItem {
|
|
const targetRef = gitLabTodoTargetRef(todo)
|
|
return {
|
|
key: `gitlab-todo:${todo.id}`,
|
|
provider: 'gitlabTodo',
|
|
title: todo.targetTitle || todo.targetUrl,
|
|
subtitle: `${todo.projectPath}${targetRef ? ` ${targetRef}` : ''}`,
|
|
status: todo.actionName.replace(/_/g, ' ') || 'Todo',
|
|
updatedAt: todo.updatedAt,
|
|
source: todo
|
|
}
|
|
}
|
|
|
|
async function mapWithConcurrency<T, R>(
|
|
items: T[],
|
|
limit: number,
|
|
worker: (item: T) => Promise<R>
|
|
): Promise<R[]> {
|
|
const results: R[] = []
|
|
let nextIndex = 0
|
|
async function run(): Promise<void> {
|
|
while (nextIndex < items.length) {
|
|
const index = nextIndex
|
|
nextIndex += 1
|
|
results[index] = await worker(items[index]!)
|
|
}
|
|
}
|
|
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, () => run()))
|
|
return results
|
|
}
|
|
|
|
function isHostedTaskRepo(repo: RepoSummary): boolean {
|
|
return repo.kind !== 'folder'
|
|
}
|
|
|
|
function reconcileRepoSelection(
|
|
repos: RepoSummary[],
|
|
persisted: string[] | null | undefined
|
|
): Set<string> {
|
|
if (!persisted || persisted.length === 0) {
|
|
return new Set()
|
|
}
|
|
const availableIds = new Set(repos.filter(isHostedTaskRepo).map((repo) => repo.id))
|
|
const selected = persisted.filter((id) => availableIds.has(id))
|
|
return selected.length === 0 ? new Set() : new Set(selected)
|
|
}
|
|
|
|
function createLinearTask(issue: LinearIssue): TaskItem {
|
|
return {
|
|
key: `linear:${issue.workspaceId ?? 'workspace'}:${issue.id}`,
|
|
provider: 'linear',
|
|
title: issue.title,
|
|
subtitle: `${issue.identifier} · ${issue.team.name}`,
|
|
status: issue.state.name,
|
|
updatedAt: issue.updatedAt,
|
|
source: issue
|
|
}
|
|
}
|
|
|
|
const LINEAR_PRIORITY_LABELS: Record<number, string> = {
|
|
0: 'None',
|
|
1: 'Urgent',
|
|
2: 'High',
|
|
3: 'Medium',
|
|
4: 'Low'
|
|
}
|
|
|
|
function getLinearPriorityLabel(priority: number): string {
|
|
return LINEAR_PRIORITY_LABELS[priority] ?? `P${priority}`
|
|
}
|
|
|
|
function getLinearPriorityRank(priority: number): number {
|
|
return priority === 0 ? 5 : priority
|
|
}
|
|
|
|
function formatGitHubReviewState(state: string | null | undefined): string {
|
|
switch (state) {
|
|
case 'APPROVED':
|
|
return 'Approved'
|
|
case 'CHANGES_REQUESTED':
|
|
return 'Changes requested'
|
|
case 'COMMENTED':
|
|
return 'Commented'
|
|
case 'DISMISSED':
|
|
return 'Dismissed'
|
|
case 'PENDING':
|
|
return 'Pending'
|
|
default:
|
|
return 'Reviewed'
|
|
}
|
|
}
|
|
|
|
function getGitHubReviewerRows(item: {
|
|
reviewRequests?: GitHubAssignableUser[]
|
|
latestReviews?: GitHubPRReviewSummary[]
|
|
}): GitHubPRReviewerRow[] {
|
|
const byLogin = new Map<string, GitHubPRReviewerRow>()
|
|
for (const user of item.reviewRequests ?? []) {
|
|
const login = user.login.trim()
|
|
if (!login) {
|
|
continue
|
|
}
|
|
byLogin.set(login.toLowerCase(), {
|
|
login,
|
|
name: user.name,
|
|
avatarUrl: user.avatarUrl,
|
|
stateLabel: 'Requested'
|
|
})
|
|
}
|
|
for (const review of item.latestReviews ?? []) {
|
|
const login = review.login.trim()
|
|
const key = login.toLowerCase()
|
|
if (!login || byLogin.has(key)) {
|
|
continue
|
|
}
|
|
byLogin.set(key, {
|
|
login,
|
|
name: null,
|
|
avatarUrl: review.avatarUrl,
|
|
stateLabel: formatGitHubReviewState(review.state)
|
|
})
|
|
}
|
|
return Array.from(byLogin.values())
|
|
}
|
|
|
|
function getGitHubReviewSummary(item: {
|
|
reviewDecision?: string | null
|
|
reviewRequests?: GitHubAssignableUser[]
|
|
latestReviews?: GitHubPRReviewSummary[]
|
|
}): string {
|
|
if (item.reviewDecision === 'APPROVED') {
|
|
return 'Approved'
|
|
}
|
|
if (item.reviewDecision === 'CHANGES_REQUESTED') {
|
|
return 'Changes requested'
|
|
}
|
|
const rows = getGitHubReviewerRows(item)
|
|
if (rows.length === 0) {
|
|
return 'No reviewers'
|
|
}
|
|
if (rows.length === 1) {
|
|
return `${rows[0]!.login} - ${rows[0]!.stateLabel}`
|
|
}
|
|
return `${rows[0]!.login} +${rows.length - 1}`
|
|
}
|
|
|
|
function formatGitHubPRDelta(item: GitHubWorkItem): string | null {
|
|
const parts: string[] = []
|
|
if (typeof item.additions === 'number') {
|
|
parts.push(`+${item.additions}`)
|
|
}
|
|
if (typeof item.deletions === 'number') {
|
|
parts.push(`-${item.deletions}`)
|
|
}
|
|
if (typeof item.changedFiles === 'number') {
|
|
parts.push(`${item.changedFiles} ${item.changedFiles === 1 ? 'file' : 'files'}`)
|
|
}
|
|
return parts.length > 0 ? parts.join(' ') : null
|
|
}
|
|
|
|
function hostedBranchSummary(item: TaskItem): { head: string; base: string } | null {
|
|
if (item.provider === 'github' && item.source.type === 'pr') {
|
|
return {
|
|
head: item.source.branchName?.trim() || 'unknown head',
|
|
base: item.source.baseRefName?.trim() || 'base'
|
|
}
|
|
}
|
|
if (item.provider === 'gitlab' && item.source.type === 'mr') {
|
|
return {
|
|
head: item.source.branchName?.trim() || 'unknown head',
|
|
base: item.source.baseRefName?.trim() || 'base'
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
|
|
function getGitHubMergeLabel(item: GitHubWorkItem): string {
|
|
if (item.mergeable === undefined && item.mergeStateStatus === undefined) {
|
|
return 'Merge'
|
|
}
|
|
if (item.state === 'merged') {
|
|
return 'Merged'
|
|
}
|
|
if (item.state === 'closed') {
|
|
return 'Closed'
|
|
}
|
|
if (item.mergeable === 'CONFLICTING') {
|
|
return 'Conflicts'
|
|
}
|
|
if (item.mergeStateStatus === 'BEHIND') {
|
|
return 'Behind'
|
|
}
|
|
if (item.mergeStateStatus === 'BLOCKED') {
|
|
return 'Blocked'
|
|
}
|
|
if (item.mergeable === 'MERGEABLE' || item.mergeStateStatus === 'CLEAN') {
|
|
return 'Able to merge'
|
|
}
|
|
return 'Unknown'
|
|
}
|
|
|
|
function getHostedReviewMergeMethodLabel(method: HostedReviewMergeMethod): string {
|
|
if (method === 'squash') {
|
|
return 'Squash and merge'
|
|
}
|
|
if (method === 'rebase') {
|
|
return 'Rebase and merge'
|
|
}
|
|
return 'Create merge commit'
|
|
}
|
|
|
|
function hostedReviewMergeTargetLabel(item: HostedReviewItem): string {
|
|
return item.provider === 'gitlab' ? 'merge request' : 'PR'
|
|
}
|
|
|
|
function getHostedMergeConfirmMessage(pending: PendingHostedMerge): string {
|
|
const target = hostedReviewMergeTargetLabel(pending.item)
|
|
if (pending.method === 'squash') {
|
|
return `Squash and merge ${target} #${pending.item.source.number}?`
|
|
}
|
|
const action = pending.method === 'rebase' ? 'Rebase and merge' : 'Merge'
|
|
return `${action} ${target} #${pending.item.source.number}?`
|
|
}
|
|
|
|
function getProjectGitHubMergeConfirmMessage(pending: PendingProjectGitHubMerge): string {
|
|
const number = pending.row.content.number
|
|
if (pending.method === 'squash') {
|
|
return `Squash and merge PR #${number}?`
|
|
}
|
|
const action = pending.method === 'rebase' ? 'Rebase and merge' : 'Merge'
|
|
return `${action} PR #${number}?`
|
|
}
|
|
|
|
function hostedStateChangeAction(nextState: PendingHostedStateChange['nextState']): string {
|
|
return nextState === 'closed' ? 'Close' : 'Reopen'
|
|
}
|
|
|
|
function hostedStateChangeTarget(pending: PendingHostedStateChange): {
|
|
titleTarget: string
|
|
labelTarget: string
|
|
number: number | null
|
|
} {
|
|
if (pending.source === 'project') {
|
|
const type = projectRowType(pending.row)
|
|
return {
|
|
titleTarget: type === 'pr' ? 'Pull Request' : 'Issue',
|
|
labelTarget: type === 'pr' ? 'PR' : 'Issue',
|
|
number: pending.row.content.number
|
|
}
|
|
}
|
|
if (pending.item.provider === 'gitlab') {
|
|
return {
|
|
titleTarget: pending.item.source.type === 'mr' ? 'Merge Request' : 'Issue',
|
|
labelTarget: pending.item.source.type === 'mr' ? 'MR' : 'Issue',
|
|
number: pending.item.source.number
|
|
}
|
|
}
|
|
return {
|
|
titleTarget: pending.item.source.type === 'pr' ? 'Pull Request' : 'Issue',
|
|
labelTarget: pending.item.source.type === 'pr' ? 'PR' : 'Issue',
|
|
number: pending.item.source.number
|
|
}
|
|
}
|
|
|
|
function getHostedStateConfirmTitle(pending: PendingHostedStateChange): string {
|
|
const target = hostedStateChangeTarget(pending)
|
|
return `${hostedStateChangeAction(pending.nextState)} ${target.titleTarget}`
|
|
}
|
|
|
|
function getHostedStateConfirmMessage(pending: PendingHostedStateChange): string {
|
|
const target = hostedStateChangeTarget(pending)
|
|
return `${hostedStateChangeAction(pending.nextState)} ${target.labelTarget} #${target.number}?`
|
|
}
|
|
|
|
function getHostedStateConfirmLabel(pending: PendingHostedStateChange): string {
|
|
const target = hostedStateChangeTarget(pending)
|
|
return `${hostedStateChangeAction(pending.nextState)} ${target.labelTarget}`
|
|
}
|
|
|
|
function mergeGitHubAssignableUsers(
|
|
users: GitHubAssignableUser[],
|
|
seeds: GitHubAssignableUser[]
|
|
): GitHubAssignableUser[] {
|
|
const byLogin = new Map<string, GitHubAssignableUser>()
|
|
for (const user of [...users, ...seeds]) {
|
|
const login = user.login.trim()
|
|
if (!login || byLogin.has(login.toLowerCase())) {
|
|
continue
|
|
}
|
|
byLogin.set(login.toLowerCase(), { ...user, login })
|
|
}
|
|
return [...byLogin.values()]
|
|
}
|
|
|
|
function getGitHubReviewerSeedUsers(item: {
|
|
reviewRequests?: GitHubAssignableUser[]
|
|
latestReviews?: GitHubPRReviewSummary[]
|
|
author?: string | null
|
|
}): GitHubAssignableUser[] {
|
|
const byLogin = new Map<string, GitHubAssignableUser>()
|
|
const add = (user: GitHubAssignableUser): void => {
|
|
const login = user.login.trim()
|
|
if (!login || byLogin.has(login.toLowerCase())) {
|
|
return
|
|
}
|
|
byLogin.set(login.toLowerCase(), { ...user, login })
|
|
}
|
|
for (const user of item.reviewRequests ?? []) {
|
|
add(user)
|
|
}
|
|
for (const review of item.latestReviews ?? []) {
|
|
add({
|
|
login: review.login,
|
|
name: null,
|
|
avatarUrl: review.avatarUrl ?? null
|
|
})
|
|
}
|
|
if (item.author) {
|
|
add({ login: item.author, name: null, avatarUrl: null })
|
|
}
|
|
return [...byLogin.values()]
|
|
}
|
|
|
|
function sameGitHubOwnerRepo(
|
|
a: GitHubOwnerRepo | null | undefined,
|
|
b: GitHubOwnerRepo | null | undefined
|
|
): boolean {
|
|
return (
|
|
!!a &&
|
|
!!b &&
|
|
a.owner.toLowerCase() === b.owner.toLowerCase() &&
|
|
a.repo.toLowerCase() === b.repo.toLowerCase()
|
|
)
|
|
}
|
|
|
|
function hasGitHubIssueSourceChoice(sources: GitHubRepoSources | undefined): boolean {
|
|
return Boolean(
|
|
sources?.prs &&
|
|
sources.upstreamCandidate &&
|
|
!sameGitHubOwnerRepo(sources.prs, sources.upstreamCandidate)
|
|
)
|
|
}
|
|
|
|
function issueSourceSlug(source: GitHubOwnerRepo | null | undefined): string {
|
|
return source ? `${source.owner}/${source.repo}` : 'Unknown'
|
|
}
|
|
|
|
function compareLinearIssues(a: LinearIssue, b: LinearIssue, orderBy: LinearOrderBy): number {
|
|
if (orderBy === 'updated') {
|
|
return taskTime(b.updatedAt) - taskTime(a.updatedAt)
|
|
}
|
|
if (orderBy === 'identifier') {
|
|
return a.identifier.localeCompare(b.identifier, undefined, { numeric: true })
|
|
}
|
|
const priorityDelta = getLinearPriorityRank(a.priority) - getLinearPriorityRank(b.priority)
|
|
return priorityDelta || taskTime(b.updatedAt) - taskTime(a.updatedAt)
|
|
}
|
|
|
|
function getLinearIssueGroup(
|
|
issue: LinearIssue,
|
|
groupBy: LinearGroupBy
|
|
): {
|
|
key: string
|
|
label: string
|
|
color: string
|
|
} {
|
|
if (groupBy === 'status') {
|
|
return { key: `status:${issue.state.name}`, label: issue.state.name, color: issue.state.color }
|
|
}
|
|
if (groupBy === 'assignee') {
|
|
return {
|
|
key: `assignee:${issue.assignee?.id ?? issue.assignee?.displayName ?? 'unassigned'}`,
|
|
label: issue.assignee?.displayName ?? 'Unassigned',
|
|
color: colors.accentBlue
|
|
}
|
|
}
|
|
if (groupBy === 'priority') {
|
|
return {
|
|
key: `priority:${issue.priority}`,
|
|
label: getLinearPriorityLabel(issue.priority),
|
|
color: issue.priority === 1 ? colors.statusRed : colors.accentBlue
|
|
}
|
|
}
|
|
if (groupBy === 'team') {
|
|
return { key: `team:${issue.team.id}`, label: issue.team.name, color: issue.state.color }
|
|
}
|
|
return { key: 'all', label: 'Issues', color: colors.accentBlue }
|
|
}
|
|
|
|
function groupLinearIssues(
|
|
issues: LinearIssue[],
|
|
groupBy: LinearGroupBy,
|
|
orderBy: LinearOrderBy
|
|
): LinearIssueSection[] {
|
|
const sorted = [...issues].sort((a, b) => compareLinearIssues(a, b, orderBy))
|
|
if (groupBy === 'none') {
|
|
return [{ key: 'all', label: 'Issues', color: colors.accentBlue, issues: sorted }]
|
|
}
|
|
const sections = new Map<
|
|
string,
|
|
{ key: string; label: string; color: string; issues: LinearIssue[] }
|
|
>()
|
|
for (const issue of sorted) {
|
|
const group = getLinearIssueGroup(issue, groupBy)
|
|
const section = sections.get(group.key)
|
|
if (section) {
|
|
section.issues.push(issue)
|
|
} else {
|
|
sections.set(group.key, { ...group, issues: [issue] })
|
|
}
|
|
}
|
|
return [...sections.values()]
|
|
}
|
|
|
|
function linearIssueSecondaryParts(
|
|
issue: LinearIssue,
|
|
displayProperties: ReadonlySet<LinearDisplayProperty>
|
|
): string[] {
|
|
const parts = [issue.identifier]
|
|
if (displayProperties.has('priority')) {
|
|
parts.push(getLinearPriorityLabel(issue.priority))
|
|
}
|
|
if (displayProperties.has('assignee') && issue.assignee?.displayName) {
|
|
parts.push(issue.assignee.displayName)
|
|
}
|
|
if (displayProperties.has('team')) {
|
|
parts.push(issue.team.name)
|
|
}
|
|
if (displayProperties.has('labels') && issue.labels.length > 0) {
|
|
parts.push(issue.labels.slice(0, 2).join(', '))
|
|
}
|
|
if (displayProperties.has('updated')) {
|
|
parts.push(formatUpdatedAt(issue.updatedAt))
|
|
}
|
|
return parts
|
|
}
|
|
|
|
function reconcileTeamSelection(
|
|
teams: LinearTeam[],
|
|
saved: string[] | null | undefined
|
|
): Set<string> {
|
|
if (!saved) {
|
|
return new Set(teams.map((team) => team.id))
|
|
}
|
|
const available = new Set(teams.map((team) => team.id))
|
|
const next = new Set(saved.filter((id) => available.has(id)))
|
|
return next.size === 0 ? new Set(teams.map((team) => team.id)) : next
|
|
}
|
|
|
|
function splitCommaList(value: string): string[] {
|
|
return value
|
|
.split(',')
|
|
.map((entry) => entry.trim())
|
|
.filter(Boolean)
|
|
}
|
|
|
|
function splitReviewerList(value: string): string[] {
|
|
return value
|
|
.split(/[\s,]+/)
|
|
.map((entry) => entry.trim())
|
|
.filter(Boolean)
|
|
}
|
|
|
|
function editableProjectFields(table: GitHubProjectTable | null): GitHubProjectField[] {
|
|
return (
|
|
table?.selectedView.fields?.filter((field) =>
|
|
['TEXT', 'NUMBER', 'DATE', 'SINGLE_SELECT', 'ITERATION'].includes(field.dataType)
|
|
) ?? []
|
|
)
|
|
}
|
|
|
|
function projectFieldValueLabel(row: GitHubProjectRow, field: GitHubProjectField): string {
|
|
const value = row.fieldValuesByFieldId?.[field.id]
|
|
if (!value) {
|
|
return 'Empty'
|
|
}
|
|
if (value.kind === 'single-select') {
|
|
return value.name
|
|
}
|
|
if (value.kind === 'iteration') {
|
|
return value.title
|
|
}
|
|
if (value.kind === 'text') {
|
|
return value.text || 'Empty'
|
|
}
|
|
if (value.kind === 'number') {
|
|
return String(value.number)
|
|
}
|
|
if (value.kind === 'date') {
|
|
return value.date
|
|
}
|
|
if (value.kind === 'labels') {
|
|
return value.labels.map((label) => label.name).join(', ') || 'Empty'
|
|
}
|
|
if (value.kind === 'users') {
|
|
return value.users.map((user) => user.login).join(', ') || 'Empty'
|
|
}
|
|
return 'Empty'
|
|
}
|
|
|
|
function projectFieldDisplayLabel(row: GitHubProjectRow, field: GitHubProjectField): string {
|
|
if (field.dataType === 'ASSIGNEES') {
|
|
return row.content.assignees.map((user) => user.login).join(', ') || 'Empty'
|
|
}
|
|
if (field.dataType === 'LABELS') {
|
|
return row.content.labels.map((label) => label.name).join(', ') || 'Empty'
|
|
}
|
|
if (field.dataType === 'REPOSITORY') {
|
|
return row.content.repository ?? 'Empty'
|
|
}
|
|
if (field.dataType === 'PARENT_ISSUE') {
|
|
return row.content.parentIssue ? `#${row.content.parentIssue.number}` : 'Empty'
|
|
}
|
|
if (field.dataType === 'ISSUE_TYPE') {
|
|
return row.content.issueType?.name ?? 'Empty'
|
|
}
|
|
if (field.dataType === 'TITLE') {
|
|
return row.content.title
|
|
}
|
|
return projectFieldValueLabel(row, field)
|
|
}
|
|
|
|
function projectSummaryFields(table: GitHubProjectTable | null): GitHubProjectField[] {
|
|
return (
|
|
table?.selectedView.fields?.filter(
|
|
(field) => field.dataType !== 'TITLE' && field.dataType !== 'REPOSITORY'
|
|
) ?? []
|
|
)
|
|
}
|
|
|
|
function projectFieldVisibilityKey(table: GitHubProjectTable | null): string | null {
|
|
if (!table) {
|
|
return null
|
|
}
|
|
// Why: desktop scopes column visibility to project + view; matching that
|
|
// avoids hiding fields across unrelated Project views with colliding IDs.
|
|
return `${table.project.id}:${table.selectedView.id}`
|
|
}
|
|
|
|
function projectFieldDraftValue(row: GitHubProjectRow, field: GitHubProjectField): string {
|
|
const value = row.fieldValuesByFieldId?.[field.id]
|
|
if (!value) {
|
|
return ''
|
|
}
|
|
if (value.kind === 'text') {
|
|
return value.text
|
|
}
|
|
if (value.kind === 'number') {
|
|
return String(value.number)
|
|
}
|
|
if (value.kind === 'date') {
|
|
return value.date
|
|
}
|
|
return ''
|
|
}
|
|
|
|
function normalizeProjectTableForMobileSort(
|
|
table: GitHubProjectTable,
|
|
rows: GitHubProjectRow[],
|
|
sortOverride: ProjectSortOverride | null
|
|
): SharedGitHubProjectTable {
|
|
const fields = table.selectedView.fields ?? []
|
|
const overrideField = sortOverride
|
|
? fields.find((field) => field.id === sortOverride.fieldId)
|
|
: undefined
|
|
const normalizedRows = rows.map((row, index) => ({
|
|
...row,
|
|
content: {
|
|
...row.content,
|
|
stateReason: row.content.stateReason ?? null,
|
|
parentIssue: row.content.parentIssue ?? null,
|
|
issueType: row.content.issueType ?? null
|
|
},
|
|
fieldValuesByFieldId: row.fieldValuesByFieldId ?? {},
|
|
position: row.position ?? index
|
|
}))
|
|
|
|
return {
|
|
...table,
|
|
selectedView: {
|
|
...table.selectedView,
|
|
fields,
|
|
groupByFields: table.selectedView.groupByFields ?? [],
|
|
sortByFields:
|
|
sortOverride && overrideField
|
|
? [{ field: overrideField, direction: sortOverride.direction }]
|
|
: (table.selectedView.sortByFields ?? [])
|
|
},
|
|
rows: normalizedRows,
|
|
parentFieldDropped: table.parentFieldDropped === true
|
|
} as unknown as SharedGitHubProjectTable
|
|
}
|
|
|
|
function projectGroupMeta(group: ProjectGroup): string {
|
|
const parts = [`${group.rows.length}`]
|
|
if (group.iteration) {
|
|
const endDate = new Date(`${group.iteration.startDate}T00:00:00Z`)
|
|
if (!Number.isNaN(endDate.getTime())) {
|
|
endDate.setUTCDate(endDate.getUTCDate() + group.iteration.duration - 1)
|
|
parts.push(`${group.iteration.startDate} - ${endDate.toISOString().slice(0, 10)}`)
|
|
}
|
|
if (isIterationCurrent(group.iteration)) {
|
|
parts.push('Current')
|
|
}
|
|
}
|
|
return parts.join(' · ')
|
|
}
|
|
|
|
function optimisticProjectFieldValue(
|
|
field: GitHubProjectField,
|
|
value: GitHubProjectFieldMutationValue
|
|
): GitHubProjectFieldValue {
|
|
if (value.kind === 'single-select' && field.kind === 'single-select') {
|
|
const option = field.options.find((entry) => entry.id === value.optionId)
|
|
return {
|
|
kind: 'single-select',
|
|
fieldId: field.id,
|
|
optionId: value.optionId,
|
|
name: option?.name ?? 'Selected',
|
|
color: option?.color ?? 'GRAY'
|
|
}
|
|
}
|
|
if (value.kind === 'iteration' && field.kind === 'iteration') {
|
|
const iteration = field.iterations.find((entry) => entry.id === value.iterationId)
|
|
return {
|
|
kind: 'iteration',
|
|
fieldId: field.id,
|
|
iterationId: value.iterationId,
|
|
title: iteration?.title ?? 'Iteration',
|
|
startDate: iteration?.startDate ?? '',
|
|
duration: iteration?.duration ?? 0
|
|
}
|
|
}
|
|
if (value.kind === 'number') {
|
|
return { kind: 'number', fieldId: field.id, number: value.number }
|
|
}
|
|
if (value.kind === 'date') {
|
|
return { kind: 'date', fieldId: field.id, date: value.date }
|
|
}
|
|
return { kind: 'text', fieldId: field.id, text: value.kind === 'text' ? value.text : '' }
|
|
}
|
|
|
|
function taskKindLabel(item: TaskItem): string {
|
|
if (item.provider === 'github') {
|
|
return item.source.type === 'pr' ? 'Pull request' : 'Issue'
|
|
}
|
|
if (item.provider === 'gitlab') {
|
|
return item.source.type === 'mr' ? 'Merge request' : 'Issue'
|
|
}
|
|
if (item.provider === 'gitlabTodo') {
|
|
return `${gitLabTodoTargetLabel(item.source)} todo`
|
|
}
|
|
return 'Linear ticket'
|
|
}
|
|
|
|
function taskExternalOpenLabel(item: TaskItem): string {
|
|
if (item.provider === 'github') {
|
|
return 'Open in GitHub'
|
|
}
|
|
if (item.provider === 'gitlab' || item.provider === 'gitlabTodo') {
|
|
return 'Open in GitLab'
|
|
}
|
|
return 'Open in Linear'
|
|
}
|
|
|
|
function taskStatusActionLabel(item: TaskItem): string {
|
|
const verb =
|
|
item.provider === 'github' || item.provider === 'gitlab'
|
|
? item.source.state === 'closed'
|
|
? 'Reopen'
|
|
: 'Close'
|
|
: ''
|
|
return verb ? `${verb} ${taskKindLabel(item).toLowerCase()}` : ''
|
|
}
|
|
|
|
function isGitHubPrMergeBlocked(item: Extract<TaskItem, { provider: 'github' }>): boolean {
|
|
return item.source.type === 'pr' && item.source.mergeable === 'CONFLICTING'
|
|
}
|
|
|
|
function commentAuthor(comment: DetailComment): string {
|
|
return comment.author ?? comment.user?.displayName ?? 'unknown'
|
|
}
|
|
|
|
function commentDate(value: string | undefined): string {
|
|
if (!value) {
|
|
return ''
|
|
}
|
|
const time = Date.parse(value)
|
|
return Number.isFinite(time) ? new Date(time).toLocaleDateString() : ''
|
|
}
|
|
|
|
function formatDurationSeconds(value: number | null | undefined): string {
|
|
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
return ''
|
|
}
|
|
const seconds = Math.max(0, Math.floor(value))
|
|
if (seconds >= 60) {
|
|
return `${Math.floor(seconds / 60)}m ${seconds % 60}s`
|
|
}
|
|
return `${seconds}s`
|
|
}
|
|
|
|
function commentSourceLabel(comment: DetailComment): string {
|
|
if (comment.path) {
|
|
const line =
|
|
typeof comment.line === 'number'
|
|
? typeof comment.startLine === 'number' && comment.startLine !== comment.line
|
|
? `${comment.startLine}-${comment.line}`
|
|
: String(comment.line)
|
|
: ''
|
|
const location = line ? `${comment.path}:${line}` : comment.path
|
|
return `${comment.isResolved ? 'Resolved review' : 'Review'} · ${location}`
|
|
}
|
|
if (comment.threadId) {
|
|
return comment.isResolved ? 'Resolved review thread' : 'Review thread'
|
|
}
|
|
return 'Top-level comment'
|
|
}
|
|
|
|
function groupDetailComments(comments: DetailComment[]): DetailCommentGroup[] {
|
|
const threads = new Map<string, { root: DetailComment; replies: DetailComment[] }>()
|
|
const groups: DetailCommentGroup[] = []
|
|
const emittedThreads = new Set<string>()
|
|
|
|
for (const comment of comments) {
|
|
if (!comment.threadId) {
|
|
continue
|
|
}
|
|
const existing = threads.get(comment.threadId)
|
|
if (existing) {
|
|
existing.replies.push(comment)
|
|
} else {
|
|
threads.set(comment.threadId, { root: comment, replies: [] })
|
|
}
|
|
}
|
|
|
|
for (const comment of comments) {
|
|
if (!comment.threadId) {
|
|
groups.push({ kind: 'standalone', comment })
|
|
continue
|
|
}
|
|
if (emittedThreads.has(comment.threadId)) {
|
|
continue
|
|
}
|
|
emittedThreads.add(comment.threadId)
|
|
const thread = threads.get(comment.threadId)
|
|
if (thread) {
|
|
groups.push({ kind: 'thread', threadId: comment.threadId, ...thread })
|
|
}
|
|
}
|
|
|
|
return groups
|
|
}
|
|
|
|
function detailCommentGroupId(group: DetailCommentGroup): string {
|
|
return group.kind === 'thread' ? `thread:${group.threadId}` : `comment:${group.comment.id}`
|
|
}
|
|
|
|
function detailCommentGroupRoot(group: DetailCommentGroup): DetailComment {
|
|
return group.kind === 'thread' ? group.root : group.comment
|
|
}
|
|
|
|
function detailCommentGroupCount(group: DetailCommentGroup): number {
|
|
return group.kind === 'thread' ? 1 + group.replies.length : 1
|
|
}
|
|
|
|
function isResolvedDetailCommentGroup(group: DetailCommentGroup): boolean {
|
|
return detailCommentGroupRoot(group).isResolved === true
|
|
}
|
|
|
|
function discussionSummary(count: number): string {
|
|
if (count === 0) {
|
|
return 'No comments yet'
|
|
}
|
|
return `${count} ${count === 1 ? 'comment' : 'comments'}`
|
|
}
|
|
|
|
function renderCommentReactions(comment: DetailComment): ReactNode {
|
|
const reactions = (comment.reactions ?? []).filter((reaction) => reaction.count > 0)
|
|
if (reactions.length === 0) {
|
|
return null
|
|
}
|
|
return (
|
|
<View style={styles.reactionRow}>
|
|
{reactions.map((reaction) => (
|
|
<View key={reaction.content} style={styles.reactionChip}>
|
|
<Text style={styles.reactionText}>
|
|
{COMMENT_REACTION_EMOJI[reaction.content]} {reaction.count}
|
|
</Text>
|
|
</View>
|
|
))}
|
|
</View>
|
|
)
|
|
}
|
|
|
|
function formatDiffLineNumber(value: number | undefined): string {
|
|
return value === undefined ? ' ' : value.toString().padStart(4, ' ')
|
|
}
|
|
|
|
function diffLinePrefix(kind: GitHubPrFileDiffLine['kind']): string {
|
|
if (kind === 'added') {
|
|
return '+'
|
|
}
|
|
if (kind === 'removed') {
|
|
return '-'
|
|
}
|
|
return ' '
|
|
}
|
|
|
|
function GitHubPrFileDiff({
|
|
filePath,
|
|
contents,
|
|
commentDrafts,
|
|
disabled,
|
|
onCommentDraftChange,
|
|
onSubmitComment
|
|
}: {
|
|
filePath: string
|
|
contents: GitHubPRFileContents
|
|
commentDrafts: Record<string, string>
|
|
disabled: boolean
|
|
onCommentDraftChange: (key: string, value: string) => void
|
|
onSubmitComment: (line: number) => void
|
|
}): ReactNode {
|
|
const diffPreview = useMemo(
|
|
() =>
|
|
buildGitHubPrFileDiffPreview(
|
|
contents.original,
|
|
contents.modified,
|
|
MAX_RENDERED_PR_DIFF_LINES
|
|
),
|
|
[contents.modified, contents.original]
|
|
)
|
|
const syntaxLanguage = useMemo(() => resolveMobileSyntaxLanguage(filePath), [filePath])
|
|
const visibleDiffLines = useMemo(
|
|
() => highlightMobileDiffLines(diffPreview.lines, syntaxLanguage),
|
|
[diffPreview.lines, syntaxLanguage]
|
|
)
|
|
const hiddenDiffLineCount = Math.max(0, diffPreview.totalLineCount - visibleDiffLines.length)
|
|
|
|
if (diffPreview.totalLineCount === 0) {
|
|
return <Text style={styles.detailMuted}>No text changes found.</Text>
|
|
}
|
|
|
|
return (
|
|
<View style={styles.fileDiff}>
|
|
{hiddenDiffLineCount > 0 ? (
|
|
<Text style={styles.detailMuted}>
|
|
Showing first {MAX_RENDERED_PR_DIFF_LINES} of {diffPreview.totalLineCount} diff lines.
|
|
</Text>
|
|
) : null}
|
|
{visibleDiffLines.map((line) => {
|
|
const commentLine = line.kind === 'removed' ? undefined : line.newLineNumber
|
|
const draftKey = commentLine === undefined ? '' : `${filePath}:${commentLine}`
|
|
return (
|
|
<View
|
|
key={line.key}
|
|
style={[
|
|
styles.diffLineBlock,
|
|
line.kind === 'added'
|
|
? styles.diffLineAdded
|
|
: line.kind === 'removed'
|
|
? styles.diffLineRemoved
|
|
: null
|
|
]}
|
|
>
|
|
<View style={styles.diffCodeRow}>
|
|
<Text style={styles.diffLineNumbers}>
|
|
{formatDiffLineNumber(line.oldLineNumber)}{' '}
|
|
{formatDiffLineNumber(line.newLineNumber)}
|
|
</Text>
|
|
<Text
|
|
style={[
|
|
styles.codeLine,
|
|
line.kind === 'added'
|
|
? styles.diffCodeAdded
|
|
: line.kind === 'removed'
|
|
? styles.diffCodeRemoved
|
|
: null
|
|
]}
|
|
>
|
|
<Text>{diffLinePrefix(line.kind)} </Text>
|
|
<MobileSyntaxSegments segments={line.segments} />
|
|
{line.text ? null : ' '}
|
|
</Text>
|
|
</View>
|
|
{commentLine !== undefined ? (
|
|
<>
|
|
<TextInput
|
|
style={[styles.input, styles.replyInput]}
|
|
value={commentDrafts[draftKey] ?? ''}
|
|
onChangeText={(next) => onCommentDraftChange(draftKey, next)}
|
|
placeholder="Add review comment"
|
|
placeholderTextColor={colors.textMuted}
|
|
multiline
|
|
textAlignVertical="top"
|
|
/>
|
|
<Pressable
|
|
style={styles.inlineSaveButtonCompact}
|
|
disabled={disabled || !(commentDrafts[draftKey] ?? '').trim()}
|
|
onPress={() => onSubmitComment(commentLine)}
|
|
>
|
|
<Text style={styles.inlineSaveText}>Comment on line {commentLine}</Text>
|
|
</Pressable>
|
|
</>
|
|
) : null}
|
|
</View>
|
|
)
|
|
})}
|
|
</View>
|
|
)
|
|
}
|
|
|
|
function isFailedGitHubCheck(check: { conclusion?: string | null }): boolean {
|
|
return ['failure', 'cancelled', 'timed_out'].includes(check.conclusion ?? '')
|
|
}
|
|
|
|
function repositoryCount(count: number): string {
|
|
return `${count} ${count === 1 ? 'repository' : 'repositories'}`
|
|
}
|
|
|
|
function buildPartialRepositoryNotice(failedCount: number, totalCount: number): string {
|
|
return `${failedCount} of ${repositoryCount(totalCount)} failed to load.`
|
|
}
|
|
|
|
function repoColor(name: string): string {
|
|
const palette = ['#f97316', '#8b5cf6', '#06b6d4', '#ec4899', '#84cc16', '#f59e0b', '#6366f1']
|
|
let hash = 0
|
|
for (let i = 0; i < name.length; i += 1) {
|
|
hash = (hash * 31 + name.charCodeAt(i)) | 0
|
|
}
|
|
return palette[Math.abs(hash) % palette.length]!
|
|
}
|
|
|
|
function getRepoBadgeColor(repo: RepoSummary | undefined, fallbackName: string): string {
|
|
return repo?.badgeColor || repoColor(repo?.displayName ?? fallbackName)
|
|
}
|
|
|
|
function setupSourceLabel(source: string | null): string {
|
|
if (source === 'orca.yaml') {
|
|
return 'orca.yaml'
|
|
}
|
|
if (source === 'legacy') {
|
|
return 'local hooks'
|
|
}
|
|
return 'repository hooks'
|
|
}
|
|
|
|
function taskRepositoryMeta(
|
|
item: TaskItem,
|
|
reposById: Map<string, RepoSummary>
|
|
): { key: string; label: string; color: string } {
|
|
if (item.provider === 'github' || item.provider === 'gitlab') {
|
|
const repo = reposById.get(item.source.repoId)
|
|
return {
|
|
key: item.source.repoId,
|
|
label: repo?.displayName ?? item.source.repoName,
|
|
color: getRepoBadgeColor(repo, item.source.repoName)
|
|
}
|
|
}
|
|
if (item.provider === 'gitlabTodo') {
|
|
return {
|
|
key: item.source.projectPath,
|
|
label: item.source.projectPath,
|
|
color: repoColor(item.source.projectPath)
|
|
}
|
|
}
|
|
return {
|
|
key: item.source.team.id,
|
|
label: item.source.team.name,
|
|
color: item.source.state.color || colors.accentBlue
|
|
}
|
|
}
|
|
|
|
function compareTasksByUpdated(a: TaskItem, b: TaskItem): number {
|
|
return taskTime(b.updatedAt) - taskTime(a.updatedAt)
|
|
}
|
|
|
|
function compareTasksByRepository(
|
|
a: TaskItem,
|
|
b: TaskItem,
|
|
reposById: Map<string, RepoSummary>
|
|
): number {
|
|
const aRepo = taskRepositoryMeta(a, reposById)
|
|
const bRepo = taskRepositoryMeta(b, reposById)
|
|
const repoComparison = aRepo.label.localeCompare(bRepo.label, undefined, { sensitivity: 'base' })
|
|
return repoComparison || compareTasksByUpdated(a, b)
|
|
}
|
|
|
|
export default function MobileTasksScreen() {
|
|
const { hostId, taskSource } = useLocalSearchParams<{ hostId: string; taskSource?: string }>()
|
|
const router = useRouter()
|
|
const insets = useSafeAreaInsets()
|
|
const { client, state: connState } = useHostClient(hostId)
|
|
const reconnectAttempts = useReconnectAttempt(hostId)
|
|
const lastConnectedAt = useLastConnectedAt(hostId)
|
|
const clientRef = useRef<RpcClient | null>(null)
|
|
const reposRef = useRef<RepoSummary[]>([])
|
|
const loadGenerationRef = useRef(0)
|
|
const taskResumeRef = useRef<TaskResumeState>({})
|
|
const [repos, setRepos] = useState<RepoSummary[]>([])
|
|
const [provider, setProvider] = useState<TaskProvider>('github')
|
|
const [visibleProviders, setVisibleProviders] = useState<TaskProvider[]>(() =>
|
|
normalizeVisibleTaskProviders(undefined)
|
|
)
|
|
const [linearConnected, setLinearConnected] = useState(false)
|
|
const [githubMode, setGithubMode] = useState<'items' | 'project'>('items')
|
|
const [githubKind, setGithubKind] = useState<GitHubTaskKind>('issues')
|
|
const [githubPreset, setGithubPreset] = useState<GitHubPreset>('issues')
|
|
const [defaultGitHubPreset, setDefaultGitHubPreset] = useState<GitHubPreset>('issues')
|
|
const [gitlabView, setGitlabView] = useState<GitLabView>('project')
|
|
const [gitlabFilter, setGitlabFilter] = useState<GitLabFilter>('opened')
|
|
const [linearFilter, setLinearFilter] = useState<LinearFilter>('all')
|
|
const [linearViewMode, setLinearViewMode] = useState<LinearViewMode>('list')
|
|
const [linearGroupBy, setLinearGroupBy] = useState<LinearGroupBy>('none')
|
|
const [linearOrderBy, setLinearOrderBy] = useState<LinearOrderBy>('priority')
|
|
const [linearDisplayProperties, setLinearDisplayProperties] = useState<
|
|
ReadonlySet<LinearDisplayProperty>
|
|
>(() => new Set(DEFAULT_LINEAR_DISPLAY_PROPERTIES))
|
|
const [linearTeamPropertyTouched, setLinearTeamPropertyTouched] = useState(false)
|
|
const [linearWorkspaces, setLinearWorkspaces] = useState<LinearWorkspace[]>([])
|
|
const [selectedLinearWorkspaceId, setSelectedLinearWorkspaceId] = useState<string | 'all' | null>(
|
|
null
|
|
)
|
|
const [selectedLinearTeamIds, setSelectedLinearTeamIds] = useState<Set<string>>(new Set())
|
|
const defaultRepoSelectionRef = useRef<string[] | null>(null)
|
|
const repoSelectionHydratedRef = useRef(false)
|
|
const defaultLinearTeamSelectionRef = useRef<string[] | null>(null)
|
|
const [showLinearWorkspacePicker, setShowLinearWorkspacePicker] = useState(false)
|
|
const [showLinearTeamPicker, setShowLinearTeamPicker] = useState(false)
|
|
const [showLinearViewPicker, setShowLinearViewPicker] = useState(false)
|
|
const [showLinearGroupPicker, setShowLinearGroupPicker] = useState(false)
|
|
const [showLinearOrderPicker, setShowLinearOrderPicker] = useState(false)
|
|
const [showLinearDisplayPicker, setShowLinearDisplayPicker] = useState(false)
|
|
const [showLinearConnect, setShowLinearConnect] = useState(false)
|
|
const [linearApiKeyDraft, setLinearApiKeyDraft] = useState('')
|
|
const [linearConnectState, setLinearConnectState] = useState<'idle' | 'connecting' | 'error'>(
|
|
'idle'
|
|
)
|
|
const [linearConnectError, setLinearConnectError] = useState('')
|
|
const [taskSort, setTaskSort] = useState<TaskSort>('updated')
|
|
const [selectedRepoIds, setSelectedRepoIds] = useState<Set<string>>(new Set())
|
|
const [items, setItems] = useState<TaskItem[]>([])
|
|
const [githubPages, setGithubPages] = useState<
|
|
Array<Extract<TaskItem, { provider: 'github' }>[]>
|
|
>([])
|
|
const [githubCurrentPage, setGithubCurrentPage] = useState(0)
|
|
const [githubTotalCount, setGithubTotalCount] = useState<number | null>(null)
|
|
const [githubPaginationLoading, setGithubPaginationLoading] = useState(false)
|
|
const [githubLoadingTargetPage, setGithubLoadingTargetPage] = useState<number | null>(null)
|
|
const [githubRepoSources, setGithubRepoSources] = useState<Record<string, GitHubRepoSources>>({})
|
|
const [githubSourceErrors, setGithubSourceErrors] = useState<GitHubIssueSourceError[]>([])
|
|
const [githubSourceFallbacks, setGithubSourceFallbacks] = useState<GitHubIssueSourceFallback[]>(
|
|
[]
|
|
)
|
|
const [retryingGithubSourceRepoPaths, setRetryingGithubSourceRepoPaths] = useState<Set<string>>(
|
|
new Set()
|
|
)
|
|
const [githubRepoSlugCache, setGithubRepoSlugCache] = useState<
|
|
Record<string, GitHubRepoSlugCacheEntry | undefined>
|
|
>({})
|
|
const [query, setQuery] = useState(getTaskPresetQuery('issues'))
|
|
const [appliedQuery, setAppliedQuery] = useState(getTaskPresetQuery('issues'))
|
|
const [showProviderPicker, setShowProviderPicker] = useState(false)
|
|
const [showGitHubKindPicker, setShowGitHubKindPicker] = useState(false)
|
|
const [showGitHubPresetPicker, setShowGitHubPresetPicker] = useState(false)
|
|
const [showGitLabViewPicker, setShowGitLabViewPicker] = useState(false)
|
|
const [showGitLabFilterPicker, setShowGitLabFilterPicker] = useState(false)
|
|
const [showLinearFilterPicker, setShowLinearFilterPicker] = useState(false)
|
|
const [showSortPicker, setShowSortPicker] = useState(false)
|
|
const [showRepoPicker, setShowRepoPicker] = useState(false)
|
|
const [showGitHubIssueSourcePicker, setShowGitHubIssueSourcePicker] = useState(false)
|
|
const [showGitHubPagePicker, setShowGitHubPagePicker] = useState(false)
|
|
const [showCreateTask, setShowCreateTask] = useState(false)
|
|
const [showCreateTargetPicker, setShowCreateTargetPicker] = useState(false)
|
|
const [createTitle, setCreateTitle] = useState('')
|
|
const [createBody, setCreateBody] = useState('')
|
|
const [createRepoId, setCreateRepoId] = useState<string | null>(null)
|
|
const [createTeamId, setCreateTeamId] = useState<string | null>(null)
|
|
const [linearTeams, setLinearTeams] = useState<LinearTeam[]>([])
|
|
const [creatingTask, setCreatingTask] = useState(false)
|
|
const [loading, setLoading] = useState(false)
|
|
const [refreshing, setRefreshing] = useState(false)
|
|
const [tasksSupportState, setTasksSupportState] = useState<TasksSupportState>({
|
|
kind: 'unknown',
|
|
client: null
|
|
})
|
|
const [error, setError] = useState('')
|
|
const [actionItem, setActionItem] = useState<ActionableTaskItem | null>(null)
|
|
const [mergeMethodTaskItem, setMergeMethodTaskItem] = useState<
|
|
Extract<TaskItem, { provider: 'github' }> | Extract<TaskItem, { provider: 'gitlab' }> | null
|
|
>(null)
|
|
const [mergeMethodProjectRow, setMergeMethodProjectRow] = useState<GitHubProjectRow | null>(null)
|
|
const [pendingHostedMerge, setPendingHostedMerge] = useState<PendingHostedMerge | null>(null)
|
|
const [pendingProjectGitHubMerge, setPendingProjectGitHubMerge] =
|
|
useState<PendingProjectGitHubMerge | null>(null)
|
|
const [pendingHostedStateChange, setPendingHostedStateChange] =
|
|
useState<PendingHostedStateChange | null>(null)
|
|
const [detailPayload, setDetailPayload] = useState<DetailPayload | null>(null)
|
|
const [detailLoading, setDetailLoading] = useState(false)
|
|
const [detailError, setDetailError] = useState('')
|
|
const [detailRefreshSeq, setDetailRefreshSeq] = useState(0)
|
|
const [itemTitleDraft, setItemTitleDraft] = useState('')
|
|
const [itemBodyDraft, setItemBodyDraft] = useState('')
|
|
const [itemCommentDraft, setItemCommentDraft] = useState('')
|
|
const [itemAddLabelsDraft, setItemAddLabelsDraft] = useState('')
|
|
const [itemRemoveLabelsDraft, setItemRemoveLabelsDraft] = useState('')
|
|
const [itemAddAssigneesDraft, setItemAddAssigneesDraft] = useState('')
|
|
const [itemRemoveAssigneesDraft, setItemRemoveAssigneesDraft] = useState('')
|
|
const [itemAvailableLabels, setItemAvailableLabels] = useState<string[]>([])
|
|
const [itemLabelsLoading, setItemLabelsLoading] = useState(false)
|
|
const [itemLabelsError, setItemLabelsError] = useState('')
|
|
const [itemAssignableUsers, setItemAssignableUsers] = useState<GitHubAssignableUser[]>([])
|
|
const [itemAssignableUsersLoading, setItemAssignableUsersLoading] = useState(false)
|
|
const [itemAssignableUsersError, setItemAssignableUsersError] = useState('')
|
|
const [itemReviewersDraft, setItemReviewersDraft] = useState('')
|
|
const [itemReplyDrafts, setItemReplyDrafts] = useState<Record<string, string>>({})
|
|
const [expandedPrFilePath, setExpandedPrFilePath] = useState<string | null>(null)
|
|
const [prFileContents, setPrFileContents] = useState<Record<string, GitHubPRFileContents>>({})
|
|
const [prFileLoadingPath, setPrFileLoadingPath] = useState<string | null>(null)
|
|
const [prFileCommentDrafts, setPrFileCommentDrafts] = useState<Record<string, string>>({})
|
|
const [copiedLinkKey, setCopiedLinkKey] = useState<string | null>(null)
|
|
const copiedLinkResetTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
const [expandedResolvedCommentGroups, setExpandedResolvedCommentGroups] = useState<Set<string>>(
|
|
() => new Set()
|
|
)
|
|
const detailCommentGroups = useMemo(
|
|
() => groupDetailComments(detailPayload?.comments ?? []),
|
|
[detailPayload?.comments]
|
|
)
|
|
const [workspaceRepoPickerItem, setWorkspaceRepoPickerItem] = useState<Extract<
|
|
TaskItem,
|
|
{ provider: 'linear' }
|
|
> | null>(null)
|
|
const [workspaceCreateDraft, setWorkspaceCreateDraft] = useState<WorkspaceCreateDraft | null>(
|
|
null
|
|
)
|
|
const [workspaceNameDraft, setWorkspaceNameDraft] = useState('')
|
|
const [workspaceLastAutoName, setWorkspaceLastAutoName] = useState('')
|
|
const [workspaceBranchAutoName, setWorkspaceBranchAutoName] = useState('')
|
|
const [workspaceBranchNameOverride, setWorkspaceBranchNameOverride] = useState<
|
|
string | undefined
|
|
>(undefined)
|
|
const [workspaceBaseBranch, setWorkspaceBaseBranch] = useState<BaseRefSearchResult | null>(null)
|
|
const [workspaceBaseBranchQuery, setWorkspaceBaseBranchQuery] = useState('')
|
|
const [workspaceBaseBranchResults, setWorkspaceBaseBranchResults] = useState<
|
|
BaseRefSearchResult[]
|
|
>([])
|
|
const [workspaceBaseBranchLoading, setWorkspaceBaseBranchLoading] = useState(false)
|
|
const [workspaceBaseBranchError, setWorkspaceBaseBranchError] = useState('')
|
|
const [workspaceSparsePresets, setWorkspaceSparsePresets] = useState<SparsePreset[]>([])
|
|
const [workspaceSparsePresetsLoading, setWorkspaceSparsePresetsLoading] = useState(false)
|
|
const [workspaceSparsePresetsLoaded, setWorkspaceSparsePresetsLoaded] = useState(false)
|
|
const [_workspaceSparsePresetsError, setWorkspaceSparsePresetsError] = useState('')
|
|
const [workspaceSparseReloadKey, setWorkspaceSparseReloadKey] = useState(0)
|
|
const [workspaceSparsePresetId, setWorkspaceSparsePresetId] = useState<string | null>(null)
|
|
const [workspaceSparseDraft, setWorkspaceSparseDraft] = useState<WorkspaceSparseDraft | null>(
|
|
null
|
|
)
|
|
const [workspaceSparseSaving, setWorkspaceSparseSaving] = useState(false)
|
|
const [workspaceAgent, setWorkspaceAgent] = useState<WorkspaceAgentChoice | null>(null)
|
|
const [workspaceAgentOverridden, setWorkspaceAgentOverridden] = useState(false)
|
|
const [workspaceDetectedAgentIds, setWorkspaceDetectedAgentIds] = useState<Set<string> | null>(
|
|
null
|
|
)
|
|
const [workspaceSshState, setWorkspaceSshState] = useState<SshConnectionState | null>(null)
|
|
const [workspaceSshConnecting, setWorkspaceSshConnecting] = useState(false)
|
|
const [showWorkspaceAgentPicker, setShowWorkspaceAgentPicker] = useState(false)
|
|
const [showWorkspaceCreateRepoPicker, setShowWorkspaceCreateRepoPicker] = useState(false)
|
|
const [showWorkspaceAdvanced, setShowWorkspaceAdvanced] = useState(false)
|
|
const [showWorkspaceBaseBranchPicker, setShowWorkspaceBaseBranchPicker] = useState(false)
|
|
const [showWorkspaceSparsePicker, setShowWorkspaceSparsePicker] = useState(false)
|
|
const [linearStatusPickerItem, setLinearStatusPickerItem] = useState<Extract<
|
|
TaskItem,
|
|
{ provider: 'linear' }
|
|
> | null>(null)
|
|
const [setupPrompt, setSetupPrompt] = useState<SetupPrompt | null>(null)
|
|
const [creatingKey, setCreatingKey] = useState<string | null>(null)
|
|
const [mutatingStatus, setMutatingStatus] = useState(false)
|
|
const [linearStates, setLinearStates] = useState<LinearState[]>([])
|
|
const [linearStatesLoading, setLinearStatesLoading] = useState(false)
|
|
const [linearCommentDraft, setLinearCommentDraft] = useState('')
|
|
const [linearSubIssueTitle, setLinearSubIssueTitle] = useState('')
|
|
const [taskStateHydrated, setTaskStateHydrated] = useState(false)
|
|
const [runtimeTaskSettings, setRuntimeTaskSettings] = useState<RuntimeTaskSettings>({})
|
|
const [trustedOrcaHooks, setTrustedOrcaHooks] = useState<PersistedTrustedOrcaHooks>({})
|
|
const [orcaYamlTrustPrompt, setOrcaYamlTrustPrompt] = useState<OrcaYamlTrustPrompt | null>(null)
|
|
const [githubProjectSettings, setGithubProjectSettings] = useState<GitHubProjectSettings>(
|
|
EMPTY_GITHUB_PROJECT_SETTINGS
|
|
)
|
|
const [githubProjects, setGithubProjects] = useState<GitHubProjectSummary[]>([])
|
|
const [githubProjectViews, setGithubProjectViews] = useState<GitHubProjectViewSummary[]>([])
|
|
const [githubProjectTable, setGithubProjectTable] = useState<GitHubProjectTable | null>(null)
|
|
const [githubProjectLoading, setGithubProjectLoading] = useState(false)
|
|
const [githubProjectError, setGithubProjectError] = useState('')
|
|
const [githubProjectPartialFailures, setGithubProjectPartialFailures] = useState<
|
|
GitHubProjectPartialFailure[]
|
|
>([])
|
|
const [githubProjectSearch, setGithubProjectSearch] = useState('')
|
|
const [githubProjectPickerSearch, setGithubProjectPickerSearch] = useState('')
|
|
const [githubProjectPasteInput, setGithubProjectPasteInput] = useState('')
|
|
const [githubProjectPasteError, setGithubProjectPasteError] = useState('')
|
|
const [githubProjectPasteBusy, setGithubProjectPasteBusy] = useState(false)
|
|
const [appliedGithubProjectSearch, setAppliedGithubProjectSearch] = useState<string | undefined>(
|
|
undefined
|
|
)
|
|
const [githubProjectSortOverride, setGithubProjectSortOverride] =
|
|
useState<ProjectSortOverride | null>(null)
|
|
const [githubProjectHiddenFieldIdsByView, setGithubProjectHiddenFieldIdsByView] = useState<
|
|
Record<string, string[]>
|
|
>({})
|
|
const [collapsedGitHubProjectGroups, setCollapsedGitHubProjectGroups] = useState<Set<string>>(
|
|
() => new Set()
|
|
)
|
|
const [showGitHubProjectPicker, setShowGitHubProjectPicker] = useState(false)
|
|
const [showGitHubProjectViewPicker, setShowGitHubProjectViewPicker] = useState(false)
|
|
const [showGitHubProjectSortPicker, setShowGitHubProjectSortPicker] = useState(false)
|
|
const [showGitHubProjectFieldsPicker, setShowGitHubProjectFieldsPicker] = useState(false)
|
|
const [pendingGitHubProjectViewSelection, setPendingGitHubProjectViewSelection] =
|
|
useState<GitHubProjectRef | null>(null)
|
|
const [projectRowItem, setProjectRowItem] = useState<GitHubProjectRow | null>(null)
|
|
const [projectRowDetail, setProjectRowDetail] = useState<DetailPayload | null>(null)
|
|
const [projectRowDetailLoading, setProjectRowDetailLoading] = useState(false)
|
|
const [projectRowDetailError, setProjectRowDetailError] = useState('')
|
|
const [projectRowDetailRefreshSeq, setProjectRowDetailRefreshSeq] = useState(0)
|
|
const [projectTitleDraft, setProjectTitleDraft] = useState('')
|
|
const [projectBodyDraft, setProjectBodyDraft] = useState('')
|
|
const [projectCommentDraft, setProjectCommentDraft] = useState('')
|
|
const [projectEditingCommentId, setProjectEditingCommentId] = useState<string | null>(null)
|
|
const [projectEditingCommentDraft, setProjectEditingCommentDraft] = useState('')
|
|
const [projectReviewersDraft, setProjectReviewersDraft] = useState('')
|
|
const [projectFieldDrafts, setProjectFieldDrafts] = useState<Record<string, string>>({})
|
|
const [projectAvailableLabels, setProjectAvailableLabels] = useState<string[]>([])
|
|
const [projectLabelsLoading, setProjectLabelsLoading] = useState(false)
|
|
const [projectLabelsError, setProjectLabelsError] = useState('')
|
|
const [projectAssignableUsers, setProjectAssignableUsers] = useState<GitHubAssignableUser[]>([])
|
|
const [projectAssignableUsersLoading, setProjectAssignableUsersLoading] = useState(false)
|
|
const [projectAssignableUsersError, setProjectAssignableUsersError] = useState('')
|
|
const [projectIssueTypes, setProjectIssueTypes] = useState<GitHubIssueType[]>([])
|
|
const [projectIssueTypesLoading, setProjectIssueTypesLoading] = useState(false)
|
|
const [projectIssueTypesError, setProjectIssueTypesError] = useState('')
|
|
const [projectMutating, setProjectMutating] = useState(false)
|
|
const [projectRepoNotInOrca, setProjectRepoNotInOrca] =
|
|
useState<ProjectRepoNotInOrcaPrompt | null>(null)
|
|
// Why: project detail text inputs rerender this screen while comments stay
|
|
// unchanged; keep grouping out of the typing path.
|
|
const projectDetailCommentGroups = useMemo(
|
|
() =>
|
|
groupDetailComments(projectRowDetail?.provider === 'github' ? projectRowDetail.comments : []),
|
|
[projectRowDetail]
|
|
)
|
|
const requestedTaskSource = useMemo(
|
|
() => (isTaskProvider(taskSource) ? taskSource : undefined),
|
|
[taskSource]
|
|
)
|
|
const linearMetadataItem = actionItem?.provider === 'linear' ? actionItem : linearStatusPickerItem
|
|
const tasksSupported =
|
|
connState === 'connected' &&
|
|
client != null &&
|
|
tasksSupportState.kind === 'supported' &&
|
|
tasksSupportState.client === client
|
|
const tasksUnsupported =
|
|
connState === 'connected' &&
|
|
client != null &&
|
|
tasksSupportState.kind === 'unsupported' &&
|
|
tasksSupportState.client === client
|
|
const taskUiReady = tasksSupported && taskStateHydrated
|
|
const activeGitHubProject = githubProjectSettings.activeProject
|
|
const activeGitHubProjectHost = githubProjectHost(
|
|
githubProjectTable?.project.host ?? activeGitHubProject?.host
|
|
)
|
|
const hostedRepos = useMemo(() => repos.filter(isHostedTaskRepo), [repos])
|
|
const workspaceRepos = useMemo(() => repos.filter((repo) => repo.kind !== 'folder'), [repos])
|
|
const reposById = useMemo(() => new Map(repos.map((repo) => [repo.id, repo])), [repos])
|
|
const selectedHostedRepos = useMemo(
|
|
() =>
|
|
selectedRepoIds.size === 0
|
|
? hostedRepos
|
|
: hostedRepos.filter((repo) => selectedRepoIds.has(repo.id)),
|
|
[hostedRepos, selectedRepoIds]
|
|
)
|
|
const findProjectRowRepo = useCallback(
|
|
(row: GitHubProjectRow): RepoSummary | null =>
|
|
findRepoForGitHubProjectRepository(
|
|
row.content.repository,
|
|
hostedRepos,
|
|
githubRepoSlugCache,
|
|
activeGitHubProjectHost
|
|
) as RepoSummary | null,
|
|
[activeGitHubProjectHost, githubRepoSlugCache, hostedRepos]
|
|
)
|
|
const githubProjectRepoSlugReady = useMemo(
|
|
() =>
|
|
hostedRepos.every((repo) => {
|
|
const cached = githubRepoSlugCache[repo.id]
|
|
return cached !== undefined && cached.path === repo.path
|
|
}),
|
|
[githubRepoSlugCache, hostedRepos]
|
|
)
|
|
const visibleGitHubProjectRows = useMemo(
|
|
() =>
|
|
githubProjectTable
|
|
? (filterGitHubProjectRowsForRepos(
|
|
githubProjectTable.rows,
|
|
hostedRepos,
|
|
githubRepoSlugCache,
|
|
activeGitHubProjectHost
|
|
) as GitHubProjectRow[])
|
|
: [],
|
|
[activeGitHubProjectHost, githubProjectTable, githubRepoSlugCache, hostedRepos]
|
|
)
|
|
const visibleGitHubProjectGroups = useMemo<ProjectGroup[]>(() => {
|
|
if (!githubProjectTable) {
|
|
return []
|
|
}
|
|
const normalizedTable = normalizeProjectTableForMobileSort(
|
|
githubProjectTable,
|
|
visibleGitHubProjectRows,
|
|
githubProjectSortOverride
|
|
)
|
|
const sorted = sortRows(normalizedTable, normalizedTable.rows)
|
|
return groupRows(normalizedTable, sorted)
|
|
}, [githubProjectSortOverride, githubProjectTable, visibleGitHubProjectRows])
|
|
const githubProjectListEntries = useMemo<ProjectListEntry[]>(() => {
|
|
const grouped = githubProjectTable?.selectedView.groupByFields?.[0] != null
|
|
if (!grouped) {
|
|
return visibleGitHubProjectGroups.flatMap((group) =>
|
|
group.rows.map((row) => ({
|
|
type: 'row' as const,
|
|
row: row as unknown as GitHubProjectRow
|
|
}))
|
|
)
|
|
}
|
|
return visibleGitHubProjectGroups.flatMap((group) => {
|
|
const collapsed = collapsedGitHubProjectGroups.has(group.key)
|
|
const header: ProjectListEntry = { type: 'group', group, collapsed }
|
|
if (collapsed) {
|
|
return [header]
|
|
}
|
|
return [
|
|
header,
|
|
...group.rows.map((row) => ({
|
|
type: 'row' as const,
|
|
row: row as unknown as GitHubProjectRow
|
|
}))
|
|
]
|
|
})
|
|
}, [collapsedGitHubProjectGroups, githubProjectTable, visibleGitHubProjectGroups])
|
|
const githubProjectAvailableSummaryFields = useMemo(
|
|
() => projectSummaryFields(githubProjectTable),
|
|
[githubProjectTable]
|
|
)
|
|
const githubProjectFieldVisibilityScope = projectFieldVisibilityKey(githubProjectTable)
|
|
const githubProjectHiddenFieldIds = useMemo(
|
|
() =>
|
|
new Set(
|
|
githubProjectFieldVisibilityScope
|
|
? (githubProjectHiddenFieldIdsByView[githubProjectFieldVisibilityScope] ?? [])
|
|
: []
|
|
),
|
|
[githubProjectFieldVisibilityScope, githubProjectHiddenFieldIdsByView]
|
|
)
|
|
const githubProjectSummaryFields = useMemo(
|
|
() =>
|
|
githubProjectAvailableSummaryFields.filter(
|
|
(field) => !githubProjectHiddenFieldIds.has(field.id)
|
|
),
|
|
[githubProjectAvailableSummaryFields, githubProjectHiddenFieldIds]
|
|
)
|
|
|
|
useEffect(() => {
|
|
if (
|
|
!client ||
|
|
connState !== 'connected' ||
|
|
!tasksSupported ||
|
|
!taskStateHydrated ||
|
|
provider !== 'github' ||
|
|
githubMode !== 'project'
|
|
) {
|
|
return
|
|
}
|
|
const missing = hostedRepos.filter((repo) => {
|
|
const cached = githubRepoSlugCache[repo.id]
|
|
return !cached || cached.path !== repo.path
|
|
})
|
|
if (missing.length === 0) {
|
|
return
|
|
}
|
|
|
|
let cancelled = false
|
|
void mapWithConcurrency(missing, GITHUB_REPO_CONCURRENCY, async (repo) => {
|
|
try {
|
|
const response = await client.sendRequest(
|
|
'github.repoSlug',
|
|
{ repo: `id:${repo.id}` },
|
|
{ timeoutMs: 30_000 }
|
|
)
|
|
if (!isSuccess(response)) {
|
|
throw new Error(response.error.message)
|
|
}
|
|
const result = response.result as GitHubOwnerRepo | null
|
|
return {
|
|
repoId: repo.id,
|
|
path: repo.path,
|
|
repository: result
|
|
}
|
|
} catch {
|
|
return { repoId: repo.id, path: repo.path, repository: null }
|
|
}
|
|
}).then((entries) => {
|
|
if (cancelled) {
|
|
return
|
|
}
|
|
setGithubRepoSlugCache((current) => {
|
|
const next = { ...current }
|
|
for (const entry of entries) {
|
|
next[entry.repoId] = { path: entry.path, repository: entry.repository }
|
|
}
|
|
return next
|
|
})
|
|
})
|
|
|
|
return () => {
|
|
cancelled = true
|
|
}
|
|
}, [
|
|
client,
|
|
connState,
|
|
githubMode,
|
|
githubRepoSlugCache,
|
|
hostedRepos,
|
|
provider,
|
|
taskStateHydrated,
|
|
tasksSupported
|
|
])
|
|
const activeGitHubProjectKey = activeGitHubProject ? githubProjectKey(activeGitHubProject) : null
|
|
const activeGitHubProjectViewId = activeGitHubProjectKey
|
|
? githubProjectSettings.lastViewByProject[activeGitHubProjectKey]?.viewId
|
|
: undefined
|
|
const activeGitHubProjectView =
|
|
githubProjectViews.find((view) => view.id === activeGitHubProjectViewId) ??
|
|
(githubProjectTable
|
|
? {
|
|
id: githubProjectTable.selectedView.id,
|
|
number: githubProjectTable.selectedView.number,
|
|
name: githubProjectTable.selectedView.name,
|
|
layout: githubProjectTable.selectedView.layout
|
|
}
|
|
: null)
|
|
const projectIssueTypeRepository =
|
|
projectRowItem?.itemType === 'ISSUE' ? projectRowItem.content.repository : null
|
|
const projectMetadataRepository =
|
|
projectRowItem && projectRowType(projectRowItem) ? projectRowItem.content.repository : null
|
|
const projectRowHostedRepo = useMemo(
|
|
() => (projectRowItem ? findProjectRowRepo(projectRowItem) : null),
|
|
[findProjectRowRepo, projectRowItem]
|
|
)
|
|
const itemReviewerCandidates = useMemo(() => {
|
|
if (!actionItem || actionItem.provider !== 'github' || actionItem.source.type !== 'pr') {
|
|
return []
|
|
}
|
|
const reviewerSeedUsers = getGitHubReviewerSeedUsers({
|
|
reviewRequests:
|
|
detailPayload?.provider === 'github'
|
|
? detailPayload.reviewRequests
|
|
: actionItem.source.reviewRequests,
|
|
latestReviews:
|
|
detailPayload?.provider === 'github'
|
|
? detailPayload.latestReviews
|
|
: actionItem.source.latestReviews,
|
|
author: actionItem.source.author
|
|
})
|
|
const authorLogin = actionItem.source.author?.trim().toLowerCase() ?? null
|
|
return mergeGitHubAssignableUsers(itemAssignableUsers, reviewerSeedUsers).filter(
|
|
(user) => user.login.trim().toLowerCase() !== authorLogin
|
|
)
|
|
}, [actionItem, detailPayload, itemAssignableUsers])
|
|
const itemSelectedReviewerLogins = useMemo(() => {
|
|
if (!actionItem || actionItem.provider !== 'github' || actionItem.source.type !== 'pr') {
|
|
return new Set<string>()
|
|
}
|
|
const reviewRequests =
|
|
detailPayload?.provider === 'github'
|
|
? detailPayload.reviewRequests
|
|
: (actionItem.source.reviewRequests ?? [])
|
|
return new Set(
|
|
reviewRequests.map((reviewer) => reviewer.login.trim().toLowerCase()).filter(Boolean)
|
|
)
|
|
}, [actionItem, detailPayload])
|
|
const projectReviewerCandidates = useMemo(() => {
|
|
if (
|
|
!projectRowItem ||
|
|
projectRowItem.itemType !== 'PULL_REQUEST' ||
|
|
projectRowDetail?.provider !== 'github'
|
|
) {
|
|
return []
|
|
}
|
|
const reviewerSeedUsers = getGitHubReviewerSeedUsers({
|
|
reviewRequests: projectRowDetail.reviewRequests,
|
|
latestReviews: projectRowDetail.latestReviews
|
|
})
|
|
return mergeGitHubAssignableUsers(projectAssignableUsers, reviewerSeedUsers)
|
|
}, [projectAssignableUsers, projectRowDetail, projectRowItem])
|
|
const projectSelectedReviewerLogins = useMemo(() => {
|
|
if (projectRowDetail?.provider !== 'github') {
|
|
return new Set<string>()
|
|
}
|
|
return new Set(
|
|
projectRowDetail.reviewRequests
|
|
.map((reviewer) => reviewer.login.trim().toLowerCase())
|
|
.filter(Boolean)
|
|
)
|
|
}, [projectRowDetail])
|
|
const projectMetadataSeedLogins = useMemo(() => {
|
|
const logins = new Set<string>()
|
|
for (const assignee of projectRowItem?.content.assignees ?? []) {
|
|
const login = assignee.login.trim()
|
|
if (login) {
|
|
logins.add(login)
|
|
}
|
|
}
|
|
for (const reviewer of projectRowDetail?.provider === 'github'
|
|
? getGitHubReviewerSeedUsers(projectRowDetail)
|
|
: []) {
|
|
const login = reviewer.login.trim()
|
|
if (login) {
|
|
logins.add(login)
|
|
}
|
|
}
|
|
return [...logins].sort().join(',')
|
|
}, [projectRowDetail, projectRowItem?.content.assignees])
|
|
|
|
// Why: task-loading effects use this as a stale-client guard, so the ref
|
|
// must be current before those passive effects can run after commit.
|
|
useLayoutEffect(() => {
|
|
clientRef.current = client
|
|
}, [client])
|
|
|
|
const persistTaskResumeState = useCallback(
|
|
(updates: Partial<TaskResumeState>) => {
|
|
if (!client || !taskUiReady) {
|
|
return
|
|
}
|
|
const next = { ...taskResumeRef.current, ...updates }
|
|
taskResumeRef.current = next
|
|
void client.sendRequest('ui.set', { taskResumeState: next }).catch(() => {
|
|
// Best-effort: desktop treats task resume as a convenience preference.
|
|
})
|
|
},
|
|
[client, taskUiReady]
|
|
)
|
|
|
|
const toggleGitHubProjectFieldVisibility = useCallback(
|
|
(fieldId: string) => {
|
|
if (!githubProjectFieldVisibilityScope) {
|
|
return
|
|
}
|
|
setGithubProjectHiddenFieldIdsByView((current) => {
|
|
const hidden = new Set(current[githubProjectFieldVisibilityScope] ?? [])
|
|
if (hidden.has(fieldId)) {
|
|
hidden.delete(fieldId)
|
|
} else {
|
|
hidden.add(fieldId)
|
|
}
|
|
const next = { ...current }
|
|
if (hidden.size === 0) {
|
|
delete next[githubProjectFieldVisibilityScope]
|
|
} else {
|
|
next[githubProjectFieldVisibilityScope] = [...hidden]
|
|
}
|
|
persistTaskResumeState({ githubProjectHiddenFieldIdsByView: next })
|
|
return next
|
|
})
|
|
},
|
|
[githubProjectFieldVisibilityScope, persistTaskResumeState]
|
|
)
|
|
|
|
const persistTaskSource = useCallback(
|
|
(nextProvider: TaskProvider) => {
|
|
if (!client || !taskUiReady) {
|
|
return
|
|
}
|
|
void client.sendRequest('settings.update', { defaultTaskSource: nextProvider }).catch(() => {
|
|
// Best-effort: a failed settings write should not block switching views.
|
|
})
|
|
},
|
|
[client, taskUiReady]
|
|
)
|
|
|
|
const persistRepoSelection = useCallback(
|
|
(selection: Set<string>, allRepos: RepoSummary[]) => {
|
|
if (!client || !taskUiReady) {
|
|
return
|
|
}
|
|
const nextSelection =
|
|
selection.size === 0 || selection.size === allRepos.length ? null : [...selection]
|
|
defaultRepoSelectionRef.current = nextSelection
|
|
void client
|
|
.sendRequest('settings.update', { defaultRepoSelection: nextSelection })
|
|
.catch(() => {
|
|
// Best-effort: the in-memory repo picker already reflects the change.
|
|
})
|
|
},
|
|
[client, taskUiReady]
|
|
)
|
|
|
|
const persistDefaultGitHubPreset = useCallback(
|
|
(preset: GitHubPreset) => {
|
|
setDefaultGitHubPreset(preset)
|
|
if (!client || !taskUiReady) {
|
|
return
|
|
}
|
|
void client.sendRequest('settings.update', { defaultTaskViewPreset: preset }).catch(() => {
|
|
// Best-effort: the current session still uses the selected preset.
|
|
})
|
|
},
|
|
[client, taskUiReady]
|
|
)
|
|
|
|
const persistGitHubProjectSettings = useCallback(
|
|
(nextSettings: GitHubProjectSettings) => {
|
|
setGithubProjectSettings(nextSettings)
|
|
if (!client || !taskUiReady) {
|
|
return
|
|
}
|
|
void client.sendRequest('settings.update', { githubProjects: nextSettings }).catch(() => {
|
|
// Best-effort: project selection can still work for the current session.
|
|
})
|
|
},
|
|
[client, taskUiReady]
|
|
)
|
|
|
|
const persistSetupHookTrust = useCallback(
|
|
async (repoId: string, contentHash: string, alwaysTrust: boolean): Promise<void> => {
|
|
if (!client) {
|
|
return
|
|
}
|
|
const next = trustedOrcaHooksWithSetupApproval({
|
|
trust: trustedOrcaHooks,
|
|
repoId,
|
|
contentHash,
|
|
alwaysTrust
|
|
})
|
|
const response = await client.sendRequest('ui.set', { trustedOrcaHooks: next })
|
|
if (!isSuccess(response)) {
|
|
throw new Error(response.error.message)
|
|
}
|
|
setTrustedOrcaHooks(next)
|
|
},
|
|
[client, trustedOrcaHooks]
|
|
)
|
|
|
|
const resetWorkspaceCreateState = useCallback((): void => {
|
|
setWorkspaceRepoPickerItem(null)
|
|
setWorkspaceCreateDraft(null)
|
|
setWorkspaceNameDraft('')
|
|
setWorkspaceLastAutoName('')
|
|
setWorkspaceBranchAutoName('')
|
|
setWorkspaceBranchNameOverride(undefined)
|
|
setWorkspaceBaseBranch(null)
|
|
setWorkspaceBaseBranchQuery('')
|
|
setWorkspaceBaseBranchResults([])
|
|
setWorkspaceBaseBranchLoading(false)
|
|
setWorkspaceBaseBranchError('')
|
|
setWorkspaceSparsePresets([])
|
|
setWorkspaceSparsePresetsLoading(false)
|
|
setWorkspaceSparsePresetsLoaded(false)
|
|
setWorkspaceSparsePresetsError('')
|
|
setWorkspaceSparseReloadKey(0)
|
|
setWorkspaceSparsePresetId(null)
|
|
setWorkspaceSparseDraft(null)
|
|
setWorkspaceSparseSaving(false)
|
|
setWorkspaceAgent(null)
|
|
setWorkspaceAgentOverridden(false)
|
|
setWorkspaceDetectedAgentIds(null)
|
|
setWorkspaceSshState(null)
|
|
setWorkspaceSshConnecting(false)
|
|
setShowWorkspaceAgentPicker(false)
|
|
setShowWorkspaceCreateRepoPicker(false)
|
|
setShowWorkspaceAdvanced(false)
|
|
setShowWorkspaceBaseBranchPicker(false)
|
|
setShowWorkspaceSparsePicker(false)
|
|
setSetupPrompt(null)
|
|
setOrcaYamlTrustPrompt(null)
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
if (!client || connState !== 'connected') {
|
|
taskResumeRef.current = {}
|
|
defaultRepoSelectionRef.current = null
|
|
repoSelectionHydratedRef.current = false
|
|
setRuntimeTaskSettings({})
|
|
setTrustedOrcaHooks({})
|
|
setOrcaYamlTrustPrompt(null)
|
|
setGithubProjectHiddenFieldIdsByView({})
|
|
setTaskStateHydrated(false)
|
|
setTasksSupportState({ kind: 'unknown', client: null })
|
|
setShowLinearWorkspacePicker(false)
|
|
setShowLinearTeamPicker(false)
|
|
setShowLinearViewPicker(false)
|
|
setShowLinearGroupPicker(false)
|
|
setShowLinearOrderPicker(false)
|
|
setShowLinearDisplayPicker(false)
|
|
setShowLinearConnect(false)
|
|
setShowProviderPicker(false)
|
|
setShowGitHubKindPicker(false)
|
|
setShowGitHubPresetPicker(false)
|
|
setShowGitLabViewPicker(false)
|
|
setShowGitLabFilterPicker(false)
|
|
setShowLinearFilterPicker(false)
|
|
setShowSortPicker(false)
|
|
setShowRepoPicker(false)
|
|
setShowGitHubIssueSourcePicker(false)
|
|
setShowGitHubPagePicker(false)
|
|
setShowGitHubProjectPicker(false)
|
|
setShowGitHubProjectViewPicker(false)
|
|
setShowGitHubProjectSortPicker(false)
|
|
setShowGitHubProjectFieldsPicker(false)
|
|
setPendingGitHubProjectViewSelection(null)
|
|
setActionItem(null)
|
|
setProjectRowItem(null)
|
|
setProjectRepoNotInOrca(null)
|
|
setDetailPayload(null)
|
|
setProjectRowDetail(null)
|
|
setShowCreateTask(false)
|
|
setShowCreateTargetPicker(false)
|
|
setLinearStatusPickerItem(null)
|
|
setPendingHostedMerge(null)
|
|
setPendingProjectGitHubMerge(null)
|
|
setPendingHostedStateChange(null)
|
|
setMergeMethodTaskItem(null)
|
|
setMergeMethodProjectRow(null)
|
|
resetWorkspaceCreateState()
|
|
return
|
|
}
|
|
|
|
let stale = false
|
|
setTaskStateHydrated(false)
|
|
setTasksSupportState({ kind: 'unknown', client })
|
|
setShowLinearWorkspacePicker(false)
|
|
setShowLinearTeamPicker(false)
|
|
setShowLinearViewPicker(false)
|
|
setShowLinearGroupPicker(false)
|
|
setShowLinearOrderPicker(false)
|
|
setShowLinearDisplayPicker(false)
|
|
setShowLinearConnect(false)
|
|
setShowProviderPicker(false)
|
|
setShowGitHubKindPicker(false)
|
|
setShowGitHubPresetPicker(false)
|
|
setShowGitLabViewPicker(false)
|
|
setShowGitLabFilterPicker(false)
|
|
setShowLinearFilterPicker(false)
|
|
setShowSortPicker(false)
|
|
setShowRepoPicker(false)
|
|
setShowGitHubIssueSourcePicker(false)
|
|
setShowGitHubPagePicker(false)
|
|
setShowGitHubProjectPicker(false)
|
|
setShowGitHubProjectViewPicker(false)
|
|
setShowGitHubProjectSortPicker(false)
|
|
setShowGitHubProjectFieldsPicker(false)
|
|
setPendingGitHubProjectViewSelection(null)
|
|
setActionItem(null)
|
|
setProjectRowItem(null)
|
|
setProjectRepoNotInOrca(null)
|
|
setDetailPayload(null)
|
|
setProjectRowDetail(null)
|
|
setShowCreateTask(false)
|
|
setShowCreateTargetPicker(false)
|
|
setLinearStatusPickerItem(null)
|
|
setPendingHostedMerge(null)
|
|
setPendingProjectGitHubMerge(null)
|
|
setPendingHostedStateChange(null)
|
|
setMergeMethodTaskItem(null)
|
|
setMergeMethodProjectRow(null)
|
|
resetWorkspaceCreateState()
|
|
|
|
const hydrateTaskState = async (): Promise<void> => {
|
|
const statusResponse = await client.sendRequest('status.get')
|
|
if (stale) {
|
|
return
|
|
}
|
|
if (!isSuccess(statusResponse)) {
|
|
throw new Error(statusResponse.error.message)
|
|
}
|
|
const status = statusResponse.result as TaskRuntimeStatus
|
|
if (!status.capabilities?.includes(MOBILE_TASKS_CAPABILITY)) {
|
|
// Why: Tasks is additive RPC surface, so old desktop builds can still
|
|
// pair but must not receive the newer task-specific method calls.
|
|
setTasksSupportState({ kind: 'unsupported', client })
|
|
setItems([])
|
|
setGithubPages([])
|
|
setGithubProjectTable(null)
|
|
setShowLinearWorkspacePicker(false)
|
|
setShowLinearTeamPicker(false)
|
|
setShowLinearViewPicker(false)
|
|
setShowLinearGroupPicker(false)
|
|
setShowLinearOrderPicker(false)
|
|
setShowLinearDisplayPicker(false)
|
|
setShowLinearConnect(false)
|
|
setShowProviderPicker(false)
|
|
setShowGitHubKindPicker(false)
|
|
setShowGitHubPresetPicker(false)
|
|
setShowGitLabViewPicker(false)
|
|
setShowGitLabFilterPicker(false)
|
|
setShowLinearFilterPicker(false)
|
|
setShowSortPicker(false)
|
|
setShowRepoPicker(false)
|
|
setShowGitHubIssueSourcePicker(false)
|
|
setShowGitHubPagePicker(false)
|
|
setShowGitHubProjectPicker(false)
|
|
setShowGitHubProjectViewPicker(false)
|
|
setShowGitHubProjectSortPicker(false)
|
|
setShowGitHubProjectFieldsPicker(false)
|
|
setPendingGitHubProjectViewSelection(null)
|
|
setActionItem(null)
|
|
setProjectRowItem(null)
|
|
setProjectRepoNotInOrca(null)
|
|
setDetailPayload(null)
|
|
setProjectRowDetail(null)
|
|
setShowCreateTask(false)
|
|
setShowCreateTargetPicker(false)
|
|
setLinearStatusPickerItem(null)
|
|
setPendingHostedMerge(null)
|
|
setPendingProjectGitHubMerge(null)
|
|
setPendingHostedStateChange(null)
|
|
setMergeMethodTaskItem(null)
|
|
setMergeMethodProjectRow(null)
|
|
resetWorkspaceCreateState()
|
|
setError('Update Orca desktop to use Tasks on mobile.')
|
|
setTaskStateHydrated(false)
|
|
return
|
|
}
|
|
setTasksSupportState({ kind: 'supported', client })
|
|
setError('')
|
|
const [settingsResponse, uiResponse, preflightResponse, linearStatusResponse] =
|
|
await Promise.all([
|
|
client.sendRequest('settings.get'),
|
|
client.sendRequest('ui.get'),
|
|
client.sendRequest('preflight.check'),
|
|
client.sendRequest('linear.status')
|
|
])
|
|
if (stale) {
|
|
return
|
|
}
|
|
|
|
const settings = isSuccess(settingsResponse)
|
|
? (((settingsResponse.result as { settings?: RuntimeTaskSettings }).settings ??
|
|
{}) as RuntimeTaskSettings)
|
|
: {}
|
|
setRuntimeTaskSettings(settings)
|
|
const uiState = isSuccess(uiResponse)
|
|
? (
|
|
uiResponse.result as {
|
|
ui?: {
|
|
taskResumeState?: TaskResumeState
|
|
trustedOrcaHooks?: PersistedTrustedOrcaHooks
|
|
}
|
|
}
|
|
).ui
|
|
: null
|
|
setTrustedOrcaHooks(uiState?.trustedOrcaHooks ?? {})
|
|
const resume = uiState?.taskResumeState ?? {}
|
|
taskResumeRef.current = resume
|
|
setGithubProjectHiddenFieldIdsByView(resume.githubProjectHiddenFieldIdsByView ?? {})
|
|
|
|
const preflight = isSuccess(preflightResponse)
|
|
? (preflightResponse.result as { glab?: { installed?: boolean } })
|
|
: null
|
|
const linearStatus = isSuccess(linearStatusResponse)
|
|
? (linearStatusResponse.result as LinearStatusResponse)
|
|
: null
|
|
const preferredProviders = normalizeVisibleTaskProviders(settings.visibleTaskProviders)
|
|
const linearIsConnected = linearStatus?.connected === true
|
|
const availableProviders = filterAvailableTaskProviders(preferredProviders, {
|
|
gitlabInstalled: preflight?.glab?.installed === true,
|
|
linearConnected: linearIsConnected
|
|
})
|
|
const nextVisibleProviders =
|
|
preferredProviders.includes('linear') && !availableProviders.includes('linear')
|
|
? [...availableProviders, 'linear' as const]
|
|
: availableProviders
|
|
setLinearConnected(linearIsConnected)
|
|
if (!linearIsConnected) {
|
|
setLinearWorkspaces([])
|
|
setLinearTeams([])
|
|
setSelectedLinearTeamIds(new Set())
|
|
setSelectedLinearWorkspaceId(null)
|
|
}
|
|
const nextProvider =
|
|
requestedTaskSource && nextVisibleProviders.includes(requestedTaskSource)
|
|
? requestedTaskSource
|
|
: resolveVisibleTaskProvider(
|
|
isTaskProvider(settings.defaultTaskSource) ? settings.defaultTaskSource : undefined,
|
|
nextVisibleProviders
|
|
)
|
|
const preset =
|
|
resume.githubItemsPreset === null
|
|
? normalizeGitHubPreset(settings.defaultTaskViewPreset)
|
|
: normalizeGitHubPreset(resume.githubItemsPreset ?? settings.defaultTaskViewPreset)
|
|
const defaultPreset = normalizeGitHubPreset(settings.defaultTaskViewPreset)
|
|
const githubQuery =
|
|
resume.githubItemsPreset === null
|
|
? (resume.githubItemsQuery ?? '')
|
|
: getTaskPresetQuery(preset)
|
|
const nextLinearFilter = normalizeLinearFilter(resume.linearPreset)
|
|
const nextLinearQuery = resume.linearQuery ?? ''
|
|
defaultRepoSelectionRef.current = settings.defaultRepoSelection ?? null
|
|
defaultLinearTeamSelectionRef.current = settings.defaultLinearTeamSelection ?? null
|
|
const nextQuery =
|
|
nextProvider === 'github' ? githubQuery : nextProvider === 'linear' ? nextLinearQuery : ''
|
|
const nextAppliedQuery =
|
|
nextProvider === 'github'
|
|
? scopeGitHubTaskSearch(githubQuery, githubKindFromQuery(githubQuery, preset))
|
|
: nextQuery
|
|
|
|
setVisibleProviders(nextVisibleProviders)
|
|
setProvider(nextProvider)
|
|
setGithubMode(resume.githubMode === 'project' ? 'project' : 'items')
|
|
setDefaultGitHubPreset(defaultPreset)
|
|
setGithubPreset(preset)
|
|
setGithubKind(githubKindFromQuery(githubQuery, preset))
|
|
setLinearFilter(nextLinearFilter)
|
|
setGithubProjectSettings(settings.githubProjects ?? EMPTY_GITHUB_PROJECT_SETTINGS)
|
|
setQuery(nextQuery)
|
|
setAppliedQuery(nextAppliedQuery)
|
|
setTaskStateHydrated(true)
|
|
}
|
|
|
|
void hydrateTaskState().catch((err) => {
|
|
if (stale) {
|
|
return
|
|
}
|
|
setError(err instanceof Error ? err.message : 'Failed to load Tasks settings')
|
|
setTaskStateHydrated(false)
|
|
})
|
|
|
|
return () => {
|
|
stale = true
|
|
}
|
|
}, [client, connState, requestedTaskSource, resetWorkspaceCreateState])
|
|
|
|
useEffect(() => {
|
|
if (visibleProviders.includes(provider)) {
|
|
return
|
|
}
|
|
setProvider(resolveVisibleTaskProvider(provider, visibleProviders))
|
|
}, [provider, visibleProviders])
|
|
|
|
const loadRepos = useCallback(async (): Promise<RepoSummary[]> => {
|
|
if (!client || connState !== 'connected') {
|
|
return []
|
|
}
|
|
const response = await client.sendRequest('repo.list')
|
|
if (!isSuccess(response)) {
|
|
throw new Error(response.error.message)
|
|
}
|
|
const result = response.result as { repos: RepoSummary[] }
|
|
reposRef.current = result.repos
|
|
setRepos(result.repos)
|
|
if (!repoSelectionHydratedRef.current) {
|
|
repoSelectionHydratedRef.current = true
|
|
setSelectedRepoIds(reconcileRepoSelection(result.repos, defaultRepoSelectionRef.current))
|
|
} else {
|
|
setSelectedRepoIds((current) => {
|
|
if (current.size === 0) {
|
|
return current
|
|
}
|
|
const availableIds = new Set(result.repos.filter(isHostedTaskRepo).map((repo) => repo.id))
|
|
const next = new Set([...current].filter((id) => availableIds.has(id)))
|
|
return next.size === current.size ? current : next
|
|
})
|
|
}
|
|
return result.repos
|
|
}, [client, connState])
|
|
|
|
const loadLinearContext = useCallback(async (): Promise<void> => {
|
|
if (!client || connState !== 'connected' || !tasksSupported) {
|
|
return
|
|
}
|
|
const statusResponse = await client.sendRequest('linear.status')
|
|
if (!isSuccess(statusResponse)) {
|
|
throw new Error(statusResponse.error.message)
|
|
}
|
|
const status = statusResponse.result as LinearStatusResponse
|
|
setLinearConnected(status.connected === true)
|
|
if (status.connected !== true) {
|
|
setLinearWorkspaces([])
|
|
setLinearTeams([])
|
|
setSelectedLinearTeamIds(new Set())
|
|
setSelectedLinearWorkspaceId(null)
|
|
return
|
|
}
|
|
const workspaces = status.workspaces ?? []
|
|
const workspaceId =
|
|
status.selectedWorkspaceId ?? status.activeWorkspaceId ?? workspaces[0]?.id ?? null
|
|
setLinearWorkspaces(workspaces)
|
|
setSelectedLinearWorkspaceId(workspaceId)
|
|
|
|
const teamsResponse = await client.sendRequest('linear.listTeams', {
|
|
workspaceId: workspaceId ?? undefined
|
|
})
|
|
if (!isSuccess(teamsResponse)) {
|
|
throw new Error(teamsResponse.error.message)
|
|
}
|
|
const teams = teamsResponse.result as LinearTeam[]
|
|
setLinearTeams(teams)
|
|
setSelectedLinearTeamIds(reconcileTeamSelection(teams, defaultLinearTeamSelectionRef.current))
|
|
}, [client, connState, tasksSupported])
|
|
|
|
const persistLinearTeamSelection = useCallback(
|
|
(teamIds: Set<string>, allTeams: LinearTeam[]) => {
|
|
if (!client || !taskUiReady) {
|
|
return
|
|
}
|
|
const selection = teamIds.size === allTeams.length ? null : [...teamIds]
|
|
defaultLinearTeamSelectionRef.current = selection
|
|
void client
|
|
.sendRequest('settings.update', { defaultLinearTeamSelection: selection })
|
|
.catch(() => {
|
|
// Best-effort preference persistence; the local picker state already changed.
|
|
})
|
|
},
|
|
[client, taskUiReady]
|
|
)
|
|
|
|
const fetchGitHubItemsPage = useCallback(
|
|
async (
|
|
requestClient: RpcClient,
|
|
queriedRepos: RepoSummary[],
|
|
before?: string
|
|
): Promise<{
|
|
items: Array<Extract<TaskItem, { provider: 'github' }>>
|
|
failedCount: number
|
|
sourcesByRepoId: Record<string, GitHubRepoSources>
|
|
sourceErrors: GitHubIssueSourceError[]
|
|
sourceFallbacks: GitHubIssueSourceFallback[]
|
|
}> => {
|
|
const results = await mapWithConcurrency(
|
|
queriedRepos,
|
|
GITHUB_REPO_CONCURRENCY,
|
|
async (repo) => {
|
|
try {
|
|
const response = await requestClient.sendRequest('github.listWorkItems', {
|
|
repo: `id:${repo.id}`,
|
|
limit: PER_REPO_FETCH_LIMIT,
|
|
query: scopeGitHubTaskSearch(appliedQuery, githubKind),
|
|
before
|
|
})
|
|
if (!isSuccess(response)) {
|
|
throw new Error(response.error.message)
|
|
}
|
|
const envelope = response.result as {
|
|
items: Array<Omit<GitHubWorkItem, 'repoId' | 'repoName'>>
|
|
sources?: GitHubRepoSources
|
|
errors?: { issues?: { message: string } }
|
|
issueSourceFellBack?: true
|
|
}
|
|
return {
|
|
items: envelope.items.map((item) => createGitHubTask(repo, item)),
|
|
sources: envelope.sources,
|
|
sourceError: extractGitHubIssueSourceError(repo, envelope),
|
|
sourceFallback: extractGitHubIssueSourceFallback(repo, envelope),
|
|
repoId: repo.id
|
|
}
|
|
} catch (err) {
|
|
const isExpectedSshSkip = isGitHubWorkItemsSshRemoteRequiredError(err)
|
|
const logWorkItemFetchFailure = isExpectedSshSkip ? console.log : console.warn
|
|
logWorkItemFetchFailure(
|
|
'[mobile tasks] failed to fetch github work items',
|
|
repo.id,
|
|
isExpectedSshSkip && err instanceof Error ? err.message : err
|
|
)
|
|
return {
|
|
items: [] as Array<Extract<TaskItem, { provider: 'github' }>>,
|
|
repoId: repo.id,
|
|
error: err instanceof Error ? err.message : 'Failed to load GitHub tasks'
|
|
}
|
|
}
|
|
}
|
|
)
|
|
|
|
const sourcesByRepoId: Record<string, GitHubRepoSources> = {}
|
|
const sourceErrors: GitHubIssueSourceError[] = []
|
|
const sourceFallbacks: GitHubIssueSourceFallback[] = []
|
|
for (const result of results) {
|
|
if (result.sources) {
|
|
sourcesByRepoId[result.repoId] = result.sources
|
|
}
|
|
if (result.sourceError) {
|
|
sourceErrors.push(result.sourceError)
|
|
}
|
|
if (result.sourceFallback) {
|
|
sourceFallbacks.push(result.sourceFallback)
|
|
}
|
|
}
|
|
|
|
return {
|
|
items: results
|
|
.flatMap((result) => result.items)
|
|
.sort((a, b) => taskTime(b.updatedAt) - taskTime(a.updatedAt))
|
|
.slice(0, CROSS_REPO_DISPLAY_LIMIT),
|
|
failedCount: results.filter((result) => result.error).length,
|
|
sourcesByRepoId,
|
|
sourceErrors,
|
|
sourceFallbacks
|
|
}
|
|
},
|
|
[appliedQuery, githubKind]
|
|
)
|
|
|
|
const countGitHubItems = useCallback(
|
|
async (requestClient: RpcClient, queriedRepos: RepoSummary[]): Promise<number> => {
|
|
const counts = await mapWithConcurrency(
|
|
queriedRepos,
|
|
GITHUB_REPO_CONCURRENCY,
|
|
async (repo) => {
|
|
try {
|
|
const response = await requestClient.sendRequest(
|
|
'github.countWorkItems',
|
|
{
|
|
repo: `id:${repo.id}`,
|
|
query: scopeGitHubTaskSearch(appliedQuery, githubKind)
|
|
},
|
|
{ timeoutMs: 30_000 }
|
|
)
|
|
if (!isSuccess(response)) {
|
|
throw new Error(response.error.message)
|
|
}
|
|
return typeof response.result === 'number' ? response.result : 0
|
|
} catch (err) {
|
|
const isExpectedSshSkip = isGitHubWorkItemsSshRemoteRequiredError(err)
|
|
const logWorkItemCountFailure = isExpectedSshSkip ? console.log : console.warn
|
|
logWorkItemCountFailure(
|
|
'[mobile tasks] failed to count github work items',
|
|
repo.id,
|
|
isExpectedSshSkip && err instanceof Error ? err.message : err
|
|
)
|
|
return 0
|
|
}
|
|
}
|
|
)
|
|
return counts.reduce((sum, count) => sum + count, 0)
|
|
},
|
|
[appliedQuery, githubKind]
|
|
)
|
|
|
|
const loadTasks = useCallback(
|
|
async (options: { silent?: boolean } = {}): Promise<void> => {
|
|
if (!client || connState !== 'connected' || !tasksSupported || !taskStateHydrated) {
|
|
return
|
|
}
|
|
const generation = loadGenerationRef.current + 1
|
|
loadGenerationRef.current = generation
|
|
const requestClient = client
|
|
const isCurrent = () =>
|
|
loadGenerationRef.current === generation && clientRef.current === requestClient
|
|
setError('')
|
|
if (options.silent) {
|
|
setRefreshing(true)
|
|
} else {
|
|
setLoading(true)
|
|
}
|
|
try {
|
|
if (provider !== 'github' || githubMode !== 'items') {
|
|
setGithubPages([])
|
|
setGithubCurrentPage(0)
|
|
setGithubTotalCount(null)
|
|
setGithubSourceErrors([])
|
|
setGithubSourceFallbacks([])
|
|
}
|
|
if (provider === 'github' && githubMode === 'project') {
|
|
setItems([])
|
|
return
|
|
}
|
|
if (provider === 'linear' && !linearConnected) {
|
|
setItems([])
|
|
return
|
|
}
|
|
const currentRepos = reposRef.current.length > 0 ? reposRef.current : await loadRepos()
|
|
if (!isCurrent()) {
|
|
return
|
|
}
|
|
if (provider === 'github' || provider === 'gitlab') {
|
|
const supportedRepos = currentRepos.filter(isHostedTaskRepo)
|
|
const queriedRepos =
|
|
selectedRepoIds.size === 0
|
|
? supportedRepos
|
|
: supportedRepos.filter((repo) => selectedRepoIds.has(repo.id))
|
|
if (queriedRepos.length === 0) {
|
|
if (!isCurrent()) {
|
|
return
|
|
}
|
|
setItems([])
|
|
setGithubPages([])
|
|
setGithubCurrentPage(0)
|
|
setGithubTotalCount(null)
|
|
setGithubSourceErrors([])
|
|
setGithubSourceFallbacks([])
|
|
return
|
|
}
|
|
if (provider === 'github') {
|
|
const page = await fetchGitHubItemsPage(requestClient, queriedRepos)
|
|
if (!isCurrent()) {
|
|
return
|
|
}
|
|
setGithubRepoSources((current) => ({ ...current, ...page.sourcesByRepoId }))
|
|
setGithubSourceErrors(page.sourceErrors)
|
|
setGithubSourceFallbacks(page.sourceFallbacks)
|
|
if (page.failedCount === queriedRepos.length) {
|
|
throw new Error('Failed to load GitHub tasks')
|
|
}
|
|
setGithubPages([page.items])
|
|
setGithubCurrentPage(0)
|
|
setItems(page.items)
|
|
if (selectedRepoIds.size > 0) {
|
|
void countGitHubItems(requestClient, queriedRepos).then((count) => {
|
|
if (isCurrent()) {
|
|
setGithubTotalCount(count)
|
|
}
|
|
})
|
|
} else {
|
|
// Why: the default all-repos view must not spend GitHub Search
|
|
// quota on totals before the user narrows the repository scope.
|
|
setGithubTotalCount(null)
|
|
}
|
|
if (page.failedCount > 0) {
|
|
setError(buildPartialRepositoryNotice(page.failedCount, queriedRepos.length))
|
|
} else {
|
|
setError('')
|
|
}
|
|
return
|
|
}
|
|
if (provider === 'gitlab' && gitlabView === 'todos') {
|
|
setGithubPages([])
|
|
setGithubCurrentPage(0)
|
|
setGithubTotalCount(null)
|
|
setGithubSourceErrors([])
|
|
setGithubSourceFallbacks([])
|
|
const response = await requestClient.sendRequest('gitlab.todos', {
|
|
repo: `id:${queriedRepos[0]!.id}`
|
|
})
|
|
if (!isSuccess(response)) {
|
|
throw new Error(response.error.message)
|
|
}
|
|
if (!isCurrent()) {
|
|
return
|
|
}
|
|
setItems(
|
|
((response.result as GitLabTodo[]) ?? [])
|
|
.map(createGitLabTodoTask)
|
|
.sort((a, b) => taskTime(b.updatedAt) - taskTime(a.updatedAt))
|
|
)
|
|
return
|
|
}
|
|
setGithubPages([])
|
|
setGithubCurrentPage(0)
|
|
setGithubTotalCount(null)
|
|
setGithubSourceErrors([])
|
|
setGithubSourceFallbacks([])
|
|
const results = await mapWithConcurrency(
|
|
queriedRepos,
|
|
GITHUB_REPO_CONCURRENCY,
|
|
async (repo) => {
|
|
try {
|
|
const response = await requestClient.sendRequest('gitlab.listWorkItems', {
|
|
repo: `id:${repo.id}`,
|
|
state: gitlabFilter,
|
|
page: 1,
|
|
perPage: GITLAB_PER_PAGE,
|
|
query: appliedQuery.trim() || undefined
|
|
})
|
|
if (!isSuccess(response)) {
|
|
throw new Error(response.error.message)
|
|
}
|
|
const envelope = response.result as {
|
|
items: Array<Omit<GitLabWorkItem, 'repoId' | 'repoName'>>
|
|
error?: { type?: string; message: string }
|
|
}
|
|
if (envelope.error?.type && envelope.error.type !== 'not_found') {
|
|
return { items: [], error: envelope.error.message }
|
|
}
|
|
return { items: envelope.items.map((item) => createGitLabTask(repo, item)) }
|
|
} catch (err) {
|
|
console.warn(`[mobile tasks] failed to fetch ${provider} work items`, repo.id, err)
|
|
return {
|
|
items: [] as TaskItem[],
|
|
error: err instanceof Error ? err.message : 'Failed to load GitLab tasks'
|
|
}
|
|
}
|
|
}
|
|
)
|
|
if (!isCurrent()) {
|
|
return
|
|
}
|
|
const failedCount = results.filter((result) => result.error).length
|
|
if (failedCount === queriedRepos.length) {
|
|
throw new Error(
|
|
results.find((result) => result.error)?.error ?? 'Failed to load GitLab tasks'
|
|
)
|
|
}
|
|
setItems(
|
|
results
|
|
.flatMap((result) => result.items)
|
|
.sort((a, b) => taskTime(b.updatedAt) - taskTime(a.updatedAt))
|
|
)
|
|
if (failedCount > 0) {
|
|
setError(buildPartialRepositoryNotice(failedCount, queriedRepos.length))
|
|
} else {
|
|
setError('')
|
|
}
|
|
} else {
|
|
const normalizedQuery = appliedQuery.trim()
|
|
const response = normalizedQuery
|
|
? await requestClient.sendRequest('linear.searchIssues', {
|
|
query: normalizedQuery,
|
|
limit: LINEAR_LIMIT,
|
|
workspaceId: selectedLinearWorkspaceId ?? undefined
|
|
})
|
|
: await requestClient.sendRequest('linear.listIssues', {
|
|
filter: linearFilter,
|
|
limit: LINEAR_LIMIT,
|
|
workspaceId: selectedLinearWorkspaceId ?? undefined
|
|
})
|
|
if (!isSuccess(response)) {
|
|
throw new Error(response.error.message)
|
|
}
|
|
const issues = extractLinearIssueReadItems(response.result)
|
|
const filtered =
|
|
selectedLinearTeamIds.size > 0
|
|
? issues.filter((issue) => selectedLinearTeamIds.has(issue.team.id))
|
|
: issues
|
|
const sorted = [...filtered].sort((a, b) => compareLinearIssues(a, b, linearOrderBy))
|
|
if (!isCurrent()) {
|
|
return
|
|
}
|
|
setItems(sorted.map(createLinearTask))
|
|
}
|
|
} catch (err) {
|
|
if (!isCurrent()) {
|
|
return
|
|
}
|
|
setItems([])
|
|
setGithubSourceErrors([])
|
|
setGithubSourceFallbacks([])
|
|
setError(err instanceof Error ? err.message : 'Failed to load tasks')
|
|
} finally {
|
|
if (isCurrent()) {
|
|
setLoading(false)
|
|
setRefreshing(false)
|
|
}
|
|
}
|
|
},
|
|
[
|
|
appliedQuery,
|
|
client,
|
|
connState,
|
|
countGitHubItems,
|
|
fetchGitHubItemsPage,
|
|
gitlabFilter,
|
|
gitlabView,
|
|
githubMode,
|
|
linearConnected,
|
|
linearFilter,
|
|
linearOrderBy,
|
|
loadRepos,
|
|
provider,
|
|
selectedLinearTeamIds,
|
|
selectedLinearWorkspaceId,
|
|
selectedRepoIds,
|
|
taskStateHydrated,
|
|
tasksSupported
|
|
]
|
|
)
|
|
|
|
const connectLinearAccount = useCallback(async (): Promise<void> => {
|
|
if (!client || connState !== 'connected' || !taskUiReady) {
|
|
return
|
|
}
|
|
const apiKey = linearApiKeyDraft.trim()
|
|
if (!apiKey || linearConnectState === 'connecting') {
|
|
return
|
|
}
|
|
setLinearConnectState('connecting')
|
|
setLinearConnectError('')
|
|
try {
|
|
const response = await client.sendRequest('linear.connect', { apiKey })
|
|
if (!isSuccess(response)) {
|
|
throw new Error(response.error.message)
|
|
}
|
|
const result = response.result as { ok?: boolean; error?: string }
|
|
if (result.ok === false) {
|
|
throw new Error(result.error ?? 'Failed to connect Linear')
|
|
}
|
|
setLinearApiKeyDraft('')
|
|
setLinearConnectState('idle')
|
|
setShowLinearConnect(false)
|
|
setLinearConnected(true)
|
|
setVisibleProviders((current) =>
|
|
current.includes('linear') ? current : [...current, 'linear']
|
|
)
|
|
setProvider('linear')
|
|
await loadLinearContext()
|
|
} catch (err) {
|
|
setLinearConnectState('error')
|
|
setLinearConnectError(err instanceof Error ? err.message : 'Connection failed')
|
|
}
|
|
}, [client, connState, linearApiKeyDraft, linearConnectState, loadLinearContext, taskUiReady])
|
|
|
|
const retryGitHubIssueSourceFetch = useCallback(
|
|
async (repoPath: string): Promise<void> => {
|
|
setRetryingGithubSourceRepoPaths((current) => {
|
|
const next = new Set(current)
|
|
next.add(repoPath)
|
|
return next
|
|
})
|
|
try {
|
|
// Why: desktop retries through the shared list refresh path so source
|
|
// errors, rows, counts, and pagination reset from one authoritative fetch.
|
|
await loadTasks({ silent: true })
|
|
} finally {
|
|
setRetryingGithubSourceRepoPaths((current) => {
|
|
const next = new Set(current)
|
|
next.delete(repoPath)
|
|
return next
|
|
})
|
|
}
|
|
},
|
|
[loadTasks]
|
|
)
|
|
|
|
const githubTotalPages = useMemo(() => {
|
|
const selectedRepoCount = Math.max(1, selectedHostedRepos.length)
|
|
const pageCapacity = Math.max(
|
|
1,
|
|
Math.min(CROSS_REPO_DISPLAY_LIMIT, selectedRepoCount * PER_REPO_FETCH_LIMIT)
|
|
)
|
|
if (githubTotalCount !== null) {
|
|
return Math.max(githubPages.length, Math.ceil(githubTotalCount / pageCapacity))
|
|
}
|
|
return githubPages.length
|
|
}, [githubPages.length, githubTotalCount, selectedHostedRepos.length])
|
|
const githubPageCapacity = useMemo(() => {
|
|
const selectedRepoCount = Math.max(1, selectedHostedRepos.length)
|
|
return Math.max(1, Math.min(CROSS_REPO_DISPLAY_LIMIT, selectedRepoCount * PER_REPO_FETCH_LIMIT))
|
|
}, [selectedHostedRepos.length])
|
|
const githubCanLoadUncountedNextPage =
|
|
githubTotalCount === null && (githubPages.at(-1)?.length ?? 0) >= githubPageCapacity
|
|
const githubCanShowPagination =
|
|
githubTotalPages > 1 || (githubPages.length > 0 && githubCanLoadUncountedNextPage)
|
|
const githubPagePickerPages = useMemo(() => {
|
|
const visible = new Set<number>()
|
|
const availablePages = Math.min(
|
|
githubTotalPages + (githubCanLoadUncountedNextPage ? 1 : 0),
|
|
githubPages.length + (githubCanLoadUncountedNextPage ? 1 : 0)
|
|
)
|
|
for (let index = 0; index < availablePages; index += 1) {
|
|
visible.add(index)
|
|
}
|
|
for (
|
|
let index = Math.max(0, githubCurrentPage - 2);
|
|
index <= Math.min(availablePages - 1, githubCurrentPage + 2);
|
|
index += 1
|
|
) {
|
|
visible.add(index)
|
|
}
|
|
return [...visible].sort((a, b) => a - b)
|
|
}, [githubCanLoadUncountedNextPage, githubCurrentPage, githubTotalPages])
|
|
|
|
const handleGitHubPageChange = useCallback(
|
|
async (targetPage: number): Promise<void> => {
|
|
if (
|
|
!client ||
|
|
!tasksSupported ||
|
|
targetPage < 0 ||
|
|
githubPaginationLoading ||
|
|
selectedHostedRepos.length === 0
|
|
) {
|
|
return
|
|
}
|
|
if (targetPage > githubPages.length) {
|
|
return
|
|
}
|
|
if (targetPage < githubPages.length) {
|
|
setGithubCurrentPage(targetPage)
|
|
setItems(githubPages[targetPage] ?? [])
|
|
return
|
|
}
|
|
const lastPage = githubPages[githubPages.length - 1]
|
|
const oldestItem = lastPage?.[lastPage.length - 1]
|
|
if (!oldestItem?.updatedAt) {
|
|
return
|
|
}
|
|
|
|
setGithubPaginationLoading(true)
|
|
setGithubLoadingTargetPage(targetPage)
|
|
try {
|
|
let cursor = oldestItem.updatedAt
|
|
let loadedPages = githubPages.length
|
|
const nextPages: Array<Extract<TaskItem, { provider: 'github' }>[]> = []
|
|
while (loadedPages <= targetPage) {
|
|
const page = await fetchGitHubItemsPage(client, selectedHostedRepos, cursor)
|
|
if (page.items.length === 0) {
|
|
break
|
|
}
|
|
if (page.failedCount > 0) {
|
|
setError(buildPartialRepositoryNotice(page.failedCount, selectedHostedRepos.length))
|
|
}
|
|
nextPages.push(page.items)
|
|
cursor = page.items[page.items.length - 1]!.updatedAt
|
|
loadedPages += 1
|
|
}
|
|
if (nextPages.length === 0) {
|
|
return
|
|
}
|
|
const allPages = [...githubPages, ...nextPages]
|
|
const nextPage = targetPage < loadedPages ? targetPage : loadedPages - 1
|
|
setGithubPages(allPages)
|
|
setGithubCurrentPage(nextPage)
|
|
setItems(allPages[nextPage] ?? [])
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Failed to load more GitHub tasks')
|
|
} finally {
|
|
setGithubPaginationLoading(false)
|
|
setGithubLoadingTargetPage(null)
|
|
}
|
|
},
|
|
[
|
|
client,
|
|
fetchGitHubItemsPage,
|
|
githubPages,
|
|
githubPaginationLoading,
|
|
selectedHostedRepos,
|
|
tasksSupported
|
|
]
|
|
)
|
|
|
|
const loadGitHubProjects = useCallback(async (): Promise<void> => {
|
|
if (!client || connState !== 'connected' || !tasksSupported) {
|
|
return
|
|
}
|
|
setGithubProjectError('')
|
|
setGithubProjectPartialFailures([])
|
|
const response = await client.sendRequest('github.project.listAccessible', {
|
|
host: 'github.com'
|
|
})
|
|
if (!isSuccess(response)) {
|
|
throw new Error(response.error.message)
|
|
}
|
|
const result = response.result as
|
|
| {
|
|
ok: true
|
|
projects: GitHubProjectSummary[]
|
|
partialFailures?: GitHubProjectPartialFailure[]
|
|
}
|
|
| { ok: false; error: { message: string } }
|
|
if (!result.ok) {
|
|
throw new Error(result.error.message)
|
|
}
|
|
setGithubProjects(result.projects)
|
|
setGithubProjectPartialFailures(result.partialFailures ?? [])
|
|
}, [client, connState, tasksSupported])
|
|
|
|
const loadGitHubProjectViews = useCallback(
|
|
async (project: GitHubProjectRef): Promise<GitHubProjectViewSummary[]> => {
|
|
if (!client || connState !== 'connected' || !tasksSupported || !taskStateHydrated) {
|
|
return []
|
|
}
|
|
const response = await client.sendRequest('github.project.listViews', {
|
|
owner: project.owner,
|
|
host: githubProjectHost(project.host),
|
|
ownerType: project.ownerType,
|
|
projectNumber: project.number
|
|
})
|
|
if (!isSuccess(response)) {
|
|
throw new Error(response.error.message)
|
|
}
|
|
const result = response.result as
|
|
| { ok: true; views: GitHubProjectViewSummary[] }
|
|
| { ok: false; error: { message: string } }
|
|
if (!result.ok) {
|
|
throw new Error(result.error.message)
|
|
}
|
|
setGithubProjectViews(result.views)
|
|
return result.views
|
|
},
|
|
[client, connState, taskStateHydrated, tasksSupported]
|
|
)
|
|
|
|
const loadGitHubProjectTable = useCallback(
|
|
async (options: { force?: boolean; queryOverride?: string } = {}): Promise<void> => {
|
|
if (
|
|
!client ||
|
|
connState !== 'connected' ||
|
|
!tasksSupported ||
|
|
!activeGitHubProject ||
|
|
!activeGitHubProjectViewId
|
|
) {
|
|
setGithubProjectTable(null)
|
|
return
|
|
}
|
|
setGithubProjectLoading(true)
|
|
setGithubProjectError('')
|
|
try {
|
|
const response = await client.sendRequest(
|
|
'github.project.viewTable',
|
|
{
|
|
owner: activeGitHubProject.owner,
|
|
host: activeGitHubProjectHost,
|
|
ownerType: activeGitHubProject.ownerType,
|
|
projectNumber: activeGitHubProject.number,
|
|
viewId: activeGitHubProjectViewId,
|
|
...(options.queryOverride !== undefined ? { queryOverride: options.queryOverride } : {})
|
|
},
|
|
{ timeoutMs: 60_000 }
|
|
)
|
|
if (!isSuccess(response)) {
|
|
throw new Error(response.error.message)
|
|
}
|
|
const result = response.result as
|
|
| { ok: true; data: GitHubProjectTable }
|
|
| { ok: false; error: { message: string }; totalCount?: number }
|
|
if (!result.ok) {
|
|
throw new Error(result.error.message)
|
|
}
|
|
setGithubProjectTable(result.data)
|
|
setGithubProjectSearch(options.queryOverride ?? result.data.selectedView.filter ?? '')
|
|
setGithubProjectViews((current) =>
|
|
current.some((view) => view.id === result.data.selectedView.id)
|
|
? current
|
|
: [
|
|
...current,
|
|
{
|
|
id: result.data.selectedView.id,
|
|
number: result.data.selectedView.number,
|
|
name: result.data.selectedView.name,
|
|
layout: result.data.selectedView.layout
|
|
}
|
|
]
|
|
)
|
|
} catch (err) {
|
|
setGithubProjectTable(null)
|
|
setGithubProjectError(err instanceof Error ? err.message : 'Failed to load project view')
|
|
} finally {
|
|
setGithubProjectLoading(false)
|
|
}
|
|
},
|
|
[
|
|
activeGitHubProject,
|
|
activeGitHubProjectHost,
|
|
activeGitHubProjectViewId,
|
|
client,
|
|
connState,
|
|
tasksSupported
|
|
]
|
|
)
|
|
|
|
const commitGitHubProjectView = useCallback(
|
|
(project: GitHubProjectRef, viewId: string): void => {
|
|
const projectKey = githubProjectKey(project)
|
|
const nextSettings: GitHubProjectSettings = {
|
|
...githubProjectSettings,
|
|
recent: [
|
|
{ ...project, lastOpenedAt: new Date().toISOString() },
|
|
...githubProjectSettings.recent.filter((entry) => githubProjectKey(entry) !== projectKey)
|
|
].slice(0, 10),
|
|
lastViewByProject: {
|
|
...githubProjectSettings.lastViewByProject,
|
|
[projectKey]: { viewId }
|
|
},
|
|
activeProject: project
|
|
}
|
|
persistGitHubProjectSettings(nextSettings)
|
|
setAppliedGithubProjectSearch(undefined)
|
|
setGithubProjectSearch('')
|
|
setGithubProjectTable(null)
|
|
},
|
|
[githubProjectSettings, persistGitHubProjectSettings]
|
|
)
|
|
|
|
const selectGitHubProject = useCallback(
|
|
async (project: GitHubProjectRef, options: { viewNumber?: number } = {}): Promise<void> => {
|
|
if (!tasksSupported || !taskStateHydrated) {
|
|
return
|
|
}
|
|
setGithubProjectLoading(true)
|
|
setGithubProjectError('')
|
|
try {
|
|
const views = await loadGitHubProjectViews(project)
|
|
const projectKey = githubProjectKey(project)
|
|
const rememberedView = githubProjectSettings.lastViewByProject[projectKey]?.viewId
|
|
const explicitView =
|
|
typeof options.viewNumber === 'number'
|
|
? views.find((view) => view.number === options.viewNumber)
|
|
: undefined
|
|
if (options.viewNumber !== undefined && !explicitView) {
|
|
// Why: desktop treats stale /views/{n} URLs as a prompt to choose a
|
|
// replacement view, not as a failed project selection.
|
|
const supportedViews = views.filter((view) => view.layout === 'TABLE_LAYOUT')
|
|
if (supportedViews.length === 0) {
|
|
throw new Error('This project has no supported views.')
|
|
}
|
|
setPendingGitHubProjectViewSelection(project)
|
|
setShowGitHubProjectViewPicker(true)
|
|
return
|
|
}
|
|
if (explicitView && explicitView.layout !== 'TABLE_LAYOUT') {
|
|
throw new Error("Orca doesn't support this GitHub Project layout yet.")
|
|
}
|
|
if (!explicitView && !rememberedView) {
|
|
// Why: desktop asks which Project view to open the first time a project
|
|
// is selected. Mobile should not silently choose the first table view.
|
|
const supportedViews = views.filter((view) => view.layout === 'TABLE_LAYOUT')
|
|
if (supportedViews.length === 0) {
|
|
throw new Error('This project has no supported views.')
|
|
}
|
|
setPendingGitHubProjectViewSelection(project)
|
|
setShowGitHubProjectViewPicker(true)
|
|
return
|
|
}
|
|
const selectedView =
|
|
explicitView ??
|
|
views.find((view) => view.id === rememberedView && view.layout === 'TABLE_LAYOUT') ??
|
|
undefined
|
|
if (!selectedView) {
|
|
throw new Error('This project has no supported views.')
|
|
}
|
|
commitGitHubProjectView(project, selectedView.id)
|
|
} catch (err) {
|
|
setGithubProjectError(err instanceof Error ? err.message : 'Failed to select project')
|
|
} finally {
|
|
setGithubProjectLoading(false)
|
|
}
|
|
},
|
|
[
|
|
commitGitHubProjectView,
|
|
githubProjectSettings,
|
|
loadGitHubProjectViews,
|
|
taskStateHydrated,
|
|
tasksSupported
|
|
]
|
|
)
|
|
|
|
const resolveGitHubProjectFromInput = useCallback(async (): Promise<void> => {
|
|
if (!client || connState !== 'connected' || !tasksSupported || !taskStateHydrated) {
|
|
return
|
|
}
|
|
const input = githubProjectPasteInput.trim()
|
|
const parsed = parseProjectInput(input)
|
|
if (!parsed) {
|
|
setGithubProjectPasteError('Expected a project URL or owner/number.')
|
|
return
|
|
}
|
|
setGithubProjectPasteBusy(true)
|
|
setGithubProjectPasteError('')
|
|
setGithubProjectError('')
|
|
try {
|
|
const response = await client.sendRequest('github.project.resolveRef', {
|
|
input,
|
|
host: githubProjectHost(parsed.host)
|
|
})
|
|
if (!isSuccess(response)) {
|
|
throw new Error(response.error.message)
|
|
}
|
|
const result = response.result as
|
|
| {
|
|
ok: true
|
|
owner: string
|
|
ownerType: GitHubProjectOwnerType
|
|
number: number
|
|
title: string
|
|
host?: string
|
|
viewNumber?: number
|
|
}
|
|
| { ok: false; error: { message: string } }
|
|
if (!result.ok) {
|
|
setGithubProjectPasteError(result.error.message)
|
|
return
|
|
}
|
|
setGithubProjectPasteInput('')
|
|
setShowGitHubProjectPicker(false)
|
|
await selectGitHubProject(
|
|
{
|
|
owner: result.owner,
|
|
ownerType: result.ownerType,
|
|
number: result.number,
|
|
host: githubProjectHost(result.host ?? parsed.host)
|
|
},
|
|
{ viewNumber: result.viewNumber }
|
|
)
|
|
} catch (err) {
|
|
setGithubProjectPasteError(err instanceof Error ? err.message : 'Failed to add project.')
|
|
} finally {
|
|
setGithubProjectPasteBusy(false)
|
|
}
|
|
}, [
|
|
client,
|
|
connState,
|
|
githubProjectPasteInput,
|
|
selectGitHubProject,
|
|
taskStateHydrated,
|
|
tasksSupported
|
|
])
|
|
|
|
useEffect(() => {
|
|
if (!taskStateHydrated) {
|
|
return
|
|
}
|
|
const timer = setTimeout(() => {
|
|
setAppliedQuery(
|
|
provider === 'github' ? scopeGitHubTaskSearch(query, githubKind) : query.trim()
|
|
)
|
|
}, 300)
|
|
return () => clearTimeout(timer)
|
|
}, [githubKind, provider, query, taskStateHydrated])
|
|
|
|
const setTaskCopyFeedbackRootRef = useCallback((node: View | null): void => {
|
|
if (node !== null) {
|
|
return
|
|
}
|
|
// Why: copied-link feedback is screen-local; clear the pending reset when
|
|
// the Tasks screen detaches without a passive cleanup-only Effect.
|
|
clearMobileTaskCopyFeedbackTimer(copiedLinkResetTimerRef)
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
if (!taskUiReady || provider !== 'github' || githubMode !== 'items') {
|
|
return
|
|
}
|
|
const trimmed = appliedQuery.trim()
|
|
persistTaskResumeState({
|
|
githubMode: 'items',
|
|
githubItemsPreset: trimmed === getTaskPresetQuery(githubPreset) ? githubPreset : null,
|
|
githubItemsQuery: trimmed
|
|
})
|
|
}, [appliedQuery, githubMode, githubPreset, persistTaskResumeState, provider, taskUiReady])
|
|
|
|
useEffect(() => {
|
|
if (!taskUiReady || provider !== 'linear') {
|
|
return
|
|
}
|
|
persistTaskResumeState({
|
|
linearPreset: linearFilter,
|
|
linearQuery: appliedQuery.trim()
|
|
})
|
|
}, [appliedQuery, linearFilter, persistTaskResumeState, provider, taskUiReady])
|
|
|
|
useEffect(() => {
|
|
if (connState !== 'connected' || !taskStateHydrated) {
|
|
return
|
|
}
|
|
void loadTasks()
|
|
}, [connState, loadTasks, taskStateHydrated])
|
|
|
|
useEffect(() => {
|
|
if (!taskStateHydrated || provider !== 'linear' || !linearConnected) {
|
|
return
|
|
}
|
|
void loadLinearContext().catch((err) => {
|
|
setError(err instanceof Error ? err.message : 'Failed to load Linear context')
|
|
})
|
|
}, [linearConnected, loadLinearContext, provider, taskStateHydrated])
|
|
|
|
useEffect(() => {
|
|
if (!taskUiReady || provider !== 'github' || githubMode !== 'project') {
|
|
return
|
|
}
|
|
persistTaskResumeState({ githubMode: 'project' })
|
|
if (activeGitHubProject && activeGitHubProjectViewId) {
|
|
void loadGitHubProjectTable({ queryOverride: appliedGithubProjectSearch })
|
|
} else if (activeGitHubProject) {
|
|
void selectGitHubProject(activeGitHubProject)
|
|
} else {
|
|
void loadGitHubProjects().catch((err) => {
|
|
setGithubProjectError(err instanceof Error ? err.message : 'Failed to load projects')
|
|
})
|
|
}
|
|
}, [
|
|
activeGitHubProject,
|
|
activeGitHubProjectViewId,
|
|
appliedGithubProjectSearch,
|
|
githubMode,
|
|
loadGitHubProjectTable,
|
|
loadGitHubProjects,
|
|
persistTaskResumeState,
|
|
provider,
|
|
selectGitHubProject,
|
|
taskUiReady
|
|
])
|
|
|
|
useEffect(() => {
|
|
if (!taskUiReady || !showGitHubProjectPicker) {
|
|
return
|
|
}
|
|
void loadGitHubProjects().catch((err) => {
|
|
setGithubProjectError(err instanceof Error ? err.message : 'Failed to load projects')
|
|
})
|
|
}, [loadGitHubProjects, showGitHubProjectPicker, taskUiReady])
|
|
|
|
useEffect(() => {
|
|
if (!tasksSupported || !taskStateHydrated || !showCreateTask) {
|
|
return
|
|
}
|
|
setCreatingTask(false)
|
|
if (provider === 'github' || provider === 'gitlab') {
|
|
setCreateRepoId((current) =>
|
|
current && hostedRepos.some((repo) => repo.id === current)
|
|
? current
|
|
: (hostedRepos[0]?.id ?? null)
|
|
)
|
|
return
|
|
}
|
|
if (!client) {
|
|
return
|
|
}
|
|
let stale = false
|
|
setCreateTeamId(null)
|
|
void client
|
|
.sendRequest('linear.listTeams')
|
|
.then((response) => {
|
|
if (stale) {
|
|
return
|
|
}
|
|
if (isSuccess(response)) {
|
|
const teams = response.result as LinearTeam[]
|
|
setLinearTeams(teams)
|
|
setCreateTeamId((current) => current ?? teams[0]?.id ?? null)
|
|
} else {
|
|
setLinearTeams([])
|
|
setCreateTeamId(null)
|
|
}
|
|
})
|
|
.catch(() => {
|
|
if (!stale) {
|
|
setLinearTeams([])
|
|
setCreateTeamId(null)
|
|
}
|
|
})
|
|
return () => {
|
|
stale = true
|
|
}
|
|
}, [client, hostedRepos, provider, showCreateTask, taskStateHydrated, tasksSupported])
|
|
|
|
useEffect(() => {
|
|
if (!tasksSupported || !linearMetadataItem || !client) {
|
|
setLinearStates([])
|
|
setLinearCommentDraft('')
|
|
setLinearSubIssueTitle('')
|
|
return
|
|
}
|
|
let stale = false
|
|
setLinearStatesLoading(true)
|
|
setLinearCommentDraft('')
|
|
setLinearSubIssueTitle('')
|
|
const baseParams = {
|
|
teamId: linearMetadataItem.source.team.id,
|
|
workspaceId: linearMetadataItem.source.workspaceId
|
|
}
|
|
void client
|
|
.sendRequest('linear.teamStates', baseParams)
|
|
.then((statesResponse) => {
|
|
if (stale) {
|
|
return
|
|
}
|
|
if (isSuccess(statesResponse)) {
|
|
setLinearStates(statesResponse.result as LinearState[])
|
|
} else {
|
|
setLinearStates([])
|
|
}
|
|
})
|
|
.catch(() => {
|
|
if (!stale) {
|
|
setLinearStates([])
|
|
}
|
|
})
|
|
.finally(() => {
|
|
if (!stale) {
|
|
setLinearStatesLoading(false)
|
|
}
|
|
})
|
|
return () => {
|
|
stale = true
|
|
}
|
|
}, [client, linearMetadataItem, tasksSupported])
|
|
|
|
useEffect(() => {
|
|
if (!actionItem) {
|
|
setItemTitleDraft('')
|
|
setItemBodyDraft('')
|
|
setItemCommentDraft('')
|
|
setItemAddLabelsDraft('')
|
|
setItemRemoveLabelsDraft('')
|
|
setItemAddAssigneesDraft('')
|
|
setItemRemoveAssigneesDraft('')
|
|
setItemReviewersDraft('')
|
|
setItemReplyDrafts({})
|
|
setExpandedPrFilePath(null)
|
|
setPrFileContents({})
|
|
setPrFileLoadingPath(null)
|
|
setPrFileCommentDrafts({})
|
|
setExpandedResolvedCommentGroups(new Set())
|
|
return
|
|
}
|
|
setItemTitleDraft(actionItem.title)
|
|
setItemBodyDraft('')
|
|
setItemCommentDraft('')
|
|
setItemAddLabelsDraft('')
|
|
setItemRemoveLabelsDraft('')
|
|
setItemAddAssigneesDraft('')
|
|
setItemRemoveAssigneesDraft('')
|
|
setItemReviewersDraft('')
|
|
setItemReplyDrafts({})
|
|
setExpandedPrFilePath(null)
|
|
setPrFileContents({})
|
|
setPrFileLoadingPath(null)
|
|
setPrFileCommentDrafts({})
|
|
setExpandedResolvedCommentGroups(new Set())
|
|
}, [actionItem])
|
|
|
|
useEffect(() => {
|
|
if (!detailPayload) {
|
|
setItemBodyDraft('')
|
|
return
|
|
}
|
|
setItemBodyDraft(
|
|
detailPayload.provider === 'linear' ? detailPayload.description : detailPayload.body
|
|
)
|
|
}, [detailPayload])
|
|
|
|
useEffect(() => {
|
|
if (!tasksSupported || !client || actionItem?.provider !== 'github') {
|
|
setItemAvailableLabels([])
|
|
setItemLabelsLoading(false)
|
|
setItemLabelsError('')
|
|
setItemAssignableUsers([])
|
|
setItemAssignableUsersLoading(false)
|
|
setItemAssignableUsersError('')
|
|
return
|
|
}
|
|
|
|
let stale = false
|
|
if (actionItem.source.type === 'issue' || actionItem.source.type === 'pr') {
|
|
setItemAvailableLabels([])
|
|
setItemLabelsError('')
|
|
setItemLabelsLoading(true)
|
|
void client
|
|
.sendRequest(
|
|
'github.listLabels',
|
|
{ repo: `id:${actionItem.source.repoId}` },
|
|
{ timeoutMs: 30_000 }
|
|
)
|
|
.then((response) => {
|
|
if (stale) {
|
|
return
|
|
}
|
|
if (!isSuccess(response)) {
|
|
throw new Error(response.error.message)
|
|
}
|
|
setItemAvailableLabels(response.result as string[])
|
|
})
|
|
.catch((err) => {
|
|
if (!stale) {
|
|
setItemLabelsError(err instanceof Error ? err.message : 'Failed to load labels')
|
|
}
|
|
})
|
|
.finally(() => {
|
|
if (!stale) {
|
|
setItemLabelsLoading(false)
|
|
}
|
|
})
|
|
} else {
|
|
setItemAvailableLabels([])
|
|
setItemLabelsLoading(false)
|
|
setItemLabelsError('')
|
|
}
|
|
|
|
setItemAssignableUsers([])
|
|
setItemAssignableUsersError('')
|
|
setItemAssignableUsersLoading(true)
|
|
void client
|
|
.sendRequest(
|
|
'github.listAssignableUsers',
|
|
{ repo: `id:${actionItem.source.repoId}` },
|
|
{ timeoutMs: 30_000 }
|
|
)
|
|
.then((response) => {
|
|
if (stale) {
|
|
return
|
|
}
|
|
if (!isSuccess(response)) {
|
|
throw new Error(response.error.message)
|
|
}
|
|
setItemAssignableUsers(response.result as GitHubAssignableUser[])
|
|
})
|
|
.catch((err) => {
|
|
if (!stale) {
|
|
setItemAssignableUsersError(
|
|
err instanceof Error ? err.message : 'Failed to load assignees'
|
|
)
|
|
}
|
|
})
|
|
.finally(() => {
|
|
if (!stale) {
|
|
setItemAssignableUsersLoading(false)
|
|
}
|
|
})
|
|
|
|
return () => {
|
|
stale = true
|
|
}
|
|
}, [actionItem, client, tasksSupported])
|
|
|
|
useEffect(() => {
|
|
if (!tasksSupported || !actionItem || !client) {
|
|
setDetailPayload(null)
|
|
setDetailLoading(false)
|
|
setDetailError('')
|
|
return
|
|
}
|
|
|
|
let stale = false
|
|
setDetailPayload(null)
|
|
setDetailError('')
|
|
setDetailLoading(true)
|
|
|
|
const loadDetails = async (): Promise<void> => {
|
|
if (actionItem.provider === 'github') {
|
|
const response = await client.sendRequest(
|
|
'github.workItemDetails',
|
|
{
|
|
repo: `id:${actionItem.source.repoId}`,
|
|
number: actionItem.source.number,
|
|
type: actionItem.source.type
|
|
},
|
|
{ timeoutMs: 30_000 }
|
|
)
|
|
if (!isSuccess(response)) {
|
|
throw new Error(response.error.message)
|
|
}
|
|
const details = response.result as {
|
|
body?: string
|
|
comments?: DetailComment[]
|
|
item?: {
|
|
labels?: string[]
|
|
reviewDecision?: string | null
|
|
reviewRequests?: GitHubAssignableUser[]
|
|
latestReviews?: GitHubPRReviewSummary[]
|
|
}
|
|
assignees?: string[]
|
|
headSha?: string
|
|
baseSha?: string
|
|
pullRequestId?: string
|
|
checks?: GitHubDetailCheck[]
|
|
files?: Array<{
|
|
path: string
|
|
oldPath?: string
|
|
status?: GitHubDetailFile['status']
|
|
additions?: number
|
|
deletions?: number
|
|
isBinary?: boolean
|
|
viewerViewedState?: 'DISMISSED' | 'VIEWED' | 'UNVIEWED'
|
|
}>
|
|
} | null
|
|
if (!details) {
|
|
throw new Error('Details not found')
|
|
}
|
|
if (!stale) {
|
|
setDetailPayload({
|
|
provider: 'github',
|
|
body: details.body ?? '',
|
|
comments: details.comments ?? [],
|
|
labels: details.item?.labels ?? actionItem.source.labels,
|
|
assignees: details.assignees ?? [],
|
|
reviewDecision: details.item?.reviewDecision ?? actionItem.source.reviewDecision,
|
|
reviewRequests: details.item?.reviewRequests ?? actionItem.source.reviewRequests ?? [],
|
|
latestReviews: details.item?.latestReviews ?? actionItem.source.latestReviews ?? [],
|
|
headSha: details.headSha,
|
|
baseSha: details.baseSha,
|
|
pullRequestId: details.pullRequestId,
|
|
checks: details.checks ?? [],
|
|
files: details.files ?? []
|
|
})
|
|
}
|
|
return
|
|
}
|
|
|
|
if (actionItem.provider === 'gitlab') {
|
|
const response = await client.sendRequest(
|
|
'gitlab.workItemDetails',
|
|
{
|
|
repo: `id:${actionItem.source.repoId}`,
|
|
iid: actionItem.source.number,
|
|
type: actionItem.source.type,
|
|
projectRef: actionItem.source.projectRef
|
|
},
|
|
{ timeoutMs: 30_000 }
|
|
)
|
|
if (!isSuccess(response)) {
|
|
throw new Error(response.error.message)
|
|
}
|
|
const details = response.result as {
|
|
body?: string
|
|
comments?: DetailComment[]
|
|
item?: { labels?: string[]; mergeable?: 'MERGEABLE' | 'CONFLICTING' | 'UNKNOWN' }
|
|
assignees?: string[]
|
|
pipelineJobs?: Array<{
|
|
id?: number
|
|
name: string
|
|
stage: string
|
|
status: string
|
|
webUrl?: string | null
|
|
duration?: number | null
|
|
}>
|
|
reviewers?: unknown[]
|
|
approvalState?: { approvalsRequired: number | null; approvalsLeft: number | null }
|
|
} | null
|
|
if (!details) {
|
|
throw new Error('Details not found')
|
|
}
|
|
if (!stale) {
|
|
setDetailPayload({
|
|
provider: 'gitlab',
|
|
body: details.body ?? '',
|
|
comments: details.comments ?? [],
|
|
labels: details.item?.labels ?? actionItem.source.labels,
|
|
assignees: details.assignees ?? [],
|
|
pipelineJobs: details.pipelineJobs ?? []
|
|
})
|
|
const checksSummary = buildGitLabCheckSummary(details.pipelineJobs ?? [])
|
|
const reviewDecision: Exclude<HostedReviewDecision, null> | undefined =
|
|
details.approvalState?.approvalsRequired && details.approvalState.approvalsLeft === 0
|
|
? 'approved'
|
|
: details.approvalState?.approvalsLeft && details.approvalState.approvalsLeft > 0
|
|
? 'review_required'
|
|
: undefined
|
|
const hydratedStatus = {
|
|
...(details.item?.mergeable !== undefined ? { mergeable: details.item.mergeable } : {}),
|
|
...(reviewDecision !== undefined ? { reviewDecision } : {}),
|
|
...(details.reviewers !== undefined ? { reviewerCount: details.reviewers.length } : {})
|
|
}
|
|
setActionItem((current) =>
|
|
current?.provider === 'gitlab' && current.source.id === actionItem.source.id
|
|
? {
|
|
...current,
|
|
source: {
|
|
...current.source,
|
|
checksSummary,
|
|
...hydratedStatus
|
|
}
|
|
}
|
|
: current
|
|
)
|
|
setItems((current) =>
|
|
current.map((candidate) =>
|
|
candidate.provider === 'gitlab' && candidate.source.id === actionItem.source.id
|
|
? {
|
|
...candidate,
|
|
source: {
|
|
...candidate.source,
|
|
checksSummary,
|
|
...hydratedStatus
|
|
}
|
|
}
|
|
: candidate
|
|
)
|
|
)
|
|
}
|
|
return
|
|
}
|
|
|
|
const [issueResponse, commentsResponse] = await Promise.all([
|
|
client.sendRequest(
|
|
'linear.getIssue',
|
|
{
|
|
id: actionItem.source.id,
|
|
workspaceId: actionItem.source.workspaceId
|
|
},
|
|
{ timeoutMs: 30_000 }
|
|
),
|
|
client.sendRequest(
|
|
'linear.issueComments',
|
|
{
|
|
issueId: actionItem.source.id,
|
|
workspaceId: actionItem.source.workspaceId
|
|
},
|
|
{ timeoutMs: 30_000 }
|
|
)
|
|
])
|
|
if (!isSuccess(issueResponse)) {
|
|
throw new Error(issueResponse.error.message)
|
|
}
|
|
const issue = issueResponse.result as LinearIssue | null
|
|
const comments = isSuccess(commentsResponse)
|
|
? ((commentsResponse.result as DetailComment[]) ?? [])
|
|
: []
|
|
if (!issue) {
|
|
throw new Error('Details not found')
|
|
}
|
|
if (!stale) {
|
|
setDetailPayload({
|
|
provider: 'linear',
|
|
description: issue.description ?? '',
|
|
comments,
|
|
labels: issue.labels ?? [],
|
|
assignee: issue.assignee?.displayName,
|
|
project: issue.project,
|
|
children: issue.subIssues ?? []
|
|
})
|
|
setActionItem((current) => {
|
|
if (current?.provider !== 'linear' || current.source.id !== issue.id) {
|
|
return current
|
|
}
|
|
const currentChildren = current.source.subIssues ?? []
|
|
const nextChildren = issue.subIssues ?? []
|
|
const alreadyHydrated =
|
|
current.source.project?.id === issue.project?.id &&
|
|
currentChildren.length === nextChildren.length &&
|
|
currentChildren.every((child, index) => child.id === nextChildren[index]?.id)
|
|
return alreadyHydrated
|
|
? current
|
|
: (createLinearTask(issue) as Extract<TaskItem, { provider: 'linear' }>)
|
|
})
|
|
}
|
|
}
|
|
|
|
void loadDetails()
|
|
.catch((err) => {
|
|
if (!stale) {
|
|
setDetailError(err instanceof Error ? err.message : 'Failed to load details')
|
|
}
|
|
})
|
|
.finally(() => {
|
|
if (!stale) {
|
|
setDetailLoading(false)
|
|
}
|
|
})
|
|
|
|
return () => {
|
|
stale = true
|
|
}
|
|
}, [actionItem, client, detailRefreshSeq, tasksSupported])
|
|
|
|
useEffect(() => {
|
|
if (!projectRowItem) {
|
|
setProjectRowDetail(null)
|
|
setProjectRowDetailLoading(false)
|
|
setProjectRowDetailError('')
|
|
setProjectTitleDraft('')
|
|
setProjectBodyDraft('')
|
|
setProjectCommentDraft('')
|
|
setProjectEditingCommentId(null)
|
|
setProjectEditingCommentDraft('')
|
|
setProjectReviewersDraft('')
|
|
setExpandedPrFilePath(null)
|
|
setPrFileContents({})
|
|
setPrFileLoadingPath(null)
|
|
setPrFileCommentDrafts({})
|
|
setProjectFieldDrafts({})
|
|
return
|
|
}
|
|
|
|
const type = projectRowType(projectRowItem)
|
|
const slug = splitRepositorySlug(projectRowItem.content.repository)
|
|
setProjectTitleDraft(projectRowItem.content.title)
|
|
setProjectBodyDraft(projectRowItem.content.body ?? '')
|
|
setProjectCommentDraft('')
|
|
setProjectEditingCommentId(null)
|
|
setProjectEditingCommentDraft('')
|
|
setProjectReviewersDraft('')
|
|
setExpandedPrFilePath(null)
|
|
setPrFileContents({})
|
|
setPrFileLoadingPath(null)
|
|
setPrFileCommentDrafts({})
|
|
setProjectFieldDrafts(
|
|
Object.fromEntries(
|
|
editableProjectFields(githubProjectTable).map((field) => [
|
|
field.id,
|
|
projectFieldDraftValue(projectRowItem, field)
|
|
])
|
|
)
|
|
)
|
|
setProjectRowDetail(null)
|
|
setProjectRowDetailError('')
|
|
|
|
if (!tasksSupported || !client || !type || !slug || !projectRowItem.content.number) {
|
|
setProjectRowDetailLoading(false)
|
|
return
|
|
}
|
|
|
|
let stale = false
|
|
setProjectRowDetailLoading(true)
|
|
|
|
void client
|
|
.sendRequest(
|
|
'github.project.workItemDetailsBySlug',
|
|
{
|
|
owner: slug.owner,
|
|
repo: slug.repo,
|
|
host: activeGitHubProjectHost,
|
|
number: projectRowItem.content.number,
|
|
type
|
|
},
|
|
{ timeoutMs: 30_000 }
|
|
)
|
|
.then((response) => {
|
|
if (stale) {
|
|
return
|
|
}
|
|
if (!isSuccess(response)) {
|
|
throw new Error(response.error.message)
|
|
}
|
|
const result = response.result as
|
|
| {
|
|
ok: true
|
|
details: {
|
|
body?: string
|
|
comments?: DetailComment[]
|
|
item?: {
|
|
labels?: string[]
|
|
reviewDecision?: string | null
|
|
reviewRequests?: GitHubAssignableUser[]
|
|
latestReviews?: GitHubPRReviewSummary[]
|
|
}
|
|
assignees?: string[]
|
|
headSha?: string
|
|
baseSha?: string
|
|
pullRequestId?: string
|
|
checks?: GitHubDetailCheck[]
|
|
files?: Array<{
|
|
path: string
|
|
oldPath?: string
|
|
status?: GitHubDetailFile['status']
|
|
additions?: number
|
|
deletions?: number
|
|
isBinary?: boolean
|
|
viewerViewedState?: 'DISMISSED' | 'VIEWED' | 'UNVIEWED'
|
|
}>
|
|
}
|
|
}
|
|
| { ok: false; error: { message: string } }
|
|
if (!result.ok) {
|
|
throw new Error(result.error.message)
|
|
}
|
|
setProjectRowDetail({
|
|
provider: 'github',
|
|
body: result.details.body ?? '',
|
|
comments: result.details.comments ?? [],
|
|
labels: result.details.item?.labels ?? projectRowItem.content.labels.map((l) => l.name),
|
|
assignees: result.details.assignees ?? [],
|
|
reviewDecision: result.details.item?.reviewDecision,
|
|
reviewRequests: result.details.item?.reviewRequests ?? [],
|
|
latestReviews: result.details.item?.latestReviews ?? [],
|
|
headSha: result.details.headSha,
|
|
baseSha: result.details.baseSha,
|
|
pullRequestId: result.details.pullRequestId,
|
|
checks: result.details.checks ?? [],
|
|
files: result.details.files ?? []
|
|
})
|
|
})
|
|
.catch((err) => {
|
|
if (!stale) {
|
|
setProjectRowDetailError(err instanceof Error ? err.message : 'Failed to load details')
|
|
}
|
|
})
|
|
.finally(() => {
|
|
if (!stale) {
|
|
setProjectRowDetailLoading(false)
|
|
}
|
|
})
|
|
|
|
return () => {
|
|
stale = true
|
|
}
|
|
}, [
|
|
activeGitHubProjectHost,
|
|
client,
|
|
githubProjectTable,
|
|
projectRowDetailRefreshSeq,
|
|
projectRowItem,
|
|
tasksSupported
|
|
])
|
|
|
|
useEffect(() => {
|
|
const slug = splitRepositorySlug(projectMetadataRepository)
|
|
if (!tasksSupported || !client || !slug) {
|
|
setProjectAvailableLabels([])
|
|
setProjectLabelsLoading(false)
|
|
setProjectLabelsError('')
|
|
return
|
|
}
|
|
|
|
let stale = false
|
|
setProjectAvailableLabels([])
|
|
setProjectLabelsError('')
|
|
setProjectLabelsLoading(true)
|
|
void client
|
|
.sendRequest(
|
|
'github.project.listLabelsBySlug',
|
|
{ owner: slug.owner, repo: slug.repo, host: activeGitHubProjectHost },
|
|
{ timeoutMs: 30_000 }
|
|
)
|
|
.then((response) => {
|
|
if (stale) {
|
|
return
|
|
}
|
|
if (!isSuccess(response)) {
|
|
throw new Error(response.error.message)
|
|
}
|
|
const result = response.result as
|
|
| { ok: true; labels?: string[] }
|
|
| { ok: false; error?: { message?: string } }
|
|
if (!result.ok) {
|
|
throw new Error(result.error?.message ?? 'Failed to load labels')
|
|
}
|
|
setProjectAvailableLabels(result.labels ?? [])
|
|
})
|
|
.catch((err) => {
|
|
if (!stale) {
|
|
setProjectLabelsError(err instanceof Error ? err.message : 'Failed to load labels')
|
|
}
|
|
})
|
|
.finally(() => {
|
|
if (!stale) {
|
|
setProjectLabelsLoading(false)
|
|
}
|
|
})
|
|
|
|
return () => {
|
|
stale = true
|
|
}
|
|
}, [activeGitHubProjectHost, client, projectMetadataRepository, tasksSupported])
|
|
|
|
useEffect(() => {
|
|
const slug = splitRepositorySlug(projectMetadataRepository)
|
|
if (!tasksSupported || !client || !slug) {
|
|
setProjectAssignableUsers([])
|
|
setProjectAssignableUsersLoading(false)
|
|
setProjectAssignableUsersError('')
|
|
return
|
|
}
|
|
|
|
let stale = false
|
|
setProjectAssignableUsers([])
|
|
setProjectAssignableUsersError('')
|
|
setProjectAssignableUsersLoading(true)
|
|
void client
|
|
.sendRequest(
|
|
'github.project.listAssignableUsersBySlug',
|
|
{
|
|
owner: slug.owner,
|
|
repo: slug.repo,
|
|
host: activeGitHubProjectHost,
|
|
...(projectMetadataSeedLogins ? { seedLogins: projectMetadataSeedLogins.split(',') } : {})
|
|
},
|
|
{ timeoutMs: 30_000 }
|
|
)
|
|
.then((response) => {
|
|
if (stale) {
|
|
return
|
|
}
|
|
if (!isSuccess(response)) {
|
|
throw new Error(response.error.message)
|
|
}
|
|
const result = response.result as
|
|
| { ok: true; users?: GitHubAssignableUser[] }
|
|
| { ok: false; error?: { message?: string } }
|
|
if (!result.ok) {
|
|
throw new Error(result.error?.message ?? 'Failed to load assignees')
|
|
}
|
|
setProjectAssignableUsers(result.users ?? [])
|
|
})
|
|
.catch((err) => {
|
|
if (!stale) {
|
|
setProjectAssignableUsersError(
|
|
err instanceof Error ? err.message : 'Failed to load assignees'
|
|
)
|
|
}
|
|
})
|
|
.finally(() => {
|
|
if (!stale) {
|
|
setProjectAssignableUsersLoading(false)
|
|
}
|
|
})
|
|
|
|
return () => {
|
|
stale = true
|
|
}
|
|
}, [
|
|
activeGitHubProjectHost,
|
|
client,
|
|
projectMetadataRepository,
|
|
projectMetadataSeedLogins,
|
|
tasksSupported
|
|
])
|
|
|
|
useEffect(() => {
|
|
const slug = splitRepositorySlug(projectIssueTypeRepository)
|
|
if (!tasksSupported || !client || !slug) {
|
|
setProjectIssueTypes([])
|
|
setProjectIssueTypesLoading(false)
|
|
setProjectIssueTypesError('')
|
|
return
|
|
}
|
|
|
|
let stale = false
|
|
setProjectIssueTypes([])
|
|
setProjectIssueTypesError('')
|
|
setProjectIssueTypesLoading(true)
|
|
void client
|
|
.sendRequest(
|
|
'github.project.listIssueTypesBySlug',
|
|
{ owner: slug.owner, repo: slug.repo, host: activeGitHubProjectHost },
|
|
{ timeoutMs: 30_000 }
|
|
)
|
|
.then((response) => {
|
|
if (stale) {
|
|
return
|
|
}
|
|
if (!isSuccess(response)) {
|
|
throw new Error(response.error.message)
|
|
}
|
|
const result = response.result as
|
|
| { ok: true; types?: GitHubIssueType[] }
|
|
| { ok: false; error?: { message?: string } }
|
|
if (!result.ok) {
|
|
throw new Error(result.error?.message ?? 'Failed to load issue types')
|
|
}
|
|
setProjectIssueTypes(result.types ?? [])
|
|
})
|
|
.catch((err) => {
|
|
if (!stale) {
|
|
setProjectIssueTypesError(
|
|
err instanceof Error ? err.message : 'Failed to load issue types'
|
|
)
|
|
}
|
|
})
|
|
.finally(() => {
|
|
if (!stale) {
|
|
setProjectIssueTypesLoading(false)
|
|
}
|
|
})
|
|
|
|
return () => {
|
|
stale = true
|
|
}
|
|
}, [activeGitHubProjectHost, client, projectIssueTypeRepository, tasksSupported])
|
|
|
|
const getWorkspaceTargetRepo = useCallback(
|
|
(item: ActionableTaskItem, repoIdOverride?: string): RepoSummary | null => {
|
|
if (item.provider === 'github' || item.provider === 'gitlab') {
|
|
return repos.find((entry) => entry.id === item.source.repoId) ?? null
|
|
}
|
|
if (repoIdOverride) {
|
|
return workspaceRepos.find((entry) => entry.id === repoIdOverride) ?? null
|
|
}
|
|
return workspaceRepos[0] ?? null
|
|
},
|
|
[repos, workspaceRepos]
|
|
)
|
|
|
|
const workspaceCreateTargetRepo = useMemo(
|
|
() =>
|
|
workspaceCreateDraft
|
|
? getWorkspaceTargetRepo(workspaceCreateDraft.item, workspaceCreateDraft.repoIdOverride)
|
|
: null,
|
|
[getWorkspaceTargetRepo, workspaceCreateDraft]
|
|
)
|
|
const workspaceCreateTargetConnectionId = workspaceCreateTargetRepo?.connectionId ?? null
|
|
const workspaceCreateSshGate = deriveWorkspaceSshGate({
|
|
connectionId: workspaceCreateTargetConnectionId,
|
|
state: workspaceSshState,
|
|
connecting: workspaceSshConnecting
|
|
})
|
|
const workspaceCreateSshStatus = workspaceCreateSshGate.status
|
|
const workspaceCreateRequiresSshConnection = workspaceCreateSshGate.requiresConnection
|
|
const workspaceCreateSshConnectInProgress = workspaceCreateSshGate.connectInProgress
|
|
const workspaceCreateSshError = workspaceCreateSshGate.error
|
|
const workspaceCreateCanPickRepo =
|
|
workspaceCreateDraft?.item.provider === 'linear' && workspaceRepos.length > 1
|
|
const workspaceSparseCheckoutAvailable =
|
|
workspaceCreateTargetRepo != null && !workspaceCreateTargetRepo.connectionId
|
|
const workspaceSparseDraftParsed = useMemo(
|
|
() =>
|
|
workspaceSparseDraft
|
|
? parseSparsePresetDirectories(workspaceSparseDraft.directoriesText)
|
|
: null,
|
|
[workspaceSparseDraft]
|
|
)
|
|
const workspaceSparseDraftName = workspaceSparseDraft?.name.trim() ?? ''
|
|
const workspaceSparseDraftNameCollision =
|
|
workspaceSparseDraft && workspaceSparseDraftName
|
|
? (workspaceSparsePresets.find(
|
|
(preset) =>
|
|
preset.id !== workspaceSparseDraft.presetId &&
|
|
preset.name.toLowerCase() === workspaceSparseDraftName.toLowerCase()
|
|
) ?? null)
|
|
: null
|
|
const workspaceSparseDraftError =
|
|
workspaceSparseDraft && workspaceSparseDraftName.length === 0
|
|
? 'Name is required.'
|
|
: workspaceSparseDraftName.length > 80
|
|
? 'Name must be 80 characters or fewer.'
|
|
: workspaceSparseDraftNameCollision
|
|
? `"${workspaceSparseDraftNameCollision.name}" already exists.`
|
|
: (workspaceSparseDraftParsed?.error ?? null)
|
|
const canSaveWorkspaceSparseDraft =
|
|
workspaceSparseDraft !== null &&
|
|
workspaceCreateTargetRepo !== null &&
|
|
workspaceSparseCheckoutAvailable &&
|
|
workspaceSparsePresetsLoaded &&
|
|
!workspaceSparsePresetsLoading &&
|
|
!workspaceSparseSaving &&
|
|
!workspaceSparseDraftError &&
|
|
workspaceSparseDraftParsed !== null
|
|
|
|
const workspaceAgentOptions = useMemo<PickerOption<WorkspaceAgentChoice>[]>(() => {
|
|
const enabledAgents = filterWorkspaceAgents(
|
|
MOBILE_TUI_AGENT_AUTO_PICK_ORDER,
|
|
runtimeTaskSettings.disabledTuiAgents
|
|
)
|
|
const availableAgents =
|
|
workspaceDetectedAgentIds === null
|
|
? new Set<TuiAgent>(enabledAgents)
|
|
: new Set<TuiAgent>(enabledAgents.filter((agent) => workspaceDetectedAgentIds.has(agent)))
|
|
if (
|
|
workspaceAgent &&
|
|
workspaceAgent !== 'blank' &&
|
|
isWorkspaceAgentEnabled(workspaceAgent, runtimeTaskSettings.disabledTuiAgents) &&
|
|
(workspaceDetectedAgentIds === null || workspaceDetectedAgentIds.has(workspaceAgent))
|
|
) {
|
|
availableAgents.add(workspaceAgent)
|
|
}
|
|
const agents = MOBILE_TUI_AGENT_AUTO_PICK_ORDER.filter((agent) => availableAgents.has(agent))
|
|
return [
|
|
...agents.map((agent) => ({
|
|
value: agent,
|
|
label: workspaceAgentLabel(agent),
|
|
subtitle: agent,
|
|
renderIcon: () => <MobileAgentIcon agentId={workspaceAgentIconId(agent)} size={18} />
|
|
})),
|
|
{
|
|
value: 'blank' as const,
|
|
label: workspaceAgentLabel('blank'),
|
|
subtitle: 'Open a shell',
|
|
renderIcon: () => <MobileAgentIcon agentId="__blank__" size={18} />
|
|
}
|
|
]
|
|
}, [runtimeTaskSettings.disabledTuiAgents, workspaceAgent, workspaceDetectedAgentIds])
|
|
const openWorkspaceCreate = useCallback((item: ActionableTaskItem, repoIdOverride?: string) => {
|
|
const suggestedName = taskWorkspaceSuggestedName(item)
|
|
setWorkspaceCreateDraft({ item, ...(repoIdOverride ? { repoIdOverride } : {}) })
|
|
setWorkspaceNameDraft(suggestedName)
|
|
setWorkspaceLastAutoName(suggestedName)
|
|
setWorkspaceBranchAutoName('')
|
|
setWorkspaceBranchNameOverride(undefined)
|
|
setWorkspaceBaseBranch(null)
|
|
setWorkspaceBaseBranchQuery('')
|
|
setWorkspaceBaseBranchResults([])
|
|
setWorkspaceBaseBranchLoading(false)
|
|
setWorkspaceBaseBranchError('')
|
|
setWorkspaceSparsePresetId(null)
|
|
setWorkspaceSparsePresets([])
|
|
setWorkspaceSparsePresetsLoaded(false)
|
|
setWorkspaceSparsePresetsError('')
|
|
setWorkspaceSparseReloadKey(0)
|
|
setWorkspaceSparseDraft(null)
|
|
setWorkspaceSparseSaving(false)
|
|
setWorkspaceAgentOverridden(false)
|
|
setWorkspaceAgent(null)
|
|
setOrcaYamlTrustPrompt(null)
|
|
setShowWorkspaceAgentPicker(false)
|
|
setShowWorkspaceCreateRepoPicker(false)
|
|
setShowWorkspaceAdvanced(false)
|
|
setShowWorkspaceBaseBranchPicker(false)
|
|
setShowWorkspaceSparsePicker(false)
|
|
setError('')
|
|
}, [])
|
|
|
|
const handleWorkspaceNameDraftChange = useCallback(
|
|
(nextName: string): void => {
|
|
if (!nextName.trim()) {
|
|
setWorkspaceLastAutoName('')
|
|
} else if (workspaceNameDraft !== workspaceLastAutoName) {
|
|
setWorkspaceLastAutoName('')
|
|
}
|
|
if (workspaceBranchNameOverride && nextName !== workspaceBranchAutoName) {
|
|
setWorkspaceBranchNameOverride(undefined)
|
|
setWorkspaceBranchAutoName('')
|
|
}
|
|
setWorkspaceNameDraft(nextName)
|
|
},
|
|
[
|
|
workspaceBranchAutoName,
|
|
workspaceBranchNameOverride,
|
|
workspaceLastAutoName,
|
|
workspaceNameDraft
|
|
]
|
|
)
|
|
|
|
const selectWorkspaceBaseBranch = useCallback(
|
|
(branch: BaseRefSearchResult): void => {
|
|
const selection = resolveComposerBranchSelection({
|
|
refName: branch.refName,
|
|
localBranchName: branch.localBranchName,
|
|
currentName: workspaceNameDraft,
|
|
lastAutoName: workspaceLastAutoName
|
|
})
|
|
setWorkspaceBaseBranch(branch)
|
|
setWorkspaceBranchAutoName(selection.branchAutoName)
|
|
setWorkspaceBranchNameOverride(selection.branchNameOverride)
|
|
if (selection.name !== undefined && selection.lastAutoName !== undefined) {
|
|
setWorkspaceNameDraft(selection.name)
|
|
setWorkspaceLastAutoName(selection.lastAutoName)
|
|
}
|
|
setShowWorkspaceBaseBranchPicker(false)
|
|
},
|
|
[workspaceLastAutoName, workspaceNameDraft]
|
|
)
|
|
|
|
const clearWorkspaceBaseBranch = useCallback((): void => {
|
|
setWorkspaceBaseBranch(null)
|
|
setWorkspaceBranchAutoName('')
|
|
setWorkspaceBranchNameOverride(undefined)
|
|
setShowWorkspaceBaseBranchPicker(false)
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
if (!tasksSupported || !client || !workspaceCreateDraft || !workspaceCreateTargetRepo) {
|
|
setWorkspaceSparsePresets([])
|
|
setWorkspaceSparsePresetsLoading(false)
|
|
setWorkspaceSparsePresetsLoaded(false)
|
|
setWorkspaceSparsePresetsError('')
|
|
setWorkspaceSparsePresetId(null)
|
|
setWorkspaceSparseDraft(null)
|
|
return
|
|
}
|
|
if (workspaceCreateTargetRepo.connectionId) {
|
|
setWorkspaceSparsePresets([])
|
|
setWorkspaceSparsePresetsLoading(false)
|
|
setWorkspaceSparsePresetsLoaded(false)
|
|
setWorkspaceSparsePresetsError('')
|
|
setWorkspaceSparsePresetId(null)
|
|
setWorkspaceSparseDraft(null)
|
|
return
|
|
}
|
|
|
|
let stale = false
|
|
setWorkspaceSparsePresetsLoading(true)
|
|
setWorkspaceSparsePresetsLoaded(false)
|
|
setWorkspaceSparsePresetsError('')
|
|
void client
|
|
.sendRequest('repo.sparsePresets', { repo: `id:${workspaceCreateTargetRepo.id}` })
|
|
.then((response) => {
|
|
if (stale) {
|
|
return
|
|
}
|
|
if (!isSuccess(response)) {
|
|
throw new Error(response.error.message)
|
|
}
|
|
const presets = (response.result as { presets?: SparsePreset[] }).presets ?? []
|
|
setWorkspaceSparsePresets(presets)
|
|
setWorkspaceSparsePresetsLoaded(true)
|
|
setWorkspaceSparsePresetId((current) =>
|
|
current && presets.some((preset) => preset.id === current) ? current : null
|
|
)
|
|
})
|
|
.catch((err) => {
|
|
if (!stale) {
|
|
setWorkspaceSparsePresets([])
|
|
setWorkspaceSparsePresetsLoaded(false)
|
|
setWorkspaceSparsePresetId(null)
|
|
setWorkspaceSparsePresetsError(
|
|
err instanceof Error ? err.message : 'Failed to load sparse presets.'
|
|
)
|
|
}
|
|
})
|
|
.finally(() => {
|
|
if (!stale) {
|
|
setWorkspaceSparsePresetsLoading(false)
|
|
}
|
|
})
|
|
|
|
return () => {
|
|
stale = true
|
|
}
|
|
}, [
|
|
client,
|
|
tasksSupported,
|
|
workspaceCreateDraft,
|
|
workspaceCreateTargetRepo,
|
|
workspaceSparseReloadKey
|
|
])
|
|
|
|
useEffect(() => {
|
|
if (
|
|
!client ||
|
|
!tasksSupported ||
|
|
!workspaceCreateDraft ||
|
|
!workspaceCreateTargetRepo ||
|
|
!showWorkspaceBaseBranchPicker
|
|
) {
|
|
setWorkspaceBaseBranchResults([])
|
|
setWorkspaceBaseBranchLoading(false)
|
|
setWorkspaceBaseBranchError('')
|
|
return
|
|
}
|
|
const query = workspaceBaseBranchQuery.trim()
|
|
if (!query) {
|
|
setWorkspaceBaseBranchResults([])
|
|
setWorkspaceBaseBranchLoading(false)
|
|
setWorkspaceBaseBranchError('')
|
|
return
|
|
}
|
|
|
|
let stale = false
|
|
setWorkspaceBaseBranchLoading(true)
|
|
setWorkspaceBaseBranchError('')
|
|
void client
|
|
.sendRequest(
|
|
'repo.searchRefs',
|
|
{ repo: `id:${workspaceCreateTargetRepo.id}`, query, limit: 20 },
|
|
{ timeoutMs: 30_000 }
|
|
)
|
|
.then((response) => {
|
|
if (stale) {
|
|
return
|
|
}
|
|
if (!isSuccess(response)) {
|
|
throw new Error(response.error.message)
|
|
}
|
|
const result = response.result as {
|
|
refDetails?: BaseRefSearchResult[]
|
|
refs?: string[]
|
|
}
|
|
setWorkspaceBaseBranchResults(
|
|
result.refDetails ??
|
|
(result.refs ?? []).map((refName) => ({ refName, localBranchName: refName }))
|
|
)
|
|
})
|
|
.catch((err) => {
|
|
if (!stale) {
|
|
setWorkspaceBaseBranchResults([])
|
|
setWorkspaceBaseBranchError(
|
|
err instanceof Error ? err.message : 'Failed to search branches.'
|
|
)
|
|
}
|
|
})
|
|
.finally(() => {
|
|
if (!stale) {
|
|
setWorkspaceBaseBranchLoading(false)
|
|
}
|
|
})
|
|
|
|
return () => {
|
|
stale = true
|
|
}
|
|
}, [
|
|
client,
|
|
tasksSupported,
|
|
showWorkspaceBaseBranchPicker,
|
|
workspaceBaseBranchQuery,
|
|
workspaceCreateDraft,
|
|
workspaceCreateTargetRepo
|
|
])
|
|
|
|
const startNewWorkspaceSparsePreset = useCallback(() => {
|
|
if (
|
|
!workspaceSparseCheckoutAvailable ||
|
|
!workspaceSparsePresetsLoaded ||
|
|
workspaceSparsePresetsLoading
|
|
) {
|
|
return
|
|
}
|
|
setWorkspaceSparseDraft({ mode: 'new', name: '', directoriesText: '' })
|
|
setShowWorkspaceSparsePicker(false)
|
|
}, [
|
|
workspaceSparseCheckoutAvailable,
|
|
workspaceSparsePresetsLoaded,
|
|
workspaceSparsePresetsLoading
|
|
])
|
|
|
|
const startEditWorkspaceSparsePreset = useCallback(
|
|
(preset: SparsePreset) => {
|
|
if (
|
|
!workspaceSparseCheckoutAvailable ||
|
|
!workspaceSparsePresetsLoaded ||
|
|
workspaceSparsePresetsLoading
|
|
) {
|
|
return
|
|
}
|
|
setWorkspaceSparseDraft({
|
|
mode: 'edit',
|
|
presetId: preset.id,
|
|
name: preset.name,
|
|
directoriesText: preset.directories.join('\n')
|
|
})
|
|
setShowWorkspaceSparsePicker(false)
|
|
},
|
|
[workspaceSparseCheckoutAvailable, workspaceSparsePresetsLoaded, workspaceSparsePresetsLoading]
|
|
)
|
|
|
|
const saveWorkspaceSparsePreset = useCallback(async (): Promise<void> => {
|
|
if (
|
|
!client ||
|
|
!tasksSupported ||
|
|
!workspaceCreateTargetRepo ||
|
|
!workspaceSparseDraft ||
|
|
!workspaceSparseDraftParsed ||
|
|
!canSaveWorkspaceSparseDraft
|
|
) {
|
|
return
|
|
}
|
|
setWorkspaceSparseSaving(true)
|
|
setWorkspaceSparsePresetsError('')
|
|
try {
|
|
const response = await client.sendRequest('repo.saveSparsePreset', {
|
|
repo: `id:${workspaceCreateTargetRepo.id}`,
|
|
...(workspaceSparseDraft.presetId ? { id: workspaceSparseDraft.presetId } : {}),
|
|
name: workspaceSparseDraftName,
|
|
directories: workspaceSparseDraftParsed.directories
|
|
})
|
|
if (!isSuccess(response)) {
|
|
throw new Error(response.error.message)
|
|
}
|
|
const saved = (response.result as { preset?: SparsePreset }).preset
|
|
if (!saved) {
|
|
throw new Error('Failed to save sparse preset.')
|
|
}
|
|
setWorkspaceSparsePresets((current) => {
|
|
const withoutSaved = current.filter((preset) => preset.id !== saved.id)
|
|
return sortSparsePresetsByName([...withoutSaved, saved])
|
|
})
|
|
setWorkspaceSparsePresetsLoaded(true)
|
|
if (workspaceSparseDraft.mode === 'new' || workspaceSparsePresetId === saved.id) {
|
|
setWorkspaceSparsePresetId(saved.id)
|
|
}
|
|
setWorkspaceSparseDraft(null)
|
|
} catch (err) {
|
|
setWorkspaceSparsePresetsError(
|
|
err instanceof Error ? err.message : 'Failed to save sparse preset.'
|
|
)
|
|
} finally {
|
|
setWorkspaceSparseSaving(false)
|
|
}
|
|
}, [
|
|
canSaveWorkspaceSparseDraft,
|
|
client,
|
|
tasksSupported,
|
|
workspaceCreateTargetRepo,
|
|
workspaceSparseDraft,
|
|
workspaceSparseDraftName,
|
|
workspaceSparseDraftParsed,
|
|
workspaceSparsePresetId
|
|
])
|
|
|
|
useEffect(() => {
|
|
if (!tasksSupported || !client || !workspaceCreateDraft || !workspaceCreateTargetConnectionId) {
|
|
setWorkspaceSshState(null)
|
|
setWorkspaceSshConnecting(false)
|
|
return
|
|
}
|
|
|
|
let stale = false
|
|
void client
|
|
.sendRequest('ssh.getState', { targetId: workspaceCreateTargetConnectionId })
|
|
.then((response) => {
|
|
if (stale) {
|
|
return
|
|
}
|
|
if (!isSuccess(response)) {
|
|
throw new Error(response.error.message)
|
|
}
|
|
const state = (response.result as { state?: SshConnectionState | null }).state ?? null
|
|
setWorkspaceSshState(
|
|
state ?? {
|
|
targetId: workspaceCreateTargetConnectionId,
|
|
status: 'disconnected',
|
|
error: null,
|
|
reconnectAttempt: 0
|
|
}
|
|
)
|
|
})
|
|
.catch((err) => {
|
|
if (!stale) {
|
|
setWorkspaceSshState({
|
|
targetId: workspaceCreateTargetConnectionId,
|
|
status: 'error',
|
|
error: err instanceof Error ? err.message : 'Failed to read SSH connection state.',
|
|
reconnectAttempt: 0
|
|
})
|
|
}
|
|
})
|
|
|
|
return () => {
|
|
stale = true
|
|
}
|
|
}, [client, tasksSupported, workspaceCreateDraft, workspaceCreateTargetConnectionId])
|
|
|
|
const connectWorkspaceSshRepo = useCallback(async (): Promise<void> => {
|
|
if (!client || !tasksSupported || !workspaceCreateTargetConnectionId) {
|
|
return
|
|
}
|
|
setWorkspaceSshConnecting(true)
|
|
setWorkspaceSshState({
|
|
targetId: workspaceCreateTargetConnectionId,
|
|
status: 'connecting',
|
|
error: null,
|
|
reconnectAttempt: 0
|
|
})
|
|
try {
|
|
const response = await client.sendRequest(
|
|
'ssh.connect',
|
|
{ targetId: workspaceCreateTargetConnectionId },
|
|
{ timeoutMs: 120_000 }
|
|
)
|
|
if (!isSuccess(response)) {
|
|
throw new Error(response.error.message)
|
|
}
|
|
const state = (response.result as { state?: SshConnectionState | null }).state
|
|
setWorkspaceSshState(
|
|
state ?? {
|
|
targetId: workspaceCreateTargetConnectionId,
|
|
status: 'connected',
|
|
error: null,
|
|
reconnectAttempt: 0
|
|
}
|
|
)
|
|
} catch (err) {
|
|
setWorkspaceSshState({
|
|
targetId: workspaceCreateTargetConnectionId,
|
|
status: 'error',
|
|
error: err instanceof Error ? err.message : 'Failed to connect to SSH repository.',
|
|
reconnectAttempt: 0
|
|
})
|
|
} finally {
|
|
setWorkspaceSshConnecting(false)
|
|
}
|
|
}, [client, tasksSupported, workspaceCreateTargetConnectionId])
|
|
|
|
const ensureWorkspaceSshReady = useCallback(
|
|
async (repo: RepoSummary): Promise<void> => {
|
|
if (!repo.connectionId || !client || !tasksSupported) {
|
|
return
|
|
}
|
|
if (
|
|
workspaceSshState?.targetId === repo.connectionId &&
|
|
workspaceSshState.status === 'connected'
|
|
) {
|
|
return
|
|
}
|
|
const response = await client.sendRequest('ssh.getState', { targetId: repo.connectionId })
|
|
if (!isSuccess(response)) {
|
|
throw new Error(response.error.message)
|
|
}
|
|
const state = (response.result as { state?: SshConnectionState | null }).state ?? null
|
|
if (state) {
|
|
setWorkspaceSshState(state)
|
|
}
|
|
if (state?.status !== 'connected') {
|
|
throw new Error(`Connect ${repo.displayName} before creating a workspace.`)
|
|
}
|
|
},
|
|
[client, tasksSupported, workspaceSshState]
|
|
)
|
|
|
|
useEffect(() => {
|
|
if (!tasksSupported || !workspaceCreateDraft || !client || !workspaceCreateTargetRepo) {
|
|
setWorkspaceDetectedAgentIds(null)
|
|
return
|
|
}
|
|
if (workspaceCreateTargetRepo.connectionId && workspaceCreateSshStatus !== 'connected') {
|
|
// Why: remote agent detection runs on the SSH host through the relay; a
|
|
// disconnected repo would fail and cache an empty agent list.
|
|
setWorkspaceDetectedAgentIds(null)
|
|
return
|
|
}
|
|
let stale = false
|
|
setWorkspaceDetectedAgentIds(null)
|
|
const request = workspaceCreateTargetRepo.connectionId
|
|
? client.sendRequest('preflight.detectRemoteAgents', {
|
|
connectionId: workspaceCreateTargetRepo.connectionId
|
|
})
|
|
: client.sendRequest('preflight.detectAgents')
|
|
void request
|
|
.then((response) => {
|
|
if (stale) {
|
|
return
|
|
}
|
|
setWorkspaceDetectedAgentIds(
|
|
isSuccess(response) ? new Set(response.result as string[]) : new Set()
|
|
)
|
|
})
|
|
.catch(() => {
|
|
if (!stale) {
|
|
setWorkspaceDetectedAgentIds(new Set())
|
|
}
|
|
})
|
|
return () => {
|
|
stale = true
|
|
}
|
|
}, [
|
|
client,
|
|
tasksSupported,
|
|
workspaceCreateDraft,
|
|
workspaceCreateSshStatus,
|
|
workspaceCreateTargetRepo
|
|
])
|
|
|
|
const workspaceAgentSelection = resolveWorkspaceAgentSelection({
|
|
selectionActive: tasksSupported && workspaceCreateDraft !== null,
|
|
settings: runtimeTaskSettings,
|
|
detectedAgentIds: workspaceDetectedAgentIds,
|
|
agent: workspaceAgent,
|
|
overridden: workspaceAgentOverridden
|
|
})
|
|
if (
|
|
workspaceAgentSelection.agent !== workspaceAgent ||
|
|
workspaceAgentSelection.overridden !== workspaceAgentOverridden
|
|
) {
|
|
// Why: the drawer can open before SSH/local detection settles. Resolve the
|
|
// visible agent before commit so users do not see an unavailable override.
|
|
setWorkspaceAgent(workspaceAgentSelection.agent)
|
|
setWorkspaceAgentOverridden(workspaceAgentSelection.overridden)
|
|
}
|
|
|
|
const resolvedWorkspaceAgent = useMemo(
|
|
() => workspaceAgent ?? pickWorkspaceAgent(runtimeTaskSettings, workspaceDetectedAgentIds),
|
|
[runtimeTaskSettings, workspaceAgent, workspaceDetectedAgentIds]
|
|
)
|
|
const workspaceAgentDetectionPending =
|
|
workspaceCreateDraft != null &&
|
|
workspaceCreateTargetRepo != null &&
|
|
!workspaceCreateRequiresSshConnection &&
|
|
workspaceDetectedAgentIds === null
|
|
|
|
const resolveCreateSetupDecision = useCallback(
|
|
async (
|
|
repo: RepoSummary,
|
|
override?: Exclude<SetupDecision, 'inherit'>
|
|
): Promise<
|
|
| { kind: 'decision'; decision: SetupDecision; setupTrust?: RepoHooksResponse['setupTrust'] }
|
|
| {
|
|
kind: 'prompt'
|
|
command: string
|
|
source: string | null
|
|
setupTrust?: RepoHooksResponse['setupTrust']
|
|
}
|
|
> => {
|
|
if (!client || !tasksSupported) {
|
|
return { kind: 'decision', decision: override ?? 'inherit' }
|
|
}
|
|
const response = await client.sendRequest('repo.hooks', { repo: `id:${repo.id}` })
|
|
if (!isSuccess(response)) {
|
|
throw new Error(response.error.message)
|
|
}
|
|
const result = response.result as RepoHooksResponse
|
|
const setupCommand = result.hooks?.scripts?.setup?.trim()
|
|
const setupTrust = normalizeSetupHookTrust(result.setupTrust) ?? undefined
|
|
if (!setupCommand) {
|
|
return { kind: 'decision', decision: 'inherit' }
|
|
}
|
|
if (override) {
|
|
return { kind: 'decision', decision: override, setupTrust }
|
|
}
|
|
const setupRunPolicy = result.setupRunPolicy ?? 'run-by-default'
|
|
if (setupRunPolicy === 'ask') {
|
|
return { kind: 'prompt', command: setupCommand, source: result.source, setupTrust }
|
|
}
|
|
return {
|
|
kind: 'decision',
|
|
decision: setupRunPolicy === 'run-by-default' ? 'run' : 'skip',
|
|
setupTrust
|
|
}
|
|
},
|
|
[client, tasksSupported]
|
|
)
|
|
|
|
const createWorkspace = useCallback(
|
|
async (
|
|
item: ActionableTaskItem,
|
|
repoIdOverride?: string,
|
|
setupOverride?: Exclude<SetupDecision, 'inherit'>,
|
|
agentOverride?: WorkspaceAgentChoice,
|
|
workspaceNameOverride?: string,
|
|
noteOverride?: string,
|
|
baseBranchOverride?: string,
|
|
branchNameOverride?: string,
|
|
sparseCheckoutOverride?: { directories: string[]; presetId?: string },
|
|
approvedSetupContentHash?: string
|
|
): Promise<void> => {
|
|
if (!client || !tasksSupported || !taskStateHydrated) {
|
|
return
|
|
}
|
|
setCreatingKey(item.key)
|
|
setError('')
|
|
try {
|
|
const targetRepo = getWorkspaceTargetRepo(item, repoIdOverride)
|
|
if (!targetRepo) {
|
|
throw new Error(
|
|
item.provider === 'linear'
|
|
? 'Add a Git repository before creating a Linear workspace.'
|
|
: 'Repository not found.'
|
|
)
|
|
}
|
|
await ensureWorkspaceSshReady(targetRepo)
|
|
let latestRuntimeTaskSettings = runtimeTaskSettings
|
|
try {
|
|
const settingsResponse = await client.sendRequest('settings.get')
|
|
if (isSuccess(settingsResponse)) {
|
|
latestRuntimeTaskSettings = ((
|
|
settingsResponse.result as { settings?: RuntimeTaskSettings }
|
|
).settings ?? {}) as RuntimeTaskSettings
|
|
setRuntimeTaskSettings(latestRuntimeTaskSettings)
|
|
}
|
|
} catch {
|
|
// Best-effort refresh; the runtime still validates agent availability before spawning.
|
|
}
|
|
const selectedAgent =
|
|
agentOverride &&
|
|
(agentOverride === 'blank' ||
|
|
isWorkspaceAgentEnabled(agentOverride, latestRuntimeTaskSettings.disabledTuiAgents))
|
|
? agentOverride
|
|
: pickWorkspaceAgent(latestRuntimeTaskSettings, workspaceDetectedAgentIds)
|
|
if (
|
|
agentOverride &&
|
|
agentOverride !== 'blank' &&
|
|
!isWorkspaceAgentEnabled(agentOverride, latestRuntimeTaskSettings.disabledTuiAgents)
|
|
) {
|
|
setWorkspaceAgent(selectedAgent)
|
|
setWorkspaceAgentOverridden(false)
|
|
throw new Error('Selected agent is disabled. Choose an enabled agent before creating.')
|
|
}
|
|
const setupResolution = await resolveCreateSetupDecision(targetRepo, setupOverride)
|
|
const comment = noteOverride?.trim()
|
|
if (setupResolution.kind === 'prompt') {
|
|
// Why: desktop does not silently create when a repo policy says setup
|
|
// requires a per-workspace decision. Mobile must ask before create too.
|
|
setSetupPrompt({
|
|
item,
|
|
...(repoIdOverride ? { repoIdOverride } : {}),
|
|
...(agentOverride ? { agentOverride } : {}),
|
|
...(workspaceNameOverride ? { workspaceNameOverride } : {}),
|
|
...(comment ? { noteOverride: comment } : {}),
|
|
...(baseBranchOverride ? { baseBranchOverride } : {}),
|
|
...(branchNameOverride ? { branchNameOverride } : {}),
|
|
...(sparseCheckoutOverride ? { sparseCheckoutOverride } : {}),
|
|
repoName: targetRepo.displayName,
|
|
command: setupResolution.command,
|
|
source: setupResolution.source
|
|
})
|
|
return
|
|
}
|
|
const setupDecision = setupResolution.decision
|
|
if (
|
|
setupDecision === 'run' &&
|
|
setupResolution.setupTrust &&
|
|
setupResolution.setupTrust.contentHash !== approvedSetupContentHash &&
|
|
!isSetupHookTrusted(
|
|
trustedOrcaHooks,
|
|
targetRepo.id,
|
|
setupResolution.setupTrust.contentHash
|
|
)
|
|
) {
|
|
// Why: desktop prompts before running repo-owned orca.yaml hooks. Mobile
|
|
// stores the same trust hash in persisted UI state so either surface can
|
|
// approve the script version for future workspace creates.
|
|
setSetupPrompt(null)
|
|
setOrcaYamlTrustPrompt({
|
|
item,
|
|
...(repoIdOverride ? { repoIdOverride } : {}),
|
|
setupOverride: 'run',
|
|
...(agentOverride ? { agentOverride } : {}),
|
|
...(workspaceNameOverride ? { workspaceNameOverride } : {}),
|
|
...(comment ? { noteOverride: comment } : {}),
|
|
...(baseBranchOverride ? { baseBranchOverride } : {}),
|
|
...(branchNameOverride ? { branchNameOverride } : {}),
|
|
...(sparseCheckoutOverride ? { sparseCheckoutOverride } : {}),
|
|
repoId: targetRepo.id,
|
|
repoName: targetRepo.displayName,
|
|
scriptContent: setupResolution.setupTrust.scriptContent,
|
|
contentHash: setupResolution.setupTrust.contentHash,
|
|
previouslyApproved: wasSetupHookPreviouslyApproved(trustedOrcaHooks, targetRepo.id)
|
|
})
|
|
return
|
|
}
|
|
let params: Record<string, unknown>
|
|
if (item.provider === 'github') {
|
|
const source = item.source
|
|
let prStartPoint: { baseBranch: string; pushTarget?: GitPushTarget } | undefined
|
|
if (
|
|
shouldResolveHostedReviewStartPoint({
|
|
type: source.type,
|
|
baseBranchOverride
|
|
})
|
|
) {
|
|
const response = await client.sendRequest(
|
|
'worktree.resolvePrBase',
|
|
{
|
|
repo: `id:${source.repoId}`,
|
|
prNumber: source.number,
|
|
...(source.branchName ? { headRefName: source.branchName } : {}),
|
|
...(source.isCrossRepository !== undefined
|
|
? { isCrossRepository: source.isCrossRepository }
|
|
: {})
|
|
},
|
|
{ timeoutMs: 30_000 }
|
|
)
|
|
if (!isSuccess(response)) {
|
|
throw new Error(response.error.message)
|
|
}
|
|
const result = response.result as
|
|
| { baseBranch: string; pushTarget?: GitPushTarget }
|
|
| { error: string }
|
|
if ('error' in result) {
|
|
throw new Error(result.error)
|
|
}
|
|
prStartPoint = result
|
|
}
|
|
params = buildTaskWorkspaceCreateParams({
|
|
item,
|
|
targetRepoId: targetRepo.id,
|
|
setupDecision,
|
|
agent: selectedAgent,
|
|
workspaceName: workspaceNameOverride,
|
|
note: comment,
|
|
baseBranch: baseBranchOverride,
|
|
branchNameOverride,
|
|
sparseCheckout: sparseCheckoutOverride,
|
|
hostedStartPoint: prStartPoint
|
|
})
|
|
} else if (item.provider === 'gitlab') {
|
|
const source = item.source
|
|
let mrStartPoint: { baseBranch: string; pushTarget?: GitPushTarget } | undefined
|
|
if (
|
|
shouldResolveHostedReviewStartPoint({
|
|
type: source.type,
|
|
baseBranchOverride
|
|
})
|
|
) {
|
|
const response = await client.sendRequest(
|
|
'worktree.resolveMrBase',
|
|
{
|
|
repo: `id:${source.repoId}`,
|
|
mrIid: source.number,
|
|
...(source.branchName ? { sourceBranch: source.branchName } : {}),
|
|
...(source.isCrossRepository !== undefined
|
|
? { isCrossRepository: source.isCrossRepository }
|
|
: {})
|
|
},
|
|
{ timeoutMs: 30_000 }
|
|
)
|
|
if (!isSuccess(response)) {
|
|
throw new Error(response.error.message)
|
|
}
|
|
const result = response.result as
|
|
| { baseBranch: string; pushTarget?: GitPushTarget }
|
|
| { error: string }
|
|
if ('error' in result) {
|
|
throw new Error(result.error)
|
|
}
|
|
mrStartPoint = result
|
|
}
|
|
params = buildTaskWorkspaceCreateParams({
|
|
item,
|
|
targetRepoId: targetRepo.id,
|
|
setupDecision,
|
|
agent: selectedAgent,
|
|
workspaceName: workspaceNameOverride,
|
|
note: comment,
|
|
baseBranch: baseBranchOverride,
|
|
branchNameOverride,
|
|
sparseCheckout: sparseCheckoutOverride,
|
|
hostedStartPoint: mrStartPoint
|
|
})
|
|
} else {
|
|
params = buildTaskWorkspaceCreateParams({
|
|
item,
|
|
targetRepoId: targetRepo.id,
|
|
setupDecision,
|
|
agent: selectedAgent,
|
|
workspaceName: workspaceNameOverride,
|
|
note: comment,
|
|
baseBranch: baseBranchOverride,
|
|
branchNameOverride,
|
|
sparseCheckout: sparseCheckoutOverride
|
|
})
|
|
}
|
|
const response = await client.sendRequest('worktree.create', params, {
|
|
timeoutMs: WORKTREE_CREATE_TIMEOUT_MS
|
|
})
|
|
if (!isSuccess(response)) {
|
|
throw new Error(response.error.message)
|
|
}
|
|
const result = response.result as {
|
|
worktree: { id: string; displayName?: string }
|
|
warning?: string
|
|
}
|
|
setActionItem(null)
|
|
setWorkspaceCreateDraft(null)
|
|
setSetupPrompt(null)
|
|
const name = result.worktree.displayName ?? item.title
|
|
const queryParams = new URLSearchParams({ name, created: '1' })
|
|
if (result.warning) {
|
|
queryParams.set('warning', result.warning)
|
|
}
|
|
router.push(
|
|
`/h/${hostId}/session/${encodeURIComponent(result.worktree.id)}?${queryParams.toString()}`
|
|
)
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Failed to create workspace')
|
|
} finally {
|
|
setCreatingKey(null)
|
|
}
|
|
},
|
|
[
|
|
client,
|
|
ensureWorkspaceSshReady,
|
|
getWorkspaceTargetRepo,
|
|
hostId,
|
|
resolveCreateSetupDecision,
|
|
router,
|
|
runtimeTaskSettings,
|
|
taskStateHydrated,
|
|
tasksSupported,
|
|
trustedOrcaHooks,
|
|
workspaceDetectedAgentIds
|
|
]
|
|
)
|
|
|
|
const createWorkspaceFromProjectRow = useCallback(
|
|
async (row: GitHubProjectRow): Promise<void> => {
|
|
if (!tasksSupported) {
|
|
return
|
|
}
|
|
const kind = projectRowType(row)
|
|
const repo = findProjectRowRepo(row)
|
|
if (!kind || !row.content.number || !row.content.url) {
|
|
setError('Add the project item repository to Orca before creating a workspace.')
|
|
return
|
|
}
|
|
if (!repo) {
|
|
const slug = splitRepositorySlug(row.content.repository)
|
|
setProjectRepoNotInOrca({
|
|
owner: slug?.owner ?? 'Unknown',
|
|
repo: slug?.repo ?? row.content.repository ?? 'repository',
|
|
url: row.content.url ?? null
|
|
})
|
|
return
|
|
}
|
|
const state: GitHubWorkItem['state'] =
|
|
row.content.state === 'MERGED'
|
|
? 'merged'
|
|
: row.content.state === 'CLOSED'
|
|
? 'closed'
|
|
: row.content.isDraft
|
|
? 'draft'
|
|
: 'open'
|
|
const source: GitHubWorkItem = {
|
|
id: row.id,
|
|
type: kind,
|
|
number: row.content.number,
|
|
title: row.content.title,
|
|
state,
|
|
url: row.content.url,
|
|
labels: row.content.labels.map((label) => label.name),
|
|
updatedAt: row.updatedAt,
|
|
author: null,
|
|
repoId: repo.id,
|
|
repoName: repo.displayName
|
|
}
|
|
openWorkspaceCreate({
|
|
key: `github-project:${row.id}`,
|
|
provider: 'github',
|
|
title: row.content.title,
|
|
subtitle: `${repo.displayName} #${row.content.number}`,
|
|
status: projectRowStatusLabel(row),
|
|
updatedAt: row.updatedAt,
|
|
source
|
|
})
|
|
},
|
|
[findProjectRowRepo, openWorkspaceCreate, tasksSupported]
|
|
)
|
|
|
|
const mutateProjectRowIssueOrPr = useCallback(
|
|
async (
|
|
row: GitHubProjectRow,
|
|
updates: { title?: string; body?: string; state?: 'open' | 'closed' }
|
|
): Promise<void> => {
|
|
if (!client || projectMutating) {
|
|
return
|
|
}
|
|
const type = projectRowType(row)
|
|
const slug = splitRepositorySlug(row.content.repository)
|
|
if (!type || !slug || !row.content.number) {
|
|
setProjectRowDetailError('This project item cannot be edited from mobile.')
|
|
return
|
|
}
|
|
setProjectMutating(true)
|
|
try {
|
|
const response = await client.sendRequest(
|
|
type === 'issue'
|
|
? 'github.project.updateIssueBySlug'
|
|
: 'github.project.updatePullRequestBySlug',
|
|
{
|
|
owner: slug.owner,
|
|
repo: slug.repo,
|
|
host: activeGitHubProjectHost,
|
|
number: row.content.number,
|
|
updates
|
|
},
|
|
{ timeoutMs: 30_000 }
|
|
)
|
|
if (!isSuccess(response)) {
|
|
throw new Error(response.error.message)
|
|
}
|
|
const result = response.result as { ok?: boolean; error?: { message?: string } }
|
|
if (result.ok === false) {
|
|
throw new Error(result.error?.message ?? 'Failed to update GitHub item')
|
|
}
|
|
setProjectRowItem((current) => {
|
|
if (!current || current.id !== row.id) {
|
|
return current
|
|
}
|
|
return {
|
|
...current,
|
|
content: {
|
|
...current.content,
|
|
...(updates.title !== undefined ? { title: updates.title } : {}),
|
|
...(updates.body !== undefined ? { body: updates.body } : {}),
|
|
...(updates.state !== undefined
|
|
? { state: updates.state === 'closed' ? 'CLOSED' : 'OPEN' }
|
|
: {})
|
|
}
|
|
}
|
|
})
|
|
setGithubProjectTable((table) =>
|
|
table
|
|
? {
|
|
...table,
|
|
rows: table.rows.map((candidate) =>
|
|
candidate.id === row.id
|
|
? {
|
|
...candidate,
|
|
content: {
|
|
...candidate.content,
|
|
...(updates.title !== undefined ? { title: updates.title } : {}),
|
|
...(updates.body !== undefined ? { body: updates.body } : {}),
|
|
...(updates.state !== undefined
|
|
? { state: updates.state === 'closed' ? 'CLOSED' : 'OPEN' }
|
|
: {})
|
|
}
|
|
}
|
|
: candidate
|
|
)
|
|
}
|
|
: table
|
|
)
|
|
if (updates.body !== undefined) {
|
|
setProjectRowDetail((current) =>
|
|
current?.provider === 'github' ? { ...current, body: updates.body ?? '' } : current
|
|
)
|
|
}
|
|
} catch (err) {
|
|
setProjectRowDetailError(err instanceof Error ? err.message : 'Failed to update item')
|
|
} finally {
|
|
setProjectMutating(false)
|
|
}
|
|
},
|
|
[activeGitHubProjectHost, client, projectMutating]
|
|
)
|
|
|
|
const addProjectRowComment = useCallback(
|
|
async (row: GitHubProjectRow): Promise<void> => {
|
|
if (!client || projectMutating) {
|
|
return
|
|
}
|
|
const slug = splitRepositorySlug(row.content.repository)
|
|
const body = projectCommentDraft.trim()
|
|
if (!slug || !row.content.number || !body) {
|
|
return
|
|
}
|
|
setProjectMutating(true)
|
|
try {
|
|
const response = await client.sendRequest(
|
|
'github.project.addIssueCommentBySlug',
|
|
{
|
|
owner: slug.owner,
|
|
repo: slug.repo,
|
|
host: activeGitHubProjectHost,
|
|
number: row.content.number,
|
|
body
|
|
},
|
|
{ timeoutMs: 30_000 }
|
|
)
|
|
if (!isSuccess(response)) {
|
|
throw new Error(response.error.message)
|
|
}
|
|
const result = response.result as
|
|
| { ok: true; comment?: DetailComment }
|
|
| { ok: false; error?: { message?: string } }
|
|
if (!result.ok) {
|
|
throw new Error(result.error?.message ?? 'Failed to add comment')
|
|
}
|
|
setProjectCommentDraft('')
|
|
if (result.comment) {
|
|
setProjectRowDetail((current) =>
|
|
current?.provider === 'github'
|
|
? { ...current, comments: [...current.comments, result.comment as DetailComment] }
|
|
: current
|
|
)
|
|
}
|
|
} catch (err) {
|
|
setProjectRowDetailError(err instanceof Error ? err.message : 'Failed to add comment')
|
|
} finally {
|
|
setProjectMutating(false)
|
|
}
|
|
},
|
|
[activeGitHubProjectHost, client, projectCommentDraft, projectMutating]
|
|
)
|
|
|
|
const updateProjectRowComment = useCallback(
|
|
async (row: GitHubProjectRow, comment: DetailComment): Promise<void> => {
|
|
if (!client || projectMutating) {
|
|
return
|
|
}
|
|
const slug = splitRepositorySlug(row.content.repository)
|
|
const commentId = Number(comment.id)
|
|
const body = projectEditingCommentDraft.trim()
|
|
if (!slug || !Number.isInteger(commentId) || commentId <= 0 || !body) {
|
|
setProjectRowDetailError('This project comment cannot be edited from mobile.')
|
|
return
|
|
}
|
|
setProjectMutating(true)
|
|
setProjectRowDetailError('')
|
|
try {
|
|
const response = await client.sendRequest(
|
|
'github.project.updateIssueCommentBySlug',
|
|
{
|
|
owner: slug.owner,
|
|
repo: slug.repo,
|
|
host: activeGitHubProjectHost,
|
|
commentId,
|
|
body
|
|
},
|
|
{ timeoutMs: 30_000 }
|
|
)
|
|
if (!isSuccess(response)) {
|
|
throw new Error(response.error.message)
|
|
}
|
|
const result = response.result as {
|
|
ok?: boolean
|
|
error?: string | { message?: string }
|
|
}
|
|
if (result.ok === false) {
|
|
throw new Error(
|
|
typeof result.error === 'string'
|
|
? result.error
|
|
: (result.error?.message ?? 'Failed to edit comment')
|
|
)
|
|
}
|
|
setProjectRowDetail((current) =>
|
|
current?.provider === 'github'
|
|
? {
|
|
...current,
|
|
comments: current.comments.map((candidate) =>
|
|
Number(candidate.id) === commentId ? { ...candidate, body } : candidate
|
|
)
|
|
}
|
|
: current
|
|
)
|
|
setProjectEditingCommentId(null)
|
|
setProjectEditingCommentDraft('')
|
|
} catch (err) {
|
|
setProjectRowDetailError(err instanceof Error ? err.message : 'Failed to edit comment')
|
|
} finally {
|
|
setProjectMutating(false)
|
|
}
|
|
},
|
|
[activeGitHubProjectHost, client, projectEditingCommentDraft, projectMutating]
|
|
)
|
|
|
|
const deleteProjectRowComment = useCallback(
|
|
async (row: GitHubProjectRow, comment: DetailComment): Promise<void> => {
|
|
if (!client || projectMutating) {
|
|
return
|
|
}
|
|
const slug = splitRepositorySlug(row.content.repository)
|
|
const commentId = Number(comment.id)
|
|
if (!slug || !Number.isInteger(commentId) || commentId <= 0) {
|
|
setProjectRowDetailError('This project comment cannot be deleted from mobile.')
|
|
return
|
|
}
|
|
setProjectMutating(true)
|
|
setProjectRowDetailError('')
|
|
try {
|
|
const response = await client.sendRequest(
|
|
'github.project.deleteIssueCommentBySlug',
|
|
{
|
|
owner: slug.owner,
|
|
repo: slug.repo,
|
|
host: activeGitHubProjectHost,
|
|
commentId
|
|
},
|
|
{ timeoutMs: 30_000 }
|
|
)
|
|
if (!isSuccess(response)) {
|
|
throw new Error(response.error.message)
|
|
}
|
|
const result = response.result as {
|
|
ok?: boolean
|
|
error?: string | { message?: string }
|
|
}
|
|
if (result.ok === false) {
|
|
throw new Error(
|
|
typeof result.error === 'string'
|
|
? result.error
|
|
: (result.error?.message ?? 'Failed to delete comment')
|
|
)
|
|
}
|
|
setProjectRowDetail((current) =>
|
|
current?.provider === 'github'
|
|
? {
|
|
...current,
|
|
comments: current.comments.filter((candidate) => Number(candidate.id) !== commentId)
|
|
}
|
|
: current
|
|
)
|
|
if (projectEditingCommentId === String(comment.id)) {
|
|
setProjectEditingCommentId(null)
|
|
setProjectEditingCommentDraft('')
|
|
}
|
|
} catch (err) {
|
|
setProjectRowDetailError(err instanceof Error ? err.message : 'Failed to delete comment')
|
|
} finally {
|
|
setProjectMutating(false)
|
|
}
|
|
},
|
|
[activeGitHubProjectHost, client, projectEditingCommentId, projectMutating]
|
|
)
|
|
|
|
const toggleProjectGitHubReviewThread = useCallback(
|
|
async (row: GitHubProjectRow, comment: DetailComment): Promise<void> => {
|
|
const repo = findProjectRowRepo(row)
|
|
if (
|
|
!client ||
|
|
projectMutating ||
|
|
row.itemType !== 'PULL_REQUEST' ||
|
|
!repo ||
|
|
!comment.threadId
|
|
) {
|
|
return
|
|
}
|
|
const resolve = !comment.isResolved
|
|
setProjectMutating(true)
|
|
setProjectRowDetailError('')
|
|
try {
|
|
const response = await client.sendRequest(
|
|
'github.resolveReviewThread',
|
|
{
|
|
repo: `id:${repo.id}`,
|
|
prRepo: projectRowGitHubRepository(row, activeGitHubProjectHost),
|
|
threadId: comment.threadId,
|
|
resolve
|
|
},
|
|
{ timeoutMs: 30_000 }
|
|
)
|
|
if (!isSuccess(response)) {
|
|
throw new Error(response.error.message)
|
|
}
|
|
if (response.result !== true) {
|
|
throw new Error(resolve ? 'Failed to resolve thread' : 'Failed to reopen thread')
|
|
}
|
|
setProjectRowDetail((current) =>
|
|
current?.provider === 'github'
|
|
? {
|
|
...current,
|
|
comments: current.comments.map((candidate) =>
|
|
candidate.threadId === comment.threadId
|
|
? { ...candidate, isResolved: resolve }
|
|
: candidate
|
|
)
|
|
}
|
|
: current
|
|
)
|
|
} catch (err) {
|
|
setProjectRowDetailError(
|
|
err instanceof Error ? err.message : 'Failed to update review thread'
|
|
)
|
|
} finally {
|
|
setProjectMutating(false)
|
|
}
|
|
},
|
|
[activeGitHubProjectHost, client, findProjectRowRepo, projectMutating]
|
|
)
|
|
|
|
const replyToProjectGitHubComment = useCallback(
|
|
async (row: GitHubProjectRow, comment: DetailComment): Promise<void> => {
|
|
const repo = findProjectRowRepo(row)
|
|
if (!client || projectMutating || !repo || !row.content.number) {
|
|
return
|
|
}
|
|
const key = String(comment.id)
|
|
const body = (itemReplyDrafts[key] ?? '').trim()
|
|
if (!body) {
|
|
return
|
|
}
|
|
setProjectMutating(true)
|
|
setProjectRowDetailError('')
|
|
try {
|
|
const canUseReviewReply =
|
|
row.itemType === 'PULL_REQUEST' &&
|
|
comment.path &&
|
|
typeof comment.line === 'number' &&
|
|
typeof comment.id === 'number'
|
|
const response = canUseReviewReply
|
|
? await client.sendRequest(
|
|
'github.addPRReviewCommentReply',
|
|
{
|
|
repo: `id:${repo.id}`,
|
|
prNumber: row.content.number,
|
|
prRepo: projectRowGitHubRepository(row, activeGitHubProjectHost),
|
|
commentId: comment.id,
|
|
body,
|
|
threadId: comment.threadId,
|
|
path: comment.path,
|
|
line: comment.line
|
|
},
|
|
{ timeoutMs: 30_000 }
|
|
)
|
|
: await client.sendRequest(
|
|
'github.addIssueComment',
|
|
{
|
|
repo: `id:${repo.id}`,
|
|
number: row.content.number,
|
|
prRepo: projectRowGitHubRepository(row, activeGitHubProjectHost),
|
|
body: `@${commentAuthor(comment)} ${body}`,
|
|
type: projectRowType(row) ?? 'issue'
|
|
},
|
|
{ timeoutMs: 30_000 }
|
|
)
|
|
if (!isSuccess(response)) {
|
|
throw new Error(response.error.message)
|
|
}
|
|
const result = response.result as {
|
|
ok?: boolean
|
|
error?: string
|
|
comment?: DetailComment
|
|
}
|
|
if (result.ok === false) {
|
|
throw new Error(result.error ?? 'Failed to reply')
|
|
}
|
|
const reply: DetailComment = result.comment ?? {
|
|
id: `local-${Date.now()}`,
|
|
body,
|
|
createdAt: new Date().toISOString(),
|
|
author: 'You',
|
|
path: comment.path,
|
|
line: comment.line,
|
|
threadId: comment.threadId
|
|
}
|
|
setItemReplyDrafts((current) => {
|
|
const next = { ...current }
|
|
delete next[key]
|
|
return next
|
|
})
|
|
setProjectRowDetail((current) =>
|
|
current?.provider === 'github'
|
|
? { ...current, comments: [...current.comments, reply] }
|
|
: current
|
|
)
|
|
} catch (err) {
|
|
setProjectRowDetailError(err instanceof Error ? err.message : 'Failed to reply')
|
|
} finally {
|
|
setProjectMutating(false)
|
|
}
|
|
},
|
|
[activeGitHubProjectHost, client, findProjectRowRepo, itemReplyDrafts, projectMutating]
|
|
)
|
|
|
|
const mutateProjectRowMetadata = useCallback(
|
|
async (
|
|
row: GitHubProjectRow,
|
|
updates: {
|
|
addLabels?: string[]
|
|
removeLabels?: string[]
|
|
addAssignees?: string[]
|
|
removeAssignees?: string[]
|
|
}
|
|
): Promise<void> => {
|
|
if (!client || projectMutating) {
|
|
return
|
|
}
|
|
const slug = splitRepositorySlug(row.content.repository)
|
|
if (!slug || !row.content.number) {
|
|
setProjectRowDetailError('This project item cannot be edited from mobile.')
|
|
return
|
|
}
|
|
setProjectMutating(true)
|
|
try {
|
|
const response = await client.sendRequest(
|
|
'github.project.updateIssueBySlug',
|
|
{
|
|
owner: slug.owner,
|
|
repo: slug.repo,
|
|
host: activeGitHubProjectHost,
|
|
number: row.content.number,
|
|
updates
|
|
},
|
|
{ timeoutMs: 30_000 }
|
|
)
|
|
if (!isSuccess(response)) {
|
|
throw new Error(response.error.message)
|
|
}
|
|
const result = response.result as { ok?: boolean; error?: { message?: string } }
|
|
if (result.ok === false) {
|
|
throw new Error(result.error?.message ?? 'Failed to update GitHub item')
|
|
}
|
|
const applyContentUpdate = (candidate: GitHubProjectRow): GitHubProjectRow => {
|
|
const labels = new Map(candidate.content.labels.map((label) => [label.name, label]))
|
|
for (const label of updates.addLabels ?? []) {
|
|
if (!labels.has(label)) {
|
|
labels.set(label, { name: label, color: '808080' })
|
|
}
|
|
}
|
|
for (const label of updates.removeLabels ?? []) {
|
|
labels.delete(label)
|
|
}
|
|
const assignees = new Map(
|
|
candidate.content.assignees.map((assignee) => [assignee.login, assignee])
|
|
)
|
|
for (const login of updates.addAssignees ?? []) {
|
|
if (!assignees.has(login)) {
|
|
assignees.set(login, { login, name: null })
|
|
}
|
|
}
|
|
for (const login of updates.removeAssignees ?? []) {
|
|
assignees.delete(login)
|
|
}
|
|
return {
|
|
...candidate,
|
|
content: {
|
|
...candidate.content,
|
|
labels: [...labels.values()],
|
|
assignees: [...assignees.values()]
|
|
}
|
|
}
|
|
}
|
|
setProjectRowItem((current) =>
|
|
current && current.id === row.id ? applyContentUpdate(current) : current
|
|
)
|
|
setGithubProjectTable((table) =>
|
|
table
|
|
? {
|
|
...table,
|
|
rows: table.rows.map((candidate) =>
|
|
candidate.id === row.id ? applyContentUpdate(candidate) : candidate
|
|
)
|
|
}
|
|
: table
|
|
)
|
|
setProjectRowDetail((current) =>
|
|
current?.provider === 'github'
|
|
? {
|
|
...current,
|
|
labels: [
|
|
...new Set([
|
|
...current.labels.filter(
|
|
(label) => !(updates.removeLabels ?? []).includes(label)
|
|
),
|
|
...(updates.addLabels ?? [])
|
|
])
|
|
],
|
|
assignees: [
|
|
...new Set([
|
|
...current.assignees.filter(
|
|
(login) => !(updates.removeAssignees ?? []).includes(login)
|
|
),
|
|
...(updates.addAssignees ?? [])
|
|
])
|
|
]
|
|
}
|
|
: current
|
|
)
|
|
} catch (err) {
|
|
setProjectRowDetailError(err instanceof Error ? err.message : 'Failed to update item')
|
|
} finally {
|
|
setProjectMutating(false)
|
|
}
|
|
},
|
|
[activeGitHubProjectHost, client, projectMutating]
|
|
)
|
|
|
|
const mutateProjectRowField = useCallback(
|
|
async (
|
|
row: GitHubProjectRow,
|
|
field: GitHubProjectField,
|
|
value: GitHubProjectFieldMutationValue | null
|
|
): Promise<void> => {
|
|
if (!client || !githubProjectTable || projectMutating) {
|
|
return
|
|
}
|
|
setProjectMutating(true)
|
|
try {
|
|
const response = await client.sendRequest(
|
|
value === null ? 'github.project.clearItemField' : 'github.project.updateItemField',
|
|
value === null
|
|
? {
|
|
projectId: githubProjectTable.project.id,
|
|
host: activeGitHubProjectHost,
|
|
itemId: row.id,
|
|
fieldId: field.id
|
|
}
|
|
: {
|
|
projectId: githubProjectTable.project.id,
|
|
host: activeGitHubProjectHost,
|
|
itemId: row.id,
|
|
fieldId: field.id,
|
|
value
|
|
},
|
|
{ timeoutMs: 30_000 }
|
|
)
|
|
if (!isSuccess(response)) {
|
|
throw new Error(response.error.message)
|
|
}
|
|
const result = response.result as { ok?: boolean; error?: { message?: string } }
|
|
if (result.ok === false) {
|
|
throw new Error(result.error?.message ?? 'Failed to update project field')
|
|
}
|
|
const patchRow = (candidate: GitHubProjectRow): GitHubProjectRow => {
|
|
const fieldValuesByFieldId = { ...candidate.fieldValuesByFieldId }
|
|
if (value === null) {
|
|
delete fieldValuesByFieldId[field.id]
|
|
} else {
|
|
fieldValuesByFieldId[field.id] = optimisticProjectFieldValue(field, value)
|
|
}
|
|
return { ...candidate, fieldValuesByFieldId }
|
|
}
|
|
setProjectRowItem((current) =>
|
|
current && current.id === row.id ? patchRow(current) : current
|
|
)
|
|
setGithubProjectTable((table) =>
|
|
table
|
|
? {
|
|
...table,
|
|
rows: table.rows.map((candidate) =>
|
|
candidate.id === row.id ? patchRow(candidate) : candidate
|
|
)
|
|
}
|
|
: table
|
|
)
|
|
if (value === null) {
|
|
setProjectFieldDrafts((current) => ({ ...current, [field.id]: '' }))
|
|
}
|
|
} catch (err) {
|
|
setProjectRowDetailError(
|
|
err instanceof Error ? err.message : 'Failed to update project field'
|
|
)
|
|
} finally {
|
|
setProjectMutating(false)
|
|
}
|
|
},
|
|
[activeGitHubProjectHost, client, githubProjectTable, projectMutating]
|
|
)
|
|
|
|
const mutateProjectRowIssueType = useCallback(
|
|
async (row: GitHubProjectRow, issueType: GitHubIssueType | null): Promise<void> => {
|
|
if (!client || projectMutating) {
|
|
return
|
|
}
|
|
const slug = splitRepositorySlug(row.content.repository)
|
|
if (row.itemType !== 'ISSUE' || !slug || !row.content.number) {
|
|
setProjectRowDetailError('This project issue type cannot be edited from mobile.')
|
|
return
|
|
}
|
|
setProjectMutating(true)
|
|
try {
|
|
const response = await client.sendRequest(
|
|
'github.project.updateIssueTypeBySlug',
|
|
{
|
|
owner: slug.owner,
|
|
repo: slug.repo,
|
|
host: activeGitHubProjectHost,
|
|
number: row.content.number,
|
|
issueTypeId: issueType?.id ?? null
|
|
},
|
|
{ timeoutMs: 30_000 }
|
|
)
|
|
if (!isSuccess(response)) {
|
|
throw new Error(response.error.message)
|
|
}
|
|
const result = response.result as { ok?: boolean; error?: { message?: string } }
|
|
if (result.ok === false) {
|
|
throw new Error(result.error?.message ?? 'Failed to update issue type')
|
|
}
|
|
const patchRow = (candidate: GitHubProjectRow): GitHubProjectRow => ({
|
|
...candidate,
|
|
content: { ...candidate.content, issueType }
|
|
})
|
|
setProjectRowItem((current) =>
|
|
current && current.id === row.id ? patchRow(current) : current
|
|
)
|
|
setGithubProjectTable((table) =>
|
|
table
|
|
? {
|
|
...table,
|
|
rows: table.rows.map((candidate) =>
|
|
candidate.id === row.id ? patchRow(candidate) : candidate
|
|
)
|
|
}
|
|
: table
|
|
)
|
|
} catch (err) {
|
|
setProjectRowDetailError(err instanceof Error ? err.message : 'Failed to update issue type')
|
|
} finally {
|
|
setProjectMutating(false)
|
|
}
|
|
},
|
|
[activeGitHubProjectHost, client, projectMutating]
|
|
)
|
|
|
|
const requestProjectGitHubReviewers = useCallback(
|
|
async (row: GitHubProjectRow, logins?: string[]): Promise<void> => {
|
|
const repo = findProjectRowRepo(row)
|
|
if (!client || projectMutating || row.itemType !== 'PULL_REQUEST' || !repo) {
|
|
return
|
|
}
|
|
const reviewers = logins ?? splitReviewerList(projectReviewersDraft)
|
|
if (reviewers.length === 0 || !row.content.number) {
|
|
return
|
|
}
|
|
setProjectMutating(true)
|
|
setProjectRowDetailError('')
|
|
try {
|
|
const response = await client.sendRequest(
|
|
'github.requestPRReviewers',
|
|
{
|
|
repo: `id:${repo.id}`,
|
|
prNumber: row.content.number,
|
|
prRepo: projectRowGitHubRepository(row, activeGitHubProjectHost),
|
|
reviewers
|
|
},
|
|
{ timeoutMs: 30_000 }
|
|
)
|
|
if (!isSuccess(response)) {
|
|
throw new Error(response.error.message)
|
|
}
|
|
const result = response.result as { ok?: boolean; error?: string }
|
|
if (result.ok === false) {
|
|
throw new Error(result.error ?? 'Failed to request reviewers')
|
|
}
|
|
const nextReviewRequests = (() => {
|
|
const byLogin = new Map<string, GitHubAssignableUser>()
|
|
for (const reviewer of projectRowDetail?.provider === 'github'
|
|
? projectRowDetail.reviewRequests
|
|
: []) {
|
|
const login = reviewer.login.trim()
|
|
if (login) {
|
|
byLogin.set(login.toLowerCase(), reviewer)
|
|
}
|
|
}
|
|
for (const login of reviewers) {
|
|
const normalized = login.trim().replace(/^@/, '')
|
|
if (normalized && !byLogin.has(normalized.toLowerCase())) {
|
|
byLogin.set(normalized.toLowerCase(), {
|
|
login: normalized,
|
|
name: null,
|
|
avatarUrl: null
|
|
})
|
|
}
|
|
}
|
|
return Array.from(byLogin.values())
|
|
})()
|
|
setProjectRowDetail((current) =>
|
|
current?.provider === 'github'
|
|
? { ...current, reviewRequests: nextReviewRequests }
|
|
: current
|
|
)
|
|
if (!logins) {
|
|
setProjectReviewersDraft('')
|
|
}
|
|
} catch (err) {
|
|
setProjectRowDetailError(err instanceof Error ? err.message : 'Failed to request reviewers')
|
|
} finally {
|
|
setProjectMutating(false)
|
|
}
|
|
},
|
|
[
|
|
activeGitHubProjectHost,
|
|
client,
|
|
findProjectRowRepo,
|
|
projectMutating,
|
|
projectReviewersDraft,
|
|
projectRowDetail
|
|
]
|
|
)
|
|
|
|
const refreshProjectGitHubChecks = useCallback(
|
|
async (row: GitHubProjectRow): Promise<void> => {
|
|
const repo = findProjectRowRepo(row)
|
|
if (
|
|
!client ||
|
|
projectMutating ||
|
|
row.itemType !== 'PULL_REQUEST' ||
|
|
!repo ||
|
|
!row.content.number
|
|
) {
|
|
return
|
|
}
|
|
setProjectMutating(true)
|
|
setProjectRowDetailError('')
|
|
try {
|
|
const response = await client.sendRequest(
|
|
'github.prChecks',
|
|
{
|
|
repo: `id:${repo.id}`,
|
|
prNumber: row.content.number,
|
|
prRepo: projectRowGitHubRepository(row, activeGitHubProjectHost),
|
|
headSha: projectRowDetail?.provider === 'github' ? projectRowDetail.headSha : undefined,
|
|
noCache: true
|
|
},
|
|
{ timeoutMs: 30_000 }
|
|
)
|
|
if (!isSuccess(response)) {
|
|
throw new Error(response.error.message)
|
|
}
|
|
if (!Array.isArray(response.result)) {
|
|
throw new Error('Invalid checks response')
|
|
}
|
|
const checks = response.result as GitHubDetailCheck[]
|
|
setProjectRowDetail((current) =>
|
|
current?.provider === 'github' ? { ...current, checks } : current
|
|
)
|
|
} catch (err) {
|
|
setProjectRowDetailError(err instanceof Error ? err.message : 'Failed to refresh checks')
|
|
} finally {
|
|
setProjectMutating(false)
|
|
}
|
|
},
|
|
[activeGitHubProjectHost, client, findProjectRowRepo, projectMutating, projectRowDetail]
|
|
)
|
|
|
|
const rerunProjectGitHubChecks = useCallback(
|
|
async (row: GitHubProjectRow, failedOnly: boolean): Promise<void> => {
|
|
const repo = findProjectRowRepo(row)
|
|
if (
|
|
!client ||
|
|
projectMutating ||
|
|
row.itemType !== 'PULL_REQUEST' ||
|
|
!repo ||
|
|
!row.content.number
|
|
) {
|
|
return
|
|
}
|
|
setProjectMutating(true)
|
|
setProjectRowDetailError('')
|
|
try {
|
|
const response = await client.sendRequest(
|
|
'github.rerunPRChecks',
|
|
{
|
|
repo: `id:${repo.id}`,
|
|
prNumber: row.content.number,
|
|
prRepo: projectRowGitHubRepository(row, activeGitHubProjectHost),
|
|
headSha: projectRowDetail?.provider === 'github' ? projectRowDetail.headSha : undefined,
|
|
failedOnly
|
|
},
|
|
{ timeoutMs: 60_000 }
|
|
)
|
|
if (!isSuccess(response)) {
|
|
throw new Error(response.error.message)
|
|
}
|
|
const result = response.result as { ok?: boolean; error?: string }
|
|
if (result.ok === false) {
|
|
throw new Error(result.error ?? 'Failed to rerun checks')
|
|
}
|
|
setProjectRowDetailRefreshSeq((current) => current + 1)
|
|
} catch (err) {
|
|
setProjectRowDetailError(err instanceof Error ? err.message : 'Failed to rerun checks')
|
|
} finally {
|
|
setProjectMutating(false)
|
|
}
|
|
},
|
|
[activeGitHubProjectHost, client, findProjectRowRepo, projectMutating, projectRowDetail]
|
|
)
|
|
|
|
const toggleProjectGitHubFileViewed = useCallback(
|
|
async (row: GitHubProjectRow, file: GitHubDetailFile): Promise<void> => {
|
|
const repo = findProjectRowRepo(row)
|
|
if (!client || projectMutating || row.itemType !== 'PULL_REQUEST' || !repo) {
|
|
return
|
|
}
|
|
if (projectRowDetail?.provider !== 'github' || !projectRowDetail.pullRequestId) {
|
|
setProjectRowDetailError('Unable to sync viewed state for this pull request.')
|
|
return
|
|
}
|
|
const viewed = file.viewerViewedState !== 'VIEWED'
|
|
setProjectMutating(true)
|
|
setProjectRowDetailError('')
|
|
try {
|
|
const response = await client.sendRequest(
|
|
'github.setPRFileViewed',
|
|
{
|
|
repo: `id:${repo.id}`,
|
|
prRepo: projectRowGitHubRepository(row, activeGitHubProjectHost),
|
|
pullRequestId: projectRowDetail.pullRequestId,
|
|
path: file.path,
|
|
viewed
|
|
},
|
|
{ timeoutMs: 30_000 }
|
|
)
|
|
if (!isSuccess(response)) {
|
|
throw new Error(response.error.message)
|
|
}
|
|
if (response.result !== true) {
|
|
throw new Error('Failed to sync viewed state with GitHub.')
|
|
}
|
|
setProjectRowDetail((current) =>
|
|
current?.provider === 'github'
|
|
? {
|
|
...current,
|
|
files: current.files.map((candidate) =>
|
|
candidate.path === file.path
|
|
? { ...candidate, viewerViewedState: viewed ? 'VIEWED' : 'UNVIEWED' }
|
|
: candidate
|
|
)
|
|
}
|
|
: current
|
|
)
|
|
} catch (err) {
|
|
setProjectRowDetailError(
|
|
err instanceof Error ? err.message : 'Failed to update viewed state'
|
|
)
|
|
} finally {
|
|
setProjectMutating(false)
|
|
}
|
|
},
|
|
[activeGitHubProjectHost, client, findProjectRowRepo, projectMutating, projectRowDetail]
|
|
)
|
|
|
|
const toggleProjectGitHubFileExpansion = useCallback(
|
|
async (row: GitHubProjectRow, file: GitHubDetailFile): Promise<void> => {
|
|
if (expandedPrFilePath === file.path) {
|
|
setExpandedPrFilePath(null)
|
|
return
|
|
}
|
|
setExpandedPrFilePath(file.path)
|
|
if (prFileContents[file.path]) {
|
|
return
|
|
}
|
|
const repo = findProjectRowRepo(row)
|
|
if (
|
|
!client ||
|
|
row.itemType !== 'PULL_REQUEST' ||
|
|
!repo ||
|
|
!row.content.number ||
|
|
projectRowDetail?.provider !== 'github' ||
|
|
!projectRowDetail.headSha ||
|
|
!projectRowDetail.baseSha
|
|
) {
|
|
setProjectRowDetailError('Unable to load file contents for this pull request.')
|
|
return
|
|
}
|
|
setPrFileLoadingPath(file.path)
|
|
setProjectRowDetailError('')
|
|
try {
|
|
const response = await client.sendRequest(
|
|
'github.prFileContents',
|
|
{
|
|
repo: `id:${repo.id}`,
|
|
prNumber: row.content.number,
|
|
prRepo: projectRowGitHubRepository(row, activeGitHubProjectHost),
|
|
path: file.path,
|
|
oldPath: file.oldPath,
|
|
status: file.status ?? 'modified',
|
|
headSha: projectRowDetail.headSha,
|
|
baseSha: projectRowDetail.baseSha
|
|
},
|
|
{ timeoutMs: 30_000 }
|
|
)
|
|
if (!isSuccess(response)) {
|
|
throw new Error(response.error.message)
|
|
}
|
|
setPrFileContents((current) => ({
|
|
...current,
|
|
[file.path]: response.result as GitHubPRFileContents
|
|
}))
|
|
} catch (err) {
|
|
setProjectRowDetailError(
|
|
err instanceof Error ? err.message : 'Failed to load file contents'
|
|
)
|
|
} finally {
|
|
setPrFileLoadingPath(null)
|
|
}
|
|
},
|
|
[
|
|
activeGitHubProjectHost,
|
|
client,
|
|
expandedPrFilePath,
|
|
findProjectRowRepo,
|
|
prFileContents,
|
|
projectRowDetail
|
|
]
|
|
)
|
|
|
|
const addProjectGitHubFileReviewComment = useCallback(
|
|
async (row: GitHubProjectRow, file: GitHubDetailFile, line: number): Promise<void> => {
|
|
const repo = findProjectRowRepo(row)
|
|
if (
|
|
!client ||
|
|
projectMutating ||
|
|
row.itemType !== 'PULL_REQUEST' ||
|
|
!repo ||
|
|
!row.content.number
|
|
) {
|
|
return
|
|
}
|
|
if (projectRowDetail?.provider !== 'github' || !projectRowDetail.headSha) {
|
|
setProjectRowDetailError('Unable to comment without the PR head SHA.')
|
|
return
|
|
}
|
|
const draftKey = `${file.path}:${line}`
|
|
const body = (prFileCommentDrafts[draftKey] ?? '').trim()
|
|
if (!body) {
|
|
return
|
|
}
|
|
setProjectMutating(true)
|
|
setProjectRowDetailError('')
|
|
try {
|
|
const response = await client.sendRequest(
|
|
'github.addPRReviewComment',
|
|
{
|
|
repo: `id:${repo.id}`,
|
|
prNumber: row.content.number,
|
|
prRepo: projectRowGitHubRepository(row, activeGitHubProjectHost),
|
|
commitId: projectRowDetail.headSha,
|
|
path: file.path,
|
|
line,
|
|
body
|
|
},
|
|
{ timeoutMs: 30_000 }
|
|
)
|
|
if (!isSuccess(response)) {
|
|
throw new Error(response.error.message)
|
|
}
|
|
const result = response.result as {
|
|
ok?: boolean
|
|
error?: string
|
|
comment?: DetailComment
|
|
}
|
|
if (result.ok === false) {
|
|
throw new Error(result.error ?? 'Failed to add review comment')
|
|
}
|
|
const comment: DetailComment = result.comment ?? {
|
|
id: `local-${Date.now()}`,
|
|
author: 'You',
|
|
body,
|
|
createdAt: new Date().toISOString(),
|
|
path: file.path,
|
|
line
|
|
}
|
|
setPrFileCommentDrafts((current) => {
|
|
const next = { ...current }
|
|
delete next[draftKey]
|
|
return next
|
|
})
|
|
setProjectRowDetail((current) =>
|
|
current?.provider === 'github'
|
|
? { ...current, comments: [...current.comments, comment] }
|
|
: current
|
|
)
|
|
} catch (err) {
|
|
setProjectRowDetailError(
|
|
err instanceof Error ? err.message : 'Failed to add review comment'
|
|
)
|
|
} finally {
|
|
setProjectMutating(false)
|
|
}
|
|
},
|
|
[
|
|
activeGitHubProjectHost,
|
|
client,
|
|
findProjectRowRepo,
|
|
prFileCommentDrafts,
|
|
projectMutating,
|
|
projectRowDetail
|
|
]
|
|
)
|
|
|
|
const mergeProjectGitHubPullRequest = useCallback(
|
|
async (row: GitHubProjectRow, method: HostedReviewMergeMethod): Promise<void> => {
|
|
const repo = findProjectRowRepo(row)
|
|
if (
|
|
!client ||
|
|
projectMutating ||
|
|
row.itemType !== 'PULL_REQUEST' ||
|
|
!repo ||
|
|
!row.content.number
|
|
) {
|
|
return
|
|
}
|
|
if (row.content.state === 'CLOSED' || row.content.state === 'MERGED') {
|
|
return
|
|
}
|
|
setProjectMutating(true)
|
|
setProjectRowDetailError('')
|
|
try {
|
|
const response = await client.sendRequest(
|
|
'github.mergePR',
|
|
{
|
|
repo: `id:${repo.id}`,
|
|
prNumber: row.content.number,
|
|
prRepo: projectRowGitHubRepository(row, activeGitHubProjectHost),
|
|
method
|
|
},
|
|
{ timeoutMs: 60_000 }
|
|
)
|
|
if (!isSuccess(response)) {
|
|
throw new Error(response.error.message)
|
|
}
|
|
const result = response.result as { ok?: boolean; error?: string }
|
|
if (result.ok === false) {
|
|
throw new Error(result.error ?? 'Failed to merge pull request')
|
|
}
|
|
setProjectRowItem((current) =>
|
|
current?.id === row.id
|
|
? { ...current, content: { ...current.content, state: 'MERGED' } }
|
|
: current
|
|
)
|
|
setGithubProjectTable((table) =>
|
|
table
|
|
? {
|
|
...table,
|
|
rows: table.rows.map((candidate) =>
|
|
candidate.id === row.id
|
|
? { ...candidate, content: { ...candidate.content, state: 'MERGED' } }
|
|
: candidate
|
|
)
|
|
}
|
|
: table
|
|
)
|
|
} catch (err) {
|
|
setProjectRowDetailError(
|
|
err instanceof Error ? err.message : 'Failed to merge pull request'
|
|
)
|
|
} finally {
|
|
setProjectMutating(false)
|
|
}
|
|
},
|
|
[activeGitHubProjectHost, client, findProjectRowRepo, projectMutating]
|
|
)
|
|
|
|
const toggleGitHubStatus = useCallback(
|
|
async (item: Extract<TaskItem, { provider: 'github' }>): Promise<void> => {
|
|
if (!client || mutatingStatus || item.source.state === 'merged') {
|
|
return
|
|
}
|
|
setMutatingStatus(true)
|
|
setError('')
|
|
const nextState = item.source.state === 'closed' ? 'open' : 'closed'
|
|
try {
|
|
const method = item.source.type === 'issue' ? 'github.updateIssue' : 'github.updatePRState'
|
|
const params =
|
|
item.source.type === 'issue'
|
|
? {
|
|
repo: `id:${item.source.repoId}`,
|
|
number: item.source.number,
|
|
updates: { state: nextState }
|
|
}
|
|
: {
|
|
repo: `id:${item.source.repoId}`,
|
|
prNumber: item.source.number,
|
|
updates: { state: nextState }
|
|
}
|
|
const response = await client.sendRequest(method, params)
|
|
if (!isSuccess(response)) {
|
|
throw new Error(response.error.message)
|
|
}
|
|
const result = response.result as { ok?: boolean; error?: string }
|
|
if (result.ok === false) {
|
|
throw new Error(result.error ?? 'Failed to update GitHub status')
|
|
}
|
|
setActionItem(null)
|
|
await loadTasks({ silent: true })
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Failed to update status')
|
|
} finally {
|
|
setMutatingStatus(false)
|
|
}
|
|
},
|
|
[client, loadTasks, mutatingStatus]
|
|
)
|
|
|
|
const toggleGitLabStatus = useCallback(
|
|
async (item: Extract<TaskItem, { provider: 'gitlab' }>): Promise<void> => {
|
|
if (!client || mutatingStatus || item.source.state === 'merged') {
|
|
return
|
|
}
|
|
setMutatingStatus(true)
|
|
setError('')
|
|
const nextState = item.source.state === 'closed' ? 'opened' : 'closed'
|
|
try {
|
|
const response =
|
|
item.source.type === 'issue'
|
|
? await client.sendRequest('gitlab.updateIssue', {
|
|
repo: `id:${item.source.repoId}`,
|
|
number: item.source.number,
|
|
updates: { state: nextState },
|
|
projectRef: item.source.projectRef
|
|
})
|
|
: await client.sendRequest('gitlab.updateMRState', {
|
|
repo: `id:${item.source.repoId}`,
|
|
iid: item.source.number,
|
|
state: nextState,
|
|
projectRef: item.source.projectRef
|
|
})
|
|
if (!isSuccess(response)) {
|
|
throw new Error(response.error.message)
|
|
}
|
|
const result = response.result as { ok?: boolean; error?: string }
|
|
if (result.ok === false) {
|
|
throw new Error(result.error ?? 'Failed to update GitLab item')
|
|
}
|
|
setActionItem(null)
|
|
await loadTasks({ silent: true })
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Failed to update GitLab item')
|
|
} finally {
|
|
setMutatingStatus(false)
|
|
}
|
|
},
|
|
[client, loadTasks, mutatingStatus]
|
|
)
|
|
|
|
const updateGitHubIssueMetadata = useCallback(
|
|
async (
|
|
item: Extract<TaskItem, { provider: 'github' }>,
|
|
updates: {
|
|
title?: string
|
|
body?: string
|
|
addLabels?: string[]
|
|
removeLabels?: string[]
|
|
addAssignees?: string[]
|
|
removeAssignees?: string[]
|
|
}
|
|
): Promise<void> => {
|
|
if (!client || mutatingStatus) {
|
|
return
|
|
}
|
|
setMutatingStatus(true)
|
|
setError('')
|
|
try {
|
|
const response = await client.sendRequest(
|
|
'github.updateIssue',
|
|
{
|
|
repo: `id:${item.source.repoId}`,
|
|
number: item.source.number,
|
|
updates
|
|
},
|
|
{ timeoutMs: 30_000 }
|
|
)
|
|
if (!isSuccess(response)) {
|
|
throw new Error(response.error.message)
|
|
}
|
|
const result = response.result as { ok?: boolean; error?: string }
|
|
if (result.ok === false) {
|
|
throw new Error(result.error ?? 'Failed to update GitHub issue')
|
|
}
|
|
|
|
const nextLabels = [
|
|
...new Set([
|
|
...(detailPayload?.provider === 'github'
|
|
? detailPayload.labels.filter(
|
|
(label) => !(updates.removeLabels ?? []).includes(label)
|
|
)
|
|
: item.source.labels.filter(
|
|
(label) => !(updates.removeLabels ?? []).includes(label)
|
|
)),
|
|
...(updates.addLabels ?? [])
|
|
])
|
|
]
|
|
const nextAssignees =
|
|
detailPayload?.provider === 'github'
|
|
? [
|
|
...new Set([
|
|
...detailPayload.assignees.filter(
|
|
(login) => !(updates.removeAssignees ?? []).includes(login)
|
|
),
|
|
...(updates.addAssignees ?? [])
|
|
])
|
|
]
|
|
: undefined
|
|
const nextTitle = updates.title?.trim()
|
|
setActionItem((current) =>
|
|
current?.provider === 'github' && current.source.id === item.source.id
|
|
? {
|
|
...current,
|
|
...(nextTitle ? { title: nextTitle } : {}),
|
|
source: {
|
|
...current.source,
|
|
...(nextTitle ? { title: nextTitle } : {}),
|
|
labels: nextLabels
|
|
}
|
|
}
|
|
: current
|
|
)
|
|
setItems((current) =>
|
|
current.map((candidate) =>
|
|
candidate.provider === 'github' && candidate.source.id === item.source.id
|
|
? {
|
|
...candidate,
|
|
...(nextTitle ? { title: nextTitle } : {}),
|
|
source: {
|
|
...candidate.source,
|
|
...(nextTitle ? { title: nextTitle } : {}),
|
|
labels: nextLabels
|
|
}
|
|
}
|
|
: candidate
|
|
)
|
|
)
|
|
setDetailPayload((current) =>
|
|
current?.provider === 'github'
|
|
? {
|
|
...current,
|
|
labels: nextLabels,
|
|
...(updates.body !== undefined ? { body: updates.body } : {}),
|
|
...(nextAssignees ? { assignees: nextAssignees } : {})
|
|
}
|
|
: current
|
|
)
|
|
setItemAddLabelsDraft('')
|
|
setItemRemoveLabelsDraft('')
|
|
setItemAddAssigneesDraft('')
|
|
setItemRemoveAssigneesDraft('')
|
|
await loadTasks({ silent: true })
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Failed to update GitHub issue')
|
|
} finally {
|
|
setMutatingStatus(false)
|
|
}
|
|
},
|
|
[client, detailPayload, loadTasks, mutatingStatus]
|
|
)
|
|
|
|
const updateGitHubPullRequestMetadata = useCallback(
|
|
async (
|
|
item: Extract<TaskItem, { provider: 'github' }>,
|
|
updates: { title?: string; body?: string }
|
|
): Promise<void> => {
|
|
if (!client || mutatingStatus || item.source.type !== 'pr') {
|
|
return
|
|
}
|
|
const nextTitle = updates.title?.trim()
|
|
if (updates.title !== undefined && !nextTitle) {
|
|
return
|
|
}
|
|
setMutatingStatus(true)
|
|
setError('')
|
|
try {
|
|
const response = await client.sendRequest(
|
|
'github.updatePR',
|
|
{
|
|
repo: `id:${item.source.repoId}`,
|
|
prNumber: item.source.number,
|
|
updates: {
|
|
...(nextTitle !== undefined ? { title: nextTitle } : {}),
|
|
...(updates.body !== undefined ? { body: updates.body } : {})
|
|
}
|
|
},
|
|
{ timeoutMs: 30_000 }
|
|
)
|
|
if (!isSuccess(response)) {
|
|
throw new Error(response.error.message)
|
|
}
|
|
const result = response.result as { ok?: boolean; error?: string }
|
|
if (result.ok === false) {
|
|
throw new Error(result.error ?? 'Failed to update GitHub pull request')
|
|
}
|
|
if (nextTitle !== undefined) {
|
|
setActionItem((current) =>
|
|
current?.provider === 'github' && current.source.id === item.source.id
|
|
? {
|
|
...current,
|
|
title: nextTitle,
|
|
source: { ...current.source, title: nextTitle }
|
|
}
|
|
: current
|
|
)
|
|
setItems((current) =>
|
|
current.map((candidate) =>
|
|
candidate.provider === 'github' && candidate.source.id === item.source.id
|
|
? {
|
|
...candidate,
|
|
title: nextTitle,
|
|
source: { ...candidate.source, title: nextTitle }
|
|
}
|
|
: candidate
|
|
)
|
|
)
|
|
}
|
|
if (updates.body !== undefined) {
|
|
setDetailPayload((current) =>
|
|
current?.provider === 'github' ? { ...current, body: updates.body ?? '' } : current
|
|
)
|
|
}
|
|
await loadTasks({ silent: true })
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Failed to update GitHub pull request')
|
|
} finally {
|
|
setMutatingStatus(false)
|
|
}
|
|
},
|
|
[client, loadTasks, mutatingStatus]
|
|
)
|
|
|
|
const updateGitLabIssueMetadata = useCallback(
|
|
async (
|
|
item: Extract<TaskItem, { provider: 'gitlab' }>,
|
|
updates: {
|
|
title?: string
|
|
body?: string
|
|
addLabels?: string[]
|
|
removeLabels?: string[]
|
|
addAssignees?: string[]
|
|
removeAssignees?: string[]
|
|
}
|
|
): Promise<void> => {
|
|
if (!client || mutatingStatus) {
|
|
return
|
|
}
|
|
setMutatingStatus(true)
|
|
setError('')
|
|
try {
|
|
const method = item.source.type === 'issue' ? 'gitlab.updateIssue' : 'gitlab.updateMR'
|
|
const params =
|
|
item.source.type === 'issue'
|
|
? {
|
|
repo: `id:${item.source.repoId}`,
|
|
number: item.source.number,
|
|
updates,
|
|
projectRef: item.source.projectRef
|
|
}
|
|
: {
|
|
repo: `id:${item.source.repoId}`,
|
|
iid: item.source.number,
|
|
projectRef: item.source.projectRef,
|
|
updates: {
|
|
title: updates.title,
|
|
body: updates.body,
|
|
addLabels: updates.addLabels,
|
|
removeLabels: updates.removeLabels
|
|
}
|
|
}
|
|
const response = await client.sendRequest(method, params, { timeoutMs: 30_000 })
|
|
if (!isSuccess(response)) {
|
|
throw new Error(response.error.message)
|
|
}
|
|
const result = response.result as { ok?: boolean; error?: string }
|
|
if (result.ok === false) {
|
|
throw new Error(result.error ?? 'Failed to update GitLab item')
|
|
}
|
|
const nextLabels = [
|
|
...new Set([
|
|
...(detailPayload?.provider === 'gitlab'
|
|
? detailPayload.labels.filter(
|
|
(label) => !(updates.removeLabels ?? []).includes(label)
|
|
)
|
|
: item.source.labels.filter(
|
|
(label) => !(updates.removeLabels ?? []).includes(label)
|
|
)),
|
|
...(updates.addLabels ?? [])
|
|
])
|
|
]
|
|
const nextAssignees =
|
|
detailPayload?.provider === 'gitlab'
|
|
? [
|
|
...new Set([
|
|
...detailPayload.assignees.filter(
|
|
(login) => !(updates.removeAssignees ?? []).includes(login)
|
|
),
|
|
...(updates.addAssignees ?? [])
|
|
])
|
|
]
|
|
: undefined
|
|
const nextTitle = updates.title?.trim()
|
|
setActionItem((current) =>
|
|
current?.provider === 'gitlab' && current.source.id === item.source.id
|
|
? {
|
|
...current,
|
|
...(nextTitle ? { title: nextTitle } : {}),
|
|
source: {
|
|
...current.source,
|
|
...(nextTitle ? { title: nextTitle } : {}),
|
|
labels: nextLabels
|
|
}
|
|
}
|
|
: current
|
|
)
|
|
setItems((current) =>
|
|
current.map((candidate) =>
|
|
candidate.provider === 'gitlab' && candidate.source.id === item.source.id
|
|
? {
|
|
...candidate,
|
|
...(nextTitle ? { title: nextTitle } : {}),
|
|
source: {
|
|
...candidate.source,
|
|
...(nextTitle ? { title: nextTitle } : {}),
|
|
labels: nextLabels
|
|
}
|
|
}
|
|
: candidate
|
|
)
|
|
)
|
|
setDetailPayload((current) =>
|
|
current?.provider === 'gitlab'
|
|
? {
|
|
...current,
|
|
labels: nextLabels,
|
|
...(updates.body !== undefined ? { body: updates.body } : {}),
|
|
...(nextAssignees ? { assignees: nextAssignees } : {})
|
|
}
|
|
: current
|
|
)
|
|
setItemAddLabelsDraft('')
|
|
setItemRemoveLabelsDraft('')
|
|
if (item.source.type === 'issue') {
|
|
setItemAddAssigneesDraft('')
|
|
setItemRemoveAssigneesDraft('')
|
|
}
|
|
await loadTasks({ silent: true })
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Failed to update GitLab item')
|
|
} finally {
|
|
setMutatingStatus(false)
|
|
}
|
|
},
|
|
[client, detailPayload, loadTasks, mutatingStatus]
|
|
)
|
|
|
|
const addHostedItemComment = useCallback(
|
|
async (
|
|
item: Extract<TaskItem, { provider: 'github' }> | Extract<TaskItem, { provider: 'gitlab' }>
|
|
): Promise<void> => {
|
|
if (!client || mutatingStatus) {
|
|
return
|
|
}
|
|
const body = itemCommentDraft.trim()
|
|
if (!body) {
|
|
return
|
|
}
|
|
setMutatingStatus(true)
|
|
setError('')
|
|
try {
|
|
const response =
|
|
item.provider === 'github'
|
|
? await client.sendRequest(
|
|
'github.addIssueComment',
|
|
{
|
|
repo: `id:${item.source.repoId}`,
|
|
number: item.source.number,
|
|
body,
|
|
type: item.source.type
|
|
},
|
|
{ timeoutMs: 30_000 }
|
|
)
|
|
: await client.sendRequest(
|
|
item.source.type === 'mr' ? 'gitlab.addMRComment' : 'gitlab.addIssueComment',
|
|
item.source.type === 'mr'
|
|
? {
|
|
repo: `id:${item.source.repoId}`,
|
|
iid: item.source.number,
|
|
body,
|
|
projectRef: item.source.projectRef
|
|
}
|
|
: {
|
|
repo: `id:${item.source.repoId}`,
|
|
number: item.source.number,
|
|
body,
|
|
projectRef: item.source.projectRef
|
|
},
|
|
{ timeoutMs: 30_000 }
|
|
)
|
|
if (!isSuccess(response)) {
|
|
throw new Error(response.error.message)
|
|
}
|
|
const result = response.result as {
|
|
ok?: boolean
|
|
error?: string
|
|
comment?: DetailComment
|
|
}
|
|
if (result.ok === false) {
|
|
throw new Error(result.error ?? 'Failed to add comment')
|
|
}
|
|
const comment: DetailComment = result.comment ?? {
|
|
id: `local-${Date.now()}`,
|
|
body,
|
|
createdAt: new Date().toISOString(),
|
|
author: 'You'
|
|
}
|
|
setItemCommentDraft('')
|
|
setDetailPayload((current) =>
|
|
current &&
|
|
((item.provider === 'github' && current.provider === 'github') ||
|
|
(item.provider === 'gitlab' && current.provider === 'gitlab'))
|
|
? { ...current, comments: [...current.comments, comment] }
|
|
: current
|
|
)
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Failed to add comment')
|
|
} finally {
|
|
setMutatingStatus(false)
|
|
}
|
|
},
|
|
[client, itemCommentDraft, mutatingStatus]
|
|
)
|
|
|
|
const copyTaskLink = useCallback(async (key: string, url: string): Promise<void> => {
|
|
try {
|
|
await Clipboard.setStringAsync(url)
|
|
setCopiedLinkKey(key)
|
|
scheduleMobileTaskCopyFeedbackReset(copiedLinkResetTimerRef, key, setCopiedLinkKey)
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Failed to copy link')
|
|
}
|
|
}, [])
|
|
|
|
const copyTextToClipboard = useCallback(async (key: string, value: string): Promise<void> => {
|
|
try {
|
|
await Clipboard.setStringAsync(value)
|
|
setCopiedLinkKey(key)
|
|
scheduleMobileTaskCopyFeedbackReset(copiedLinkResetTimerRef, key, setCopiedLinkKey)
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Failed to copy text')
|
|
}
|
|
}, [])
|
|
|
|
const requestGitHubReviewers = useCallback(
|
|
async (item: Extract<TaskItem, { provider: 'github' }>, logins?: string[]): Promise<void> => {
|
|
if (!client || mutatingStatus || item.source.type !== 'pr') {
|
|
return
|
|
}
|
|
const reviewers = logins ?? splitReviewerList(itemReviewersDraft)
|
|
if (reviewers.length === 0) {
|
|
return
|
|
}
|
|
setMutatingStatus(true)
|
|
setError('')
|
|
try {
|
|
const response = await client.sendRequest(
|
|
'github.requestPRReviewers',
|
|
{
|
|
repo: `id:${item.source.repoId}`,
|
|
prNumber: item.source.number,
|
|
reviewers
|
|
},
|
|
{ timeoutMs: 30_000 }
|
|
)
|
|
if (!isSuccess(response)) {
|
|
throw new Error(response.error.message)
|
|
}
|
|
const result = response.result as { ok?: boolean; error?: string }
|
|
if (result.ok === false) {
|
|
throw new Error(result.error ?? 'Failed to request reviewers')
|
|
}
|
|
const nextReviewRequests = (() => {
|
|
const byLogin = new Map<string, GitHubAssignableUser>()
|
|
for (const reviewer of detailPayload?.provider === 'github'
|
|
? detailPayload.reviewRequests
|
|
: (item.source.reviewRequests ?? [])) {
|
|
const login = reviewer.login.trim()
|
|
if (login) {
|
|
byLogin.set(login.toLowerCase(), reviewer)
|
|
}
|
|
}
|
|
for (const login of reviewers) {
|
|
const normalized = login.trim().replace(/^@/, '')
|
|
if (normalized && !byLogin.has(normalized.toLowerCase())) {
|
|
byLogin.set(normalized.toLowerCase(), {
|
|
login: normalized,
|
|
name: null,
|
|
avatarUrl: null
|
|
})
|
|
}
|
|
}
|
|
return Array.from(byLogin.values())
|
|
})()
|
|
setActionItem((current) =>
|
|
current?.provider === 'github' && current.source.id === item.source.id
|
|
? {
|
|
...current,
|
|
source: { ...current.source, reviewRequests: nextReviewRequests }
|
|
}
|
|
: current
|
|
)
|
|
setItems((current) =>
|
|
current.map((candidate) =>
|
|
candidate.provider === 'github' && candidate.source.id === item.source.id
|
|
? {
|
|
...candidate,
|
|
source: { ...candidate.source, reviewRequests: nextReviewRequests }
|
|
}
|
|
: candidate
|
|
)
|
|
)
|
|
setDetailPayload((current) =>
|
|
current?.provider === 'github'
|
|
? { ...current, reviewRequests: nextReviewRequests }
|
|
: current
|
|
)
|
|
if (!logins) {
|
|
setItemReviewersDraft('')
|
|
}
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Failed to request reviewers')
|
|
} finally {
|
|
setMutatingStatus(false)
|
|
}
|
|
},
|
|
[client, detailPayload, itemReviewersDraft, mutatingStatus]
|
|
)
|
|
|
|
const refreshGitHubChecks = useCallback(
|
|
async (item: Extract<TaskItem, { provider: 'github' }>): Promise<void> => {
|
|
if (!client || mutatingStatus || item.source.type !== 'pr') {
|
|
return
|
|
}
|
|
setMutatingStatus(true)
|
|
setError('')
|
|
try {
|
|
const response = await client.sendRequest(
|
|
'github.prChecks',
|
|
{
|
|
repo: `id:${item.source.repoId}`,
|
|
prNumber: item.source.number,
|
|
headSha: detailPayload?.provider === 'github' ? detailPayload.headSha : undefined,
|
|
noCache: true
|
|
},
|
|
{ timeoutMs: 30_000 }
|
|
)
|
|
if (!isSuccess(response)) {
|
|
throw new Error(response.error.message)
|
|
}
|
|
if (!Array.isArray(response.result)) {
|
|
throw new Error('Invalid checks response')
|
|
}
|
|
const checks = response.result as GitHubDetailCheck[]
|
|
const checksSummary = buildGitHubCheckSummary(checks)
|
|
setDetailPayload((current) =>
|
|
current?.provider === 'github' ? { ...current, checks } : current
|
|
)
|
|
setActionItem((current) =>
|
|
current?.provider === 'github' && current.source.id === item.source.id
|
|
? {
|
|
...current,
|
|
source: { ...current.source, checksSummary }
|
|
}
|
|
: current
|
|
)
|
|
setItems((current) =>
|
|
current.map((candidate) =>
|
|
candidate.provider === 'github' && candidate.source.id === item.source.id
|
|
? {
|
|
...candidate,
|
|
source: { ...candidate.source, checksSummary }
|
|
}
|
|
: candidate
|
|
)
|
|
)
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Failed to refresh checks')
|
|
} finally {
|
|
setMutatingStatus(false)
|
|
}
|
|
},
|
|
[client, detailPayload, mutatingStatus]
|
|
)
|
|
|
|
const rerunGitHubChecks = useCallback(
|
|
async (item: Extract<TaskItem, { provider: 'github' }>, failedOnly: boolean): Promise<void> => {
|
|
if (!client || mutatingStatus || item.source.type !== 'pr') {
|
|
return
|
|
}
|
|
setMutatingStatus(true)
|
|
setError('')
|
|
try {
|
|
const response = await client.sendRequest(
|
|
'github.rerunPRChecks',
|
|
{
|
|
repo: `id:${item.source.repoId}`,
|
|
prNumber: item.source.number,
|
|
headSha: detailPayload?.provider === 'github' ? detailPayload.headSha : undefined,
|
|
failedOnly
|
|
},
|
|
{ timeoutMs: 60_000 }
|
|
)
|
|
if (!isSuccess(response)) {
|
|
throw new Error(response.error.message)
|
|
}
|
|
const result = response.result as { ok?: boolean; error?: string }
|
|
if (result.ok === false) {
|
|
throw new Error(result.error ?? 'Failed to rerun checks')
|
|
}
|
|
setDetailRefreshSeq((current) => current + 1)
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Failed to rerun checks')
|
|
} finally {
|
|
setMutatingStatus(false)
|
|
}
|
|
},
|
|
[client, detailPayload, mutatingStatus]
|
|
)
|
|
|
|
const toggleGitHubFileViewed = useCallback(
|
|
async (
|
|
item: Extract<TaskItem, { provider: 'github' }>,
|
|
file: NonNullable<Extract<DetailPayload, { provider: 'github' }>['files'][number]>
|
|
): Promise<void> => {
|
|
if (!client || mutatingStatus || item.source.type !== 'pr') {
|
|
return
|
|
}
|
|
if (detailPayload?.provider !== 'github' || !detailPayload.pullRequestId) {
|
|
setError('Unable to sync viewed state for this pull request.')
|
|
return
|
|
}
|
|
const viewed = file.viewerViewedState !== 'VIEWED'
|
|
setMutatingStatus(true)
|
|
setError('')
|
|
try {
|
|
const response = await client.sendRequest(
|
|
'github.setPRFileViewed',
|
|
{
|
|
repo: `id:${item.source.repoId}`,
|
|
pullRequestId: detailPayload.pullRequestId,
|
|
path: file.path,
|
|
viewed
|
|
},
|
|
{ timeoutMs: 30_000 }
|
|
)
|
|
if (!isSuccess(response)) {
|
|
throw new Error(response.error.message)
|
|
}
|
|
if (response.result !== true) {
|
|
throw new Error('Failed to sync viewed state with GitHub.')
|
|
}
|
|
setDetailPayload((current) =>
|
|
current?.provider === 'github'
|
|
? {
|
|
...current,
|
|
files: current.files.map((candidate) =>
|
|
candidate.path === file.path
|
|
? { ...candidate, viewerViewedState: viewed ? 'VIEWED' : 'UNVIEWED' }
|
|
: candidate
|
|
)
|
|
}
|
|
: current
|
|
)
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Failed to update viewed state')
|
|
} finally {
|
|
setMutatingStatus(false)
|
|
}
|
|
},
|
|
[client, detailPayload, mutatingStatus]
|
|
)
|
|
|
|
const toggleGitHubReviewThread = useCallback(
|
|
async (
|
|
item: Extract<TaskItem, { provider: 'github' }>,
|
|
comment: DetailComment
|
|
): Promise<void> => {
|
|
if (!client || mutatingStatus || item.source.type !== 'pr' || !comment.threadId) {
|
|
return
|
|
}
|
|
const resolve = !comment.isResolved
|
|
setMutatingStatus(true)
|
|
setError('')
|
|
try {
|
|
const response = await client.sendRequest(
|
|
'github.resolveReviewThread',
|
|
{
|
|
repo: `id:${item.source.repoId}`,
|
|
threadId: comment.threadId,
|
|
resolve
|
|
},
|
|
{ timeoutMs: 30_000 }
|
|
)
|
|
if (!isSuccess(response)) {
|
|
throw new Error(response.error.message)
|
|
}
|
|
if (response.result !== true) {
|
|
throw new Error(resolve ? 'Failed to resolve thread' : 'Failed to reopen thread')
|
|
}
|
|
setDetailPayload((current) =>
|
|
current?.provider === 'github'
|
|
? {
|
|
...current,
|
|
comments: current.comments.map((candidate) =>
|
|
candidate.threadId === comment.threadId
|
|
? { ...candidate, isResolved: resolve }
|
|
: candidate
|
|
)
|
|
}
|
|
: current
|
|
)
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Failed to update review thread')
|
|
} finally {
|
|
setMutatingStatus(false)
|
|
}
|
|
},
|
|
[client, mutatingStatus]
|
|
)
|
|
|
|
const toggleGitHubFileExpansion = useCallback(
|
|
async (
|
|
item: Extract<TaskItem, { provider: 'github' }>,
|
|
file: GitHubDetailFile
|
|
): Promise<void> => {
|
|
if (expandedPrFilePath === file.path) {
|
|
setExpandedPrFilePath(null)
|
|
return
|
|
}
|
|
setExpandedPrFilePath(file.path)
|
|
if (prFileContents[file.path]) {
|
|
return
|
|
}
|
|
if (
|
|
!client ||
|
|
item.source.type !== 'pr' ||
|
|
detailPayload?.provider !== 'github' ||
|
|
!detailPayload.headSha ||
|
|
!detailPayload.baseSha
|
|
) {
|
|
setError('Unable to load file contents for this pull request.')
|
|
return
|
|
}
|
|
setPrFileLoadingPath(file.path)
|
|
setError('')
|
|
try {
|
|
const response = await client.sendRequest(
|
|
'github.prFileContents',
|
|
{
|
|
repo: `id:${item.source.repoId}`,
|
|
prNumber: item.source.number,
|
|
path: file.path,
|
|
oldPath: file.oldPath,
|
|
status: file.status ?? 'modified',
|
|
headSha: detailPayload.headSha,
|
|
baseSha: detailPayload.baseSha
|
|
},
|
|
{ timeoutMs: 30_000 }
|
|
)
|
|
if (!isSuccess(response)) {
|
|
throw new Error(response.error.message)
|
|
}
|
|
setPrFileContents((current) => ({
|
|
...current,
|
|
[file.path]: response.result as GitHubPRFileContents
|
|
}))
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Failed to load file contents')
|
|
} finally {
|
|
setPrFileLoadingPath(null)
|
|
}
|
|
},
|
|
[client, detailPayload, expandedPrFilePath, prFileContents]
|
|
)
|
|
|
|
const addGitHubFileReviewComment = useCallback(
|
|
async (
|
|
item: Extract<TaskItem, { provider: 'github' }>,
|
|
file: GitHubDetailFile,
|
|
line: number
|
|
): Promise<void> => {
|
|
if (!client || mutatingStatus || item.source.type !== 'pr') {
|
|
return
|
|
}
|
|
if (detailPayload?.provider !== 'github' || !detailPayload.headSha) {
|
|
setError('Unable to comment without the PR head SHA.')
|
|
return
|
|
}
|
|
const draftKey = `${file.path}:${line}`
|
|
const body = (prFileCommentDrafts[draftKey] ?? '').trim()
|
|
if (!body) {
|
|
return
|
|
}
|
|
setMutatingStatus(true)
|
|
setError('')
|
|
try {
|
|
const response = await client.sendRequest(
|
|
'github.addPRReviewComment',
|
|
{
|
|
repo: `id:${item.source.repoId}`,
|
|
prNumber: item.source.number,
|
|
commitId: detailPayload.headSha,
|
|
path: file.path,
|
|
line,
|
|
body
|
|
},
|
|
{ timeoutMs: 30_000 }
|
|
)
|
|
if (!isSuccess(response)) {
|
|
throw new Error(response.error.message)
|
|
}
|
|
const result = response.result as {
|
|
ok?: boolean
|
|
error?: string
|
|
comment?: DetailComment
|
|
}
|
|
if (result.ok === false) {
|
|
throw new Error(result.error ?? 'Failed to add review comment')
|
|
}
|
|
const comment: DetailComment = result.comment ?? {
|
|
id: `local-${Date.now()}`,
|
|
author: 'You',
|
|
body,
|
|
createdAt: new Date().toISOString(),
|
|
path: file.path,
|
|
line
|
|
}
|
|
setPrFileCommentDrafts((current) => {
|
|
const next = { ...current }
|
|
delete next[draftKey]
|
|
return next
|
|
})
|
|
setDetailPayload((current) =>
|
|
current?.provider === 'github'
|
|
? { ...current, comments: [...current.comments, comment] }
|
|
: current
|
|
)
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Failed to add review comment')
|
|
} finally {
|
|
setMutatingStatus(false)
|
|
}
|
|
},
|
|
[client, detailPayload, mutatingStatus, prFileCommentDrafts]
|
|
)
|
|
|
|
const replyToGitHubComment = useCallback(
|
|
async (
|
|
item: Extract<TaskItem, { provider: 'github' }>,
|
|
comment: DetailComment
|
|
): Promise<void> => {
|
|
if (!client || mutatingStatus) {
|
|
return
|
|
}
|
|
const key = String(comment.id)
|
|
const body = (itemReplyDrafts[key] ?? '').trim()
|
|
if (!body) {
|
|
return
|
|
}
|
|
setMutatingStatus(true)
|
|
setError('')
|
|
try {
|
|
const canUseReviewReply =
|
|
item.source.type === 'pr' &&
|
|
comment.path &&
|
|
typeof comment.line === 'number' &&
|
|
typeof comment.id === 'number'
|
|
const response = canUseReviewReply
|
|
? await client.sendRequest(
|
|
'github.addPRReviewCommentReply',
|
|
{
|
|
repo: `id:${item.source.repoId}`,
|
|
prNumber: item.source.number,
|
|
commentId: comment.id,
|
|
body,
|
|
threadId: comment.threadId,
|
|
path: comment.path,
|
|
line: comment.line
|
|
},
|
|
{ timeoutMs: 30_000 }
|
|
)
|
|
: await client.sendRequest(
|
|
'github.addIssueComment',
|
|
{
|
|
repo: `id:${item.source.repoId}`,
|
|
number: item.source.number,
|
|
body: `@${commentAuthor(comment)} ${body}`,
|
|
type: item.source.type
|
|
},
|
|
{ timeoutMs: 30_000 }
|
|
)
|
|
if (!isSuccess(response)) {
|
|
throw new Error(response.error.message)
|
|
}
|
|
const result = response.result as {
|
|
ok?: boolean
|
|
error?: string
|
|
comment?: DetailComment
|
|
}
|
|
if (result.ok === false) {
|
|
throw new Error(result.error ?? 'Failed to reply')
|
|
}
|
|
const reply: DetailComment = result.comment ?? {
|
|
id: `local-${Date.now()}`,
|
|
body,
|
|
createdAt: new Date().toISOString(),
|
|
author: 'You',
|
|
path: comment.path,
|
|
line: comment.line,
|
|
threadId: comment.threadId
|
|
}
|
|
setItemReplyDrafts((current) => {
|
|
const next = { ...current }
|
|
delete next[key]
|
|
return next
|
|
})
|
|
setDetailPayload((current) =>
|
|
current?.provider === 'github'
|
|
? { ...current, comments: [...current.comments, reply] }
|
|
: current
|
|
)
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Failed to reply')
|
|
} finally {
|
|
setMutatingStatus(false)
|
|
}
|
|
},
|
|
[client, itemReplyDrafts, mutatingStatus]
|
|
)
|
|
|
|
const mergeHostedReview = useCallback(
|
|
async (
|
|
item: Extract<TaskItem, { provider: 'github' }> | Extract<TaskItem, { provider: 'gitlab' }>,
|
|
method: HostedReviewMergeMethod
|
|
): Promise<void> => {
|
|
if (!client || mutatingStatus) {
|
|
return
|
|
}
|
|
if (item.provider === 'github' && item.source.type !== 'pr') {
|
|
return
|
|
}
|
|
if (item.provider === 'gitlab' && item.source.type !== 'mr') {
|
|
return
|
|
}
|
|
if (item.provider === 'github' && isGitHubPrMergeBlocked(item)) {
|
|
setError('GitHub reports merge conflicts. Open in GitHub to continue.')
|
|
return
|
|
}
|
|
setMutatingStatus(true)
|
|
setError('')
|
|
try {
|
|
const response =
|
|
item.provider === 'github'
|
|
? await client.sendRequest(
|
|
'github.mergePR',
|
|
{
|
|
repo: `id:${item.source.repoId}`,
|
|
prNumber: item.source.number,
|
|
method
|
|
},
|
|
{ timeoutMs: 60_000 }
|
|
)
|
|
: await client.sendRequest(
|
|
'gitlab.mergeMR',
|
|
{
|
|
repo: `id:${item.source.repoId}`,
|
|
iid: item.source.number,
|
|
method,
|
|
projectRef: item.source.projectRef
|
|
},
|
|
{ timeoutMs: 60_000 }
|
|
)
|
|
if (!isSuccess(response)) {
|
|
throw new Error(response.error.message)
|
|
}
|
|
const result = response.result as { ok?: boolean; error?: string }
|
|
if (result.ok === false) {
|
|
throw new Error(result.error ?? 'Failed to merge')
|
|
}
|
|
setActionItem(null)
|
|
await loadTasks({ silent: true })
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Failed to merge')
|
|
} finally {
|
|
setMutatingStatus(false)
|
|
}
|
|
},
|
|
[client, loadTasks, mutatingStatus]
|
|
)
|
|
|
|
const setLinearStatus = useCallback(
|
|
async (
|
|
item: Extract<TaskItem, { provider: 'linear' }>,
|
|
state: LinearState,
|
|
options: { closeDetail?: boolean } = {}
|
|
): Promise<void> => {
|
|
if (!client || !taskUiReady || mutatingStatus) {
|
|
return
|
|
}
|
|
setMutatingStatus(true)
|
|
setError('')
|
|
try {
|
|
const response = await client.sendRequest('linear.updateIssue', {
|
|
id: item.source.id,
|
|
workspaceId: item.source.workspaceId,
|
|
updates: { stateId: state.id }
|
|
})
|
|
if (!isSuccess(response)) {
|
|
throw new Error(response.error.message)
|
|
}
|
|
const nextState = {
|
|
name: state.name,
|
|
type: state.type,
|
|
color: state.color ?? item.source.state.color
|
|
}
|
|
setItems((current) =>
|
|
current.map((entry) =>
|
|
entry.provider === 'linear' && entry.source.id === item.source.id
|
|
? createLinearTask({ ...entry.source, state: nextState })
|
|
: entry
|
|
)
|
|
)
|
|
setActionItem((current) => {
|
|
if (!current || current.provider !== 'linear' || current.source.id !== item.source.id) {
|
|
return current
|
|
}
|
|
if (options.closeDetail !== false) {
|
|
return null
|
|
}
|
|
return createLinearTask({
|
|
...current.source,
|
|
state: nextState
|
|
}) as Extract<TaskItem, { provider: 'linear' }>
|
|
})
|
|
await loadTasks({ silent: true })
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Failed to update Linear issue')
|
|
} finally {
|
|
setMutatingStatus(false)
|
|
}
|
|
},
|
|
[client, loadTasks, mutatingStatus, taskUiReady]
|
|
)
|
|
|
|
const addLinearComment = useCallback(
|
|
async (item: Extract<TaskItem, { provider: 'linear' }>): Promise<void> => {
|
|
if (!client || mutatingStatus) {
|
|
return
|
|
}
|
|
const body = linearCommentDraft.trim()
|
|
if (!body) {
|
|
return
|
|
}
|
|
setMutatingStatus(true)
|
|
setError('')
|
|
try {
|
|
const response = await client.sendRequest(
|
|
'linear.addIssueComment',
|
|
{
|
|
issueId: item.source.id,
|
|
workspaceId: item.source.workspaceId,
|
|
body
|
|
},
|
|
{ timeoutMs: 30_000 }
|
|
)
|
|
if (!isSuccess(response)) {
|
|
throw new Error(response.error.message)
|
|
}
|
|
const result = response.result as { ok?: boolean; id?: string; error?: string }
|
|
if (result.ok === false) {
|
|
throw new Error(result.error ?? 'Failed to add comment')
|
|
}
|
|
const comment: DetailComment = {
|
|
id: result.id ?? `local-${Date.now()}`,
|
|
body,
|
|
createdAt: new Date().toISOString(),
|
|
user: { displayName: 'You' }
|
|
}
|
|
setLinearCommentDraft('')
|
|
setDetailPayload((current) =>
|
|
current?.provider === 'linear'
|
|
? { ...current, comments: [...current.comments, comment] }
|
|
: current
|
|
)
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Failed to add Linear comment')
|
|
} finally {
|
|
setMutatingStatus(false)
|
|
}
|
|
},
|
|
[client, linearCommentDraft, mutatingStatus]
|
|
)
|
|
|
|
const openLinearSubIssue = useCallback(
|
|
async (child: LinearIssueChild, workspaceId?: string): Promise<void> => {
|
|
if (!client || mutatingStatus) {
|
|
return
|
|
}
|
|
setMutatingStatus(true)
|
|
setError('')
|
|
try {
|
|
const response = await client.sendRequest(
|
|
'linear.getIssue',
|
|
{ id: child.id, workspaceId },
|
|
{ timeoutMs: 30_000 }
|
|
)
|
|
if (!isSuccess(response)) {
|
|
throw new Error(response.error.message)
|
|
}
|
|
const issue = response.result as LinearIssue | null
|
|
if (!issue) {
|
|
throw new Error('Sub-issue not found')
|
|
}
|
|
setActionItem(createLinearTask(issue) as Extract<TaskItem, { provider: 'linear' }>)
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Failed to load Linear sub-issue')
|
|
} finally {
|
|
setMutatingStatus(false)
|
|
}
|
|
},
|
|
[client, mutatingStatus]
|
|
)
|
|
|
|
const createLinearSubIssue = useCallback(
|
|
async (item: Extract<TaskItem, { provider: 'linear' }>): Promise<void> => {
|
|
if (!client || mutatingStatus) {
|
|
return
|
|
}
|
|
const title = linearSubIssueTitle.trim()
|
|
if (!title) {
|
|
return
|
|
}
|
|
setMutatingStatus(true)
|
|
setError('')
|
|
try {
|
|
const response = await client.sendRequest(
|
|
'linear.createIssue',
|
|
{
|
|
teamId: item.source.team.id,
|
|
title,
|
|
workspaceId: item.source.workspaceId,
|
|
parentIssueId: item.source.id,
|
|
projectId: item.source.project?.id ?? null
|
|
},
|
|
{ timeoutMs: 30_000 }
|
|
)
|
|
if (!isSuccess(response)) {
|
|
throw new Error(response.error.message)
|
|
}
|
|
const result = response.result as {
|
|
ok?: boolean
|
|
id?: string
|
|
identifier?: string
|
|
title?: string
|
|
url?: string
|
|
error?: string
|
|
}
|
|
if (result.ok === false || !result.id || !result.identifier) {
|
|
throw new Error(result.error ?? 'Failed to create sub-issue')
|
|
}
|
|
const child: LinearIssueChild = {
|
|
id: result.id,
|
|
identifier: result.identifier,
|
|
title: result.title ?? title,
|
|
url: result.url ?? ''
|
|
}
|
|
setLinearSubIssueTitle('')
|
|
setDetailPayload((current) =>
|
|
current?.provider === 'linear'
|
|
? {
|
|
...current,
|
|
children: current.children.some((entry) => entry.id === child.id)
|
|
? current.children
|
|
: [...current.children, child]
|
|
}
|
|
: current
|
|
)
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Failed to create Linear sub-issue')
|
|
} finally {
|
|
setMutatingStatus(false)
|
|
}
|
|
},
|
|
[client, linearSubIssueTitle, mutatingStatus]
|
|
)
|
|
|
|
const createTask = useCallback(async (): Promise<void> => {
|
|
if (!client || !tasksSupported || !taskStateHydrated || creatingTask) {
|
|
return
|
|
}
|
|
const title = createTitle.trim()
|
|
if (!title) {
|
|
return
|
|
}
|
|
setCreatingTask(true)
|
|
setError('')
|
|
try {
|
|
if (provider === 'github' || provider === 'gitlab') {
|
|
const repo = hostedRepos.find((entry) => entry.id === createRepoId) ?? hostedRepos[0]
|
|
if (!repo) {
|
|
throw new Error(
|
|
`Add a Git repository before creating a ${provider === 'github' ? 'GitHub' : 'GitLab'} issue.`
|
|
)
|
|
}
|
|
const response = await client.sendRequest(
|
|
provider === 'github' ? 'github.createIssue' : 'gitlab.createIssue',
|
|
{
|
|
repo: `id:${repo.id}`,
|
|
title,
|
|
body: createBody
|
|
}
|
|
)
|
|
if (!isSuccess(response)) {
|
|
throw new Error(response.error.message)
|
|
}
|
|
const result = response.result as {
|
|
ok?: boolean
|
|
number?: number
|
|
url?: string
|
|
error?: string
|
|
}
|
|
if (result.ok === false) {
|
|
throw new Error(
|
|
result.error ?? `Failed to create ${provider === 'github' ? 'GitHub' : 'GitLab'} issue`
|
|
)
|
|
}
|
|
if (typeof result.number === 'number') {
|
|
const createdAt = new Date().toISOString()
|
|
if (provider === 'github') {
|
|
setActionItem(
|
|
createGitHubTask(repo, {
|
|
id: `issue:${result.number}`,
|
|
type: 'issue',
|
|
number: result.number,
|
|
title,
|
|
state: 'open',
|
|
url: result.url ?? '',
|
|
labels: [],
|
|
updatedAt: createdAt,
|
|
author: null
|
|
})
|
|
)
|
|
} else {
|
|
setActionItem(
|
|
createGitLabTask(repo, {
|
|
id: `issue:${result.number}`,
|
|
type: 'issue',
|
|
number: result.number,
|
|
title,
|
|
state: 'opened',
|
|
url: result.url ?? '',
|
|
labels: [],
|
|
updatedAt: createdAt,
|
|
author: null
|
|
})
|
|
)
|
|
}
|
|
}
|
|
} else {
|
|
const team = linearTeams.find((entry) => entry.id === createTeamId) ?? linearTeams[0]
|
|
if (!team) {
|
|
throw new Error('Select a Linear team first.')
|
|
}
|
|
const response = await client.sendRequest('linear.createIssue', {
|
|
teamId: team.id,
|
|
title,
|
|
description: createBody.trim() || undefined,
|
|
workspaceId: team.workspaceId
|
|
})
|
|
if (!isSuccess(response)) {
|
|
throw new Error(response.error.message)
|
|
}
|
|
const result = response.result as {
|
|
ok?: boolean
|
|
id?: string
|
|
identifier?: string
|
|
title?: string
|
|
url?: string
|
|
error?: string
|
|
}
|
|
if (result.ok === false || !result.id || !result.identifier) {
|
|
throw new Error(result.error ?? 'Failed to create Linear issue')
|
|
}
|
|
setActionItem(
|
|
createLinearTask({
|
|
id: result.id,
|
|
workspaceId: team.workspaceId,
|
|
workspaceName: team.workspaceName,
|
|
identifier: result.identifier,
|
|
title: result.title ?? title,
|
|
description: createBody.trim(),
|
|
url: result.url ?? '',
|
|
state: { name: 'Open', type: 'unstarted', color: colors.accentBlue },
|
|
team,
|
|
labels: [],
|
|
priority: 0,
|
|
updatedAt: new Date().toISOString()
|
|
}) as Extract<TaskItem, { provider: 'linear' }>
|
|
)
|
|
}
|
|
setShowCreateTask(false)
|
|
setCreateTitle('')
|
|
setCreateBody('')
|
|
await loadTasks({ silent: true })
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Failed to create task')
|
|
} finally {
|
|
setCreatingTask(false)
|
|
}
|
|
}, [
|
|
client,
|
|
createBody,
|
|
createRepoId,
|
|
createTeamId,
|
|
createTitle,
|
|
creatingTask,
|
|
hostedRepos,
|
|
linearTeams,
|
|
loadTasks,
|
|
provider,
|
|
taskStateHydrated,
|
|
tasksSupported
|
|
])
|
|
|
|
const setGitHubIssueSourcePreference = useCallback(
|
|
async (repo: RepoSummary, preference: 'upstream' | 'origin'): Promise<void> => {
|
|
if (!client || !taskUiReady) {
|
|
return
|
|
}
|
|
setError('')
|
|
try {
|
|
const response = await client.sendRequest(
|
|
'repo.update',
|
|
{
|
|
repo: `id:${repo.id}`,
|
|
updates: { issueSourcePreference: preference }
|
|
},
|
|
{ timeoutMs: 15_000 }
|
|
)
|
|
if (!isSuccess(response)) {
|
|
throw new Error(response.error.message)
|
|
}
|
|
setRepos((current) =>
|
|
current.map((candidate) =>
|
|
candidate.id === repo.id
|
|
? { ...candidate, issueSourcePreference: preference }
|
|
: candidate
|
|
)
|
|
)
|
|
reposRef.current = reposRef.current.map((candidate) =>
|
|
candidate.id === repo.id ? { ...candidate, issueSourcePreference: preference } : candidate
|
|
)
|
|
await loadTasks({ silent: true })
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Failed to update issue source')
|
|
}
|
|
},
|
|
[client, loadTasks, taskUiReady]
|
|
)
|
|
|
|
const renderCommentComposer = (args: {
|
|
value: string
|
|
onChangeText: (next: string) => void
|
|
onSubmit: () => void
|
|
disabled?: boolean
|
|
}): ReactNode => {
|
|
const hasText = args.value.trim().length > 0
|
|
return (
|
|
<View style={styles.commentComposer}>
|
|
<TextInput
|
|
style={[styles.input, styles.commentInput, styles.commentComposerInput]}
|
|
value={args.value}
|
|
onChangeText={args.onChangeText}
|
|
placeholder="Add a comment"
|
|
placeholderTextColor={colors.textMuted}
|
|
editable={!args.disabled}
|
|
multiline
|
|
numberOfLines={1}
|
|
textAlignVertical="top"
|
|
/>
|
|
{hasText ? (
|
|
<Pressable
|
|
accessibilityRole="button"
|
|
accessibilityLabel="Send comment"
|
|
style={({ pressed }) => [
|
|
styles.commentComposerSend,
|
|
pressed && !args.disabled && styles.commentComposerSendPressed,
|
|
args.disabled && styles.commentComposerSendDisabled
|
|
]}
|
|
disabled={args.disabled}
|
|
onPress={args.onSubmit}
|
|
>
|
|
<Send size={16} color={args.disabled ? colors.textMuted : colors.textPrimary} />
|
|
</Pressable>
|
|
) : null}
|
|
</View>
|
|
)
|
|
}
|
|
|
|
const renderDetailComment = (
|
|
comment: DetailComment,
|
|
options: { nested?: boolean } = {}
|
|
): ReactNode => (
|
|
<View
|
|
key={String(comment.id)}
|
|
style={[
|
|
styles.commentBlock,
|
|
options.nested && styles.commentReplyBlock,
|
|
comment.isResolved && styles.commentResolvedBlock
|
|
]}
|
|
>
|
|
<Text style={styles.commentSource} numberOfLines={1}>
|
|
{commentSourceLabel(comment)}
|
|
</Text>
|
|
<Text style={styles.commentMeta}>
|
|
{commentAuthor(comment)}
|
|
{commentDate(comment.createdAt) ? ` · ${commentDate(comment.createdAt)}` : ''}
|
|
</Text>
|
|
<MobileMarkdown content={comment.body} />
|
|
{renderCommentReactions(comment)}
|
|
{SHOW_MOBILE_COMMENT_THREAD_TOOLS &&
|
|
actionItem?.provider === 'github' &&
|
|
detailPayload?.provider === 'github' ? (
|
|
<View style={styles.commentControls}>
|
|
{SHOW_MOBILE_COMMENT_THREAD_TOOLS &&
|
|
actionItem?.provider === 'github' &&
|
|
detailPayload?.provider === 'github' ? (
|
|
<>
|
|
{actionItem.source.type === 'pr' && comment.threadId ? (
|
|
<Pressable
|
|
style={styles.inlineSaveButtonCompact}
|
|
disabled={mutatingStatus}
|
|
onPress={() => void toggleGitHubReviewThread(actionItem, comment)}
|
|
>
|
|
<Text style={styles.inlineSaveText}>
|
|
{comment.isResolved ? 'Reopen thread' : 'Resolve thread'}
|
|
</Text>
|
|
</Pressable>
|
|
) : null}
|
|
<TextInput
|
|
style={[styles.input, styles.replyInput]}
|
|
value={itemReplyDrafts[String(comment.id)] ?? ''}
|
|
onChangeText={(next) =>
|
|
setItemReplyDrafts((current) => ({
|
|
...current,
|
|
[String(comment.id)]: next
|
|
}))
|
|
}
|
|
placeholder="Reply"
|
|
placeholderTextColor={colors.textMuted}
|
|
multiline
|
|
textAlignVertical="top"
|
|
/>
|
|
<Pressable
|
|
style={styles.inlineSaveButtonCompact}
|
|
disabled={mutatingStatus || !(itemReplyDrafts[String(comment.id)] ?? '').trim()}
|
|
onPress={() => void replyToGitHubComment(actionItem, comment)}
|
|
>
|
|
<Text style={styles.inlineSaveText}>Reply</Text>
|
|
</Pressable>
|
|
</>
|
|
) : null}
|
|
</View>
|
|
) : null}
|
|
</View>
|
|
)
|
|
|
|
const renderDetailCommentGroup = (group: DetailCommentGroup): ReactNode => {
|
|
const id = detailCommentGroupId(group)
|
|
const root = detailCommentGroupRoot(group)
|
|
const count = detailCommentGroupCount(group)
|
|
const isCollapsedResolved =
|
|
isResolvedDetailCommentGroup(group) && !expandedResolvedCommentGroups.has(id)
|
|
|
|
if (isCollapsedResolved) {
|
|
return (
|
|
<Pressable
|
|
key={id}
|
|
style={styles.resolvedCommentSummary}
|
|
onPress={() =>
|
|
setExpandedResolvedCommentGroups((current) => {
|
|
const next = new Set(current)
|
|
next.add(id)
|
|
return next
|
|
})
|
|
}
|
|
>
|
|
<Text style={styles.resolvedCommentTitle} numberOfLines={1}>
|
|
Resolved {group.kind === 'thread' ? 'thread' : 'comment'} by {commentAuthor(root)}
|
|
</Text>
|
|
<Text style={styles.detailSectionMeta}>{count > 1 ? `${count} comments` : 'Show'}</Text>
|
|
</Pressable>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<View key={id} style={styles.commentThreadGroup}>
|
|
{group.kind === 'thread'
|
|
? [
|
|
renderDetailComment(group.root),
|
|
...group.replies.map((reply) => renderDetailComment(reply, { nested: true }))
|
|
]
|
|
: renderDetailComment(group.comment)}
|
|
</View>
|
|
)
|
|
}
|
|
|
|
const createTargetOptions = useMemo<PickerOption<string>[]>(
|
|
() =>
|
|
provider === 'github' || provider === 'gitlab'
|
|
? hostedRepos.map((repo) => ({
|
|
value: repo.id,
|
|
label: repo.displayName,
|
|
subtitle: repo.path,
|
|
renderIcon: () => (
|
|
<View
|
|
style={[
|
|
styles.pickerRepoDot,
|
|
{ backgroundColor: getRepoBadgeColor(repo, repo.displayName) }
|
|
]}
|
|
/>
|
|
)
|
|
}))
|
|
: linearTeams.map((team) => ({
|
|
value: team.id,
|
|
label: team.name,
|
|
subtitle: team.workspaceName
|
|
})),
|
|
[hostedRepos, linearTeams, provider]
|
|
)
|
|
const selectedCreateTarget =
|
|
provider === 'github' || provider === 'gitlab'
|
|
? (hostedRepos.find((repo) => repo.id === createRepoId) ?? hostedRepos[0] ?? null)
|
|
: (linearTeams.find((team) => team.id === createTeamId) ?? linearTeams[0] ?? null)
|
|
const selectedCreateTargetLabel =
|
|
provider === 'github' || provider === 'gitlab'
|
|
? ((selectedCreateTarget as RepoSummary | null)?.displayName ?? 'Select target')
|
|
: ((selectedCreateTarget as LinearTeam | null)?.name ?? 'Select target')
|
|
const providerLabel =
|
|
provider === 'github' ? 'GitHub' : provider === 'gitlab' ? 'GitLab' : 'Linear'
|
|
const showHeaderCreateTask =
|
|
provider === 'linear' || (provider === 'github' && githubMode === 'items')
|
|
const providerOptions = useMemo(
|
|
() => PROVIDER_OPTIONS.filter((option) => visibleProviders.includes(option.value)),
|
|
[visibleProviders]
|
|
)
|
|
const selectedCreateRepo =
|
|
provider === 'github' || provider === 'gitlab'
|
|
? (selectedCreateTarget as RepoSummary | null)
|
|
: null
|
|
const selectedCreateGitHubSources =
|
|
provider === 'github' && selectedCreateRepo
|
|
? githubRepoSources[selectedCreateRepo.id]
|
|
: undefined
|
|
const selectedCreateIssuePreference =
|
|
selectedCreateRepo?.issueSourcePreference === 'origin' ||
|
|
selectedCreateRepo?.issueSourcePreference === 'upstream'
|
|
? selectedCreateRepo.issueSourcePreference
|
|
: 'upstream'
|
|
const githubIssueSourceRows = useMemo(
|
|
() =>
|
|
selectedHostedRepos
|
|
.map((repo) => ({ repo, sources: githubRepoSources[repo.id] }))
|
|
.filter((entry): entry is { repo: RepoSummary; sources: GitHubRepoSources } =>
|
|
hasGitHubIssueSourceChoice(entry.sources)
|
|
),
|
|
[githubRepoSources, selectedHostedRepos]
|
|
)
|
|
const githubIssueSourceLabel =
|
|
githubIssueSourceRows.length === 1
|
|
? issueSourceSlug(
|
|
githubIssueSourceRows[0]!.repo.issueSourcePreference === 'origin'
|
|
? githubIssueSourceRows[0]!.sources.prs
|
|
: githubIssueSourceRows[0]!.sources.upstreamCandidate
|
|
)
|
|
: `${githubIssueSourceRows.length} sources`
|
|
const repoPickerLabel =
|
|
selectedRepoIds.size === 0 || selectedHostedRepos.length === hostedRepos.length
|
|
? 'All repos'
|
|
: selectedHostedRepos.length === 1
|
|
? selectedHostedRepos[0]!.displayName
|
|
: `${selectedHostedRepos.length} repos`
|
|
const repoPickerSelectedRepo =
|
|
selectedRepoIds.size > 0 && selectedHostedRepos.length === 1 ? selectedHostedRepos[0]! : null
|
|
const workspaceRepoOptions = useMemo<PickerOption<string>[]>(
|
|
() =>
|
|
workspaceRepos.map((repo) => ({
|
|
value: repo.id,
|
|
label: repo.displayName,
|
|
subtitle: repo.path,
|
|
renderIcon: () => (
|
|
<View
|
|
style={[
|
|
styles.pickerRepoDot,
|
|
{ backgroundColor: getRepoBadgeColor(repo, repo.displayName) }
|
|
]}
|
|
/>
|
|
)
|
|
})),
|
|
[workspaceRepos]
|
|
)
|
|
const sortedItems = useMemo(() => {
|
|
const next = [...items]
|
|
if (taskSort === 'repository') {
|
|
next.sort((a, b) => compareTasksByRepository(a, b, reposById))
|
|
} else {
|
|
next.sort(compareTasksByUpdated)
|
|
}
|
|
return next
|
|
}, [items, reposById, taskSort])
|
|
const displayedEntries = useMemo<TaskListEntry[]>(() => {
|
|
if (taskSort !== 'repository') {
|
|
return sortedItems.map((item) => ({ type: 'item', key: item.key, item }))
|
|
}
|
|
const entries: TaskListEntry[] = []
|
|
let previousRepoKey = ''
|
|
for (const item of sortedItems) {
|
|
const repo = taskRepositoryMeta(item, reposById)
|
|
if (repo.key !== previousRepoKey) {
|
|
entries.push({
|
|
type: 'section',
|
|
key: `section:${repo.key}`,
|
|
label: repo.label,
|
|
color: repo.color
|
|
})
|
|
previousRepoKey = repo.key
|
|
}
|
|
entries.push({ type: 'item', key: item.key, item })
|
|
}
|
|
return entries
|
|
}, [reposById, sortedItems, taskSort])
|
|
const sortLabel = SORT_OPTIONS.find((option) => option.value === taskSort)?.label ?? 'Updated'
|
|
const githubProjectFields = githubProjectTable?.selectedView.fields ?? []
|
|
const githubProjectViewSort = githubProjectTable?.selectedView.sortByFields?.[0] ?? null
|
|
const githubProjectSortField = githubProjectSortOverride
|
|
? githubProjectFields.find((field) => field.id === githubProjectSortOverride.fieldId)
|
|
: githubProjectViewSort?.field
|
|
const githubProjectSortDirection =
|
|
githubProjectSortOverride?.direction ?? githubProjectViewSort?.direction ?? null
|
|
const githubProjectSortLabel = githubProjectSortField
|
|
? `${githubProjectSortField.name} ${githubProjectSortDirection === 'DESC' ? 'desc' : 'asc'}`
|
|
: 'View order'
|
|
const githubProjectFieldsLabel =
|
|
githubProjectAvailableSummaryFields.length > 0
|
|
? `${githubProjectSummaryFields.length}/${githubProjectAvailableSummaryFields.length} fields`
|
|
: 'Fields'
|
|
const githubProjectSortOptions = useMemo<PickerOption<string>[]>(
|
|
() => [
|
|
{
|
|
value: PROJECT_VIEW_DEFAULT_SORT,
|
|
label: 'View order',
|
|
subtitle: githubProjectViewSort
|
|
? `Uses ${githubProjectViewSort.field.name} ${githubProjectViewSort.direction.toLowerCase()}`
|
|
: 'Uses GitHub rank order'
|
|
},
|
|
...githubProjectFields.map((field) => {
|
|
const active = githubProjectSortOverride?.fieldId === field.id
|
|
const nextDirection =
|
|
!active || githubProjectSortOverride.direction === 'DESC' ? 'ascending' : 'descending'
|
|
return {
|
|
value: field.id,
|
|
label: field.name,
|
|
subtitle: active
|
|
? `Currently ${githubProjectSortOverride.direction.toLowerCase()} · tap for ${nextDirection}`
|
|
: 'Sort ascending'
|
|
}
|
|
})
|
|
],
|
|
[githubProjectFields, githubProjectSortOverride, githubProjectViewSort]
|
|
)
|
|
const githubProjectViewOptions = useMemo<PickerOption<string>[]>(
|
|
() =>
|
|
githubProjectViews.map((view) => ({
|
|
value: view.id,
|
|
label: view.name,
|
|
subtitle:
|
|
view.layout === 'TABLE_LAYOUT' ? `View #${view.number}` : 'Unsupported layout on mobile',
|
|
disabled: view.layout !== 'TABLE_LAYOUT'
|
|
})),
|
|
[githubProjectViews]
|
|
)
|
|
const githubPresetOptions = githubKind === 'prs' ? PR_PRESETS : ISSUE_PRESETS
|
|
const githubPresetPickerOptions = useMemo(
|
|
() =>
|
|
githubPresetOptions.map((option) =>
|
|
option.value === defaultGitHubPreset
|
|
? { ...option, subtitle: option.subtitle ? `${option.subtitle} · Default` : 'Default' }
|
|
: option
|
|
),
|
|
[defaultGitHubPreset, githubPresetOptions]
|
|
)
|
|
const githubPresetLabel =
|
|
githubPresetOptions.find((preset) => preset.value === githubPreset)?.label ?? 'Open'
|
|
const gitlabFilterLabel =
|
|
GITLAB_FILTER_OPTIONS.find((filter) => filter.value === gitlabFilter)?.label ?? 'Open'
|
|
const linearFilterLabel =
|
|
LINEAR_FILTER_OPTIONS.find((filter) => filter.value === linearFilter)?.label ?? 'All'
|
|
const linearViewLabel =
|
|
LINEAR_VIEW_OPTIONS.find((option) => option.value === linearViewMode)?.label ?? 'List'
|
|
const linearGroupLabel =
|
|
LINEAR_GROUP_OPTIONS.find((option) => option.value === linearGroupBy)?.label ?? 'No grouping'
|
|
const linearOrderLabel =
|
|
LINEAR_ORDER_OPTIONS.find((option) => option.value === linearOrderBy)?.label ?? 'Priority'
|
|
const linearWorkspaceLabel =
|
|
selectedLinearWorkspaceId === 'all'
|
|
? 'All workspaces'
|
|
: (linearWorkspaces.find((workspace) => workspace.id === selectedLinearWorkspaceId)
|
|
?.organizationName ??
|
|
linearWorkspaces.find((workspace) => workspace.id === selectedLinearWorkspaceId)
|
|
?.displayName ??
|
|
'Workspace')
|
|
const linearWorkspaceOptions = useMemo<PickerOption<string>[]>(
|
|
() => [
|
|
{ value: 'all', label: 'All workspaces' },
|
|
...linearWorkspaces.map((workspace) => ({
|
|
value: workspace.id,
|
|
label: workspace.organizationName ?? workspace.displayName ?? workspace.id
|
|
}))
|
|
],
|
|
[linearWorkspaces]
|
|
)
|
|
const linearTeamLabel =
|
|
selectedLinearTeamIds.size === 0 || selectedLinearTeamIds.size === linearTeams.length
|
|
? 'All teams'
|
|
: selectedLinearTeamIds.size === 1
|
|
? (linearTeams.find((team) => selectedLinearTeamIds.has(team.id))?.name ?? '1 team')
|
|
: `${selectedLinearTeamIds.size} teams`
|
|
const effectiveLinearDisplayProperties = useMemo(() => {
|
|
const next = new Set(linearDisplayProperties)
|
|
if (linearGroupBy === 'status') {
|
|
next.delete('state')
|
|
}
|
|
if (linearGroupBy === 'assignee') {
|
|
next.delete('assignee')
|
|
}
|
|
if (linearGroupBy === 'priority') {
|
|
next.delete('priority')
|
|
}
|
|
if (linearGroupBy === 'team') {
|
|
next.delete('team')
|
|
}
|
|
if (selectedLinearTeamIds.size <= 1 && !linearTeamPropertyTouched) {
|
|
next.delete('team')
|
|
} else if (selectedLinearTeamIds.size > 1 && !linearTeamPropertyTouched) {
|
|
next.add('team')
|
|
}
|
|
return next
|
|
}, [
|
|
linearDisplayProperties,
|
|
linearGroupBy,
|
|
linearTeamPropertyTouched,
|
|
selectedLinearTeamIds.size
|
|
])
|
|
const linearIssuesForView = useMemo(
|
|
() =>
|
|
items
|
|
.filter(
|
|
(item): item is Extract<TaskItem, { provider: 'linear' }> => item.provider === 'linear'
|
|
)
|
|
.map((item) => item.source)
|
|
.sort((a, b) => compareLinearIssues(a, b, linearOrderBy)),
|
|
[items, linearOrderBy]
|
|
)
|
|
const linearIssueSections = useMemo(
|
|
() => groupLinearIssues(linearIssuesForView, linearGroupBy, linearOrderBy),
|
|
[linearGroupBy, linearIssuesForView, linearOrderBy]
|
|
)
|
|
// Why: FlatList treats data identity as meaningful; unrelated renders should
|
|
// not rebuild the section/item wrapper array.
|
|
const linearListEntries = useMemo<LinearListEntry[]>(
|
|
() =>
|
|
linearIssueSections.flatMap((section) =>
|
|
linearGroupBy === 'none'
|
|
? section.issues.map((issue) => ({ type: 'issue' as const, issue }))
|
|
: [
|
|
{ type: 'section' as const, section },
|
|
...section.issues.map((issue) => ({ type: 'issue' as const, issue }))
|
|
]
|
|
),
|
|
[linearGroupBy, linearIssueSections]
|
|
)
|
|
const linearBoardSections = useMemo(
|
|
() =>
|
|
groupLinearIssues(
|
|
linearIssuesForView,
|
|
linearGroupBy === 'none' ? 'status' : linearGroupBy,
|
|
linearOrderBy
|
|
),
|
|
[linearGroupBy, linearIssuesForView, linearOrderBy]
|
|
)
|
|
const githubModeLabel =
|
|
githubMode === 'project' ? 'Projects' : githubKind === 'prs' ? 'PRs' : 'Issues'
|
|
const activeProjectLabel = githubProjectTable
|
|
? githubProjectTable.project.title
|
|
: activeGitHubProject
|
|
? `${activeGitHubProject.owner} #${activeGitHubProject.number}`
|
|
: 'Choose project'
|
|
const selectedGitHubProjectViewUrl = githubProjectTable
|
|
? `${githubProjectTable.project.url}/views/${githubProjectTable.selectedView.number}`
|
|
: null
|
|
const githubProjectsByKey = useMemo(
|
|
() => new Map(githubProjects.map((project) => [githubProjectKey(project), project])),
|
|
[githubProjects]
|
|
)
|
|
const pinnedGitHubProjects = useMemo(
|
|
() =>
|
|
githubProjectSettings.pinned.map((project) => ({
|
|
...project,
|
|
summary: githubProjectsByKey.get(githubProjectKey(project))
|
|
})),
|
|
[githubProjectSettings.pinned, githubProjectsByKey]
|
|
)
|
|
const recentGitHubProjects = useMemo(
|
|
() =>
|
|
githubProjectSettings.recent
|
|
.filter(
|
|
(recent) =>
|
|
!githubProjectSettings.pinned.some(
|
|
(pinned) => githubProjectKey(pinned) === githubProjectKey(recent)
|
|
)
|
|
)
|
|
.map((project) => ({
|
|
...project,
|
|
summary: githubProjectsByKey.get(githubProjectKey(project))
|
|
})),
|
|
[githubProjectSettings.pinned, githubProjectSettings.recent, githubProjectsByKey]
|
|
)
|
|
const browseGitHubProjects = useMemo(() => {
|
|
const queryText = githubProjectPickerSearch.trim().toLowerCase()
|
|
const pinnedOrRecentKeys = new Set([
|
|
...githubProjectSettings.pinned.map(githubProjectKey),
|
|
...githubProjectSettings.recent.map(githubProjectKey)
|
|
])
|
|
return githubProjects.filter((project) => {
|
|
if (pinnedOrRecentKeys.has(githubProjectKey(project))) {
|
|
return false
|
|
}
|
|
if (!queryText) {
|
|
return true
|
|
}
|
|
return (
|
|
project.title.toLowerCase().includes(queryText) ||
|
|
project.owner.toLowerCase().includes(queryText) ||
|
|
String(project.number).includes(queryText)
|
|
)
|
|
})
|
|
}, [
|
|
githubProjectPickerSearch,
|
|
githubProjectSettings.pinned,
|
|
githubProjectSettings.recent,
|
|
githubProjects
|
|
])
|
|
|
|
const toggleRepoSelection = useCallback(
|
|
(repoId: string) => {
|
|
setSelectedRepoIds((current) => {
|
|
const next = new Set(current)
|
|
if (next.has(repoId)) {
|
|
next.delete(repoId)
|
|
} else {
|
|
next.add(repoId)
|
|
}
|
|
const normalized =
|
|
next.size === 0 || next.size === hostedRepos.length ? new Set<string>() : next
|
|
persistRepoSelection(normalized, hostedRepos)
|
|
return normalized
|
|
})
|
|
},
|
|
[hostedRepos, persistRepoSelection]
|
|
)
|
|
|
|
const applyGitHubProjectSearch = useCallback(() => {
|
|
const viewFilter = githubProjectTable?.selectedView.filter ?? ''
|
|
const next = githubProjectSearch
|
|
setAppliedGithubProjectSearch(next === viewFilter ? undefined : next)
|
|
}, [githubProjectSearch, githubProjectTable?.selectedView.filter])
|
|
|
|
const headerVerdict = classifyConnection({
|
|
state: connState,
|
|
reconnectAttempts,
|
|
lastConnectedAt
|
|
})
|
|
const emptyLabel =
|
|
connState !== 'connected'
|
|
? 'Connect to a host to load tasks'
|
|
: query
|
|
? 'No matching tasks'
|
|
: provider === 'github'
|
|
? 'No GitHub tasks'
|
|
: provider === 'gitlab'
|
|
? 'No GitLab tasks'
|
|
: 'No Linear tasks'
|
|
const isGithubProjectSearch = provider === 'github' && githubMode === 'project'
|
|
|
|
return (
|
|
<SafeAreaView style={styles.container} edges={['top']}>
|
|
<View ref={setTaskCopyFeedbackRootRef} style={styles.topChrome}>
|
|
<View style={styles.statusBar}>
|
|
<Pressable style={styles.backButton} onPress={() => router.back()}>
|
|
<ChevronLeft size={22} color={colors.textPrimary} />
|
|
</Pressable>
|
|
<View style={styles.titleWrap}>
|
|
<StatusDot state={connState} verdict={headerVerdict} />
|
|
<Text style={styles.title}>Tasks</Text>
|
|
</View>
|
|
<Pressable
|
|
style={styles.iconButton}
|
|
disabled={!taskUiReady || loading || refreshing || githubProjectLoading}
|
|
onPress={() => {
|
|
if (!taskUiReady) {
|
|
return
|
|
}
|
|
if (provider === 'github' && githubMode === 'project') {
|
|
void loadGitHubProjectTable({ queryOverride: appliedGithubProjectSearch })
|
|
return
|
|
}
|
|
void loadTasks({ silent: true })
|
|
}}
|
|
>
|
|
<RefreshCw size={16} color={taskUiReady ? colors.textSecondary : colors.textMuted} />
|
|
</Pressable>
|
|
{showHeaderCreateTask ? (
|
|
<Pressable
|
|
style={styles.iconButton}
|
|
disabled={!taskUiReady}
|
|
onPress={() => {
|
|
if (!taskUiReady) {
|
|
return
|
|
}
|
|
if (provider === 'linear' && !linearConnected) {
|
|
setLinearApiKeyDraft('')
|
|
setLinearConnectState('idle')
|
|
setLinearConnectError('')
|
|
setShowLinearConnect(true)
|
|
return
|
|
}
|
|
setCreateTitle('')
|
|
setCreateBody('')
|
|
setShowCreateTask(true)
|
|
}}
|
|
>
|
|
<Plus size={16} color={taskUiReady ? colors.textSecondary : colors.textMuted} />
|
|
</Pressable>
|
|
) : null}
|
|
</View>
|
|
|
|
<ScrollView
|
|
horizontal
|
|
showsHorizontalScrollIndicator={false}
|
|
style={styles.toolbarScroll}
|
|
contentContainerStyle={styles.toolbar}
|
|
>
|
|
<Pressable
|
|
style={styles.segmentButton}
|
|
disabled={!taskUiReady}
|
|
onPress={() => {
|
|
if (!taskUiReady) {
|
|
return
|
|
}
|
|
setShowProviderPicker(true)
|
|
}}
|
|
>
|
|
<TaskProviderLogo provider={provider} size={14} color={colors.textPrimary} />
|
|
<Text style={styles.segmentButtonText}>{providerLabel}</Text>
|
|
</Pressable>
|
|
|
|
{provider === 'gitlab' || (provider === 'github' && githubMode !== 'project') ? (
|
|
<Pressable
|
|
style={styles.segmentButton}
|
|
disabled={!taskUiReady}
|
|
onPress={() => {
|
|
if (!taskUiReady) {
|
|
return
|
|
}
|
|
setShowRepoPicker(true)
|
|
}}
|
|
>
|
|
{repoPickerSelectedRepo ? (
|
|
<View
|
|
style={[
|
|
styles.segmentRepoDot,
|
|
{
|
|
backgroundColor: getRepoBadgeColor(
|
|
repoPickerSelectedRepo,
|
|
repoPickerSelectedRepo.displayName
|
|
)
|
|
}
|
|
]}
|
|
/>
|
|
) : null}
|
|
<Text style={styles.segmentSecondaryText}>{repoPickerLabel}</Text>
|
|
</Pressable>
|
|
) : null}
|
|
|
|
{provider === 'github' && (
|
|
<>
|
|
<Pressable
|
|
style={styles.segmentButton}
|
|
disabled={!taskUiReady}
|
|
onPress={() => {
|
|
if (!taskUiReady) {
|
|
return
|
|
}
|
|
setShowGitHubKindPicker(true)
|
|
}}
|
|
>
|
|
<Text style={styles.segmentSecondaryText}>{githubModeLabel}</Text>
|
|
</Pressable>
|
|
{githubMode === 'items' ? (
|
|
<>
|
|
<Pressable
|
|
style={styles.segmentButton}
|
|
disabled={!taskUiReady}
|
|
onPress={() => {
|
|
if (!taskUiReady) {
|
|
return
|
|
}
|
|
setShowGitHubPresetPicker(true)
|
|
}}
|
|
>
|
|
<Text style={styles.segmentSecondaryText}>{githubPresetLabel}</Text>
|
|
</Pressable>
|
|
{githubIssueSourceRows.length > 0 ? (
|
|
<Pressable
|
|
style={styles.segmentButton}
|
|
disabled={!taskUiReady}
|
|
onPress={() => {
|
|
if (!taskUiReady) {
|
|
return
|
|
}
|
|
setShowGitHubIssueSourcePicker(true)
|
|
}}
|
|
>
|
|
<Text style={styles.segmentSecondaryText}>
|
|
Source: {githubIssueSourceLabel}
|
|
</Text>
|
|
</Pressable>
|
|
) : null}
|
|
</>
|
|
) : (
|
|
<>
|
|
<Pressable
|
|
style={styles.segmentButton}
|
|
disabled={!taskUiReady}
|
|
onPress={() => {
|
|
if (!taskUiReady) {
|
|
return
|
|
}
|
|
setShowGitHubProjectPicker(true)
|
|
}}
|
|
>
|
|
<Text style={styles.segmentSecondaryText}>{activeProjectLabel}</Text>
|
|
</Pressable>
|
|
{activeGitHubProjectView ? (
|
|
<Pressable
|
|
style={styles.segmentButton}
|
|
disabled={!taskUiReady}
|
|
onPress={() => {
|
|
if (!taskUiReady) {
|
|
return
|
|
}
|
|
setShowGitHubProjectViewPicker(true)
|
|
}}
|
|
>
|
|
<Text style={styles.segmentSecondaryText}>
|
|
{activeGitHubProjectView.name}
|
|
</Text>
|
|
</Pressable>
|
|
) : null}
|
|
{githubProjectTable ? (
|
|
<Pressable
|
|
style={styles.segmentButton}
|
|
disabled={!taskUiReady}
|
|
onPress={() => {
|
|
if (!taskUiReady) {
|
|
return
|
|
}
|
|
setShowGitHubProjectSortPicker(true)
|
|
}}
|
|
>
|
|
<Text style={styles.segmentSecondaryText}>
|
|
Sort: {githubProjectSortLabel}
|
|
</Text>
|
|
</Pressable>
|
|
) : null}
|
|
{githubProjectAvailableSummaryFields.length > 0 ? (
|
|
<Pressable
|
|
style={styles.segmentButton}
|
|
disabled={!taskUiReady}
|
|
onPress={() => {
|
|
if (!taskUiReady) {
|
|
return
|
|
}
|
|
setShowGitHubProjectFieldsPicker(true)
|
|
}}
|
|
>
|
|
<Text style={styles.segmentSecondaryText}>
|
|
Fields: {githubProjectFieldsLabel}
|
|
</Text>
|
|
</Pressable>
|
|
) : null}
|
|
{githubProjectTable ? (
|
|
<View style={styles.segmentCountPill}>
|
|
<Text style={styles.segmentSecondaryText}>
|
|
{visibleGitHubProjectRows.length}
|
|
</Text>
|
|
</View>
|
|
) : null}
|
|
{selectedGitHubProjectViewUrl ? (
|
|
<Pressable
|
|
accessibilityRole="button"
|
|
accessibilityLabel="Open view in GitHub"
|
|
style={styles.segmentIconButton}
|
|
disabled={!taskUiReady}
|
|
onPress={() => {
|
|
if (!taskUiReady) {
|
|
return
|
|
}
|
|
void Linking.openURL(selectedGitHubProjectViewUrl)
|
|
}}
|
|
>
|
|
<ExternalLink size={14} color={colors.textSecondary} />
|
|
</Pressable>
|
|
) : null}
|
|
</>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{provider === 'gitlab' && (
|
|
<>
|
|
<Pressable
|
|
style={styles.segmentButton}
|
|
disabled={!taskUiReady}
|
|
onPress={() => {
|
|
if (!taskUiReady) {
|
|
return
|
|
}
|
|
setShowGitLabViewPicker(true)
|
|
}}
|
|
>
|
|
<Text style={styles.segmentSecondaryText}>
|
|
{gitlabView === 'project' ? 'Project MRs' : 'My Todos'}
|
|
</Text>
|
|
</Pressable>
|
|
{gitlabView === 'project' && (
|
|
<Pressable
|
|
style={styles.segmentButton}
|
|
disabled={!taskUiReady}
|
|
onPress={() => {
|
|
if (!taskUiReady) {
|
|
return
|
|
}
|
|
setShowGitLabFilterPicker(true)
|
|
}}
|
|
>
|
|
<Text style={styles.segmentSecondaryText}>{gitlabFilterLabel}</Text>
|
|
</Pressable>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{provider === 'linear' && linearConnected && (
|
|
<>
|
|
{linearWorkspaces.length > 1 ? (
|
|
<Pressable
|
|
style={styles.segmentButton}
|
|
disabled={!taskUiReady}
|
|
onPress={() => {
|
|
if (!taskUiReady) {
|
|
return
|
|
}
|
|
setShowLinearWorkspacePicker(true)
|
|
}}
|
|
>
|
|
<Text style={styles.segmentSecondaryText}>{linearWorkspaceLabel}</Text>
|
|
</Pressable>
|
|
) : null}
|
|
<Pressable
|
|
style={styles.segmentButton}
|
|
disabled={!taskUiReady}
|
|
onPress={() => {
|
|
if (!taskUiReady) {
|
|
return
|
|
}
|
|
setShowLinearTeamPicker(true)
|
|
}}
|
|
>
|
|
<Text style={styles.segmentSecondaryText}>{linearTeamLabel}</Text>
|
|
</Pressable>
|
|
<Pressable
|
|
style={styles.segmentButton}
|
|
disabled={!taskUiReady}
|
|
onPress={() => {
|
|
if (!taskUiReady) {
|
|
return
|
|
}
|
|
setShowLinearFilterPicker(true)
|
|
}}
|
|
>
|
|
<Text style={styles.segmentSecondaryText}>{linearFilterLabel}</Text>
|
|
</Pressable>
|
|
<Pressable
|
|
style={styles.segmentButton}
|
|
disabled={!taskUiReady}
|
|
onPress={() => {
|
|
if (!taskUiReady) {
|
|
return
|
|
}
|
|
setShowLinearViewPicker(true)
|
|
}}
|
|
>
|
|
<Text style={styles.segmentSecondaryText}>{linearViewLabel}</Text>
|
|
</Pressable>
|
|
<Pressable
|
|
style={styles.segmentButton}
|
|
disabled={!taskUiReady}
|
|
onPress={() => {
|
|
if (!taskUiReady) {
|
|
return
|
|
}
|
|
setShowLinearGroupPicker(true)
|
|
}}
|
|
>
|
|
<Text style={styles.segmentSecondaryText}>Group: {linearGroupLabel}</Text>
|
|
</Pressable>
|
|
<Pressable
|
|
style={styles.segmentButton}
|
|
disabled={!taskUiReady}
|
|
onPress={() => {
|
|
if (!taskUiReady) {
|
|
return
|
|
}
|
|
setShowLinearOrderPicker(true)
|
|
}}
|
|
>
|
|
<Text style={styles.segmentSecondaryText}>Order: {linearOrderLabel}</Text>
|
|
</Pressable>
|
|
<Pressable
|
|
style={styles.segmentButton}
|
|
disabled={!taskUiReady}
|
|
onPress={() => {
|
|
if (!taskUiReady) {
|
|
return
|
|
}
|
|
setShowLinearDisplayPicker(true)
|
|
}}
|
|
>
|
|
<Text style={styles.segmentSecondaryText}>Display</Text>
|
|
</Pressable>
|
|
</>
|
|
)}
|
|
|
|
{provider !== 'linear' && !(provider === 'github' && githubMode === 'project') ? (
|
|
<Pressable
|
|
style={styles.segmentButton}
|
|
disabled={!taskUiReady}
|
|
onPress={() => {
|
|
if (!taskUiReady) {
|
|
return
|
|
}
|
|
setShowSortPicker(true)
|
|
}}
|
|
>
|
|
<GitBranch size={14} color={colors.textSecondary} />
|
|
<Text style={styles.segmentSecondaryText}>Sort: {sortLabel}</Text>
|
|
</Pressable>
|
|
) : null}
|
|
</ScrollView>
|
|
|
|
{provider === 'gitlab' && gitlabView === 'todos' ? null : provider === 'linear' &&
|
|
!linearConnected ? null : (
|
|
<View style={styles.searchBar}>
|
|
<MobileSearchField
|
|
value={isGithubProjectSearch ? githubProjectSearch : query}
|
|
onChangeText={isGithubProjectSearch ? setGithubProjectSearch : setQuery}
|
|
placeholder={
|
|
isGithubProjectSearch
|
|
? 'Search project view...'
|
|
: `Search ${providerLabel} tasks...`
|
|
}
|
|
// Why: GitHub items seed the field with a preset query, so a bare
|
|
// value.length check would always show clear. Project mode shows clear
|
|
// for draft text or a non-empty applied override — not for applied ''
|
|
// (explicit unfiltered after clear), or the button sticks forever.
|
|
showClear={
|
|
isGithubProjectSearch
|
|
? githubProjectSearch.length > 0 ||
|
|
(appliedGithubProjectSearch !== undefined &&
|
|
appliedGithubProjectSearch.length > 0)
|
|
: provider === 'github'
|
|
? query.trim() !== getTaskPresetQuery(githubPreset).trim()
|
|
: undefined
|
|
}
|
|
editable={taskUiReady}
|
|
onSubmitEditing={() => {
|
|
if (!taskUiReady) {
|
|
return
|
|
}
|
|
if (isGithubProjectSearch) {
|
|
applyGitHubProjectSearch()
|
|
return
|
|
}
|
|
const nextQuery =
|
|
provider === 'github' ? scopeGitHubTaskSearch(query, githubKind) : query.trim()
|
|
setQuery(nextQuery)
|
|
setAppliedQuery(nextQuery)
|
|
if (provider === 'github') {
|
|
persistTaskResumeState({
|
|
githubItemsPreset:
|
|
nextQuery.trim() === getTaskPresetQuery(githubPreset) ? githubPreset : null,
|
|
githubItemsQuery: nextQuery.trim()
|
|
})
|
|
} else if (provider === 'linear') {
|
|
persistTaskResumeState({ linearQuery: nextQuery.trim() })
|
|
}
|
|
}}
|
|
onBlur={() => {
|
|
if (isGithubProjectSearch) {
|
|
applyGitHubProjectSearch()
|
|
}
|
|
}}
|
|
// Why: Project clear means unfiltered results ('' override when the view
|
|
// has a default filter), not restore view default. GitHub items clear
|
|
// restores the preset query. Linear clears and persists empty resume.
|
|
onClear={() => {
|
|
if (isGithubProjectSearch) {
|
|
const viewFilter = githubProjectTable?.selectedView.filter ?? ''
|
|
setGithubProjectSearch('')
|
|
// Why: undefined = use view default; '' = explicit unfiltered override.
|
|
setAppliedGithubProjectSearch(viewFilter ? '' : undefined)
|
|
return
|
|
}
|
|
if (provider === 'github') {
|
|
const nextQuery = getTaskPresetQuery(githubPreset)
|
|
setQuery(nextQuery)
|
|
setAppliedQuery(nextQuery)
|
|
persistTaskResumeState({
|
|
githubItemsPreset: githubPreset,
|
|
githubItemsQuery: nextQuery
|
|
})
|
|
return
|
|
}
|
|
setQuery('')
|
|
setAppliedQuery('')
|
|
if (provider === 'linear') {
|
|
persistTaskResumeState({ linearQuery: '' })
|
|
}
|
|
}}
|
|
/>
|
|
</View>
|
|
)}
|
|
</View>
|
|
|
|
{error ? (
|
|
<View style={styles.errorBanner}>
|
|
<Text style={styles.errorText}>{error}</Text>
|
|
</View>
|
|
) : null}
|
|
|
|
{!error && provider === 'github' && githubMode === 'items'
|
|
? githubSourceFallbacks.map((fallback) => (
|
|
<View
|
|
key={`github-source-fallback:${fallback.repoId}`}
|
|
style={styles.sourceNoticeBanner}
|
|
>
|
|
<Text style={styles.sourceNoticeText}>
|
|
Preferred issue source upstream is unavailable for {fallback.repoLabel}. Using
|
|
origin.
|
|
</Text>
|
|
</View>
|
|
))
|
|
: null}
|
|
|
|
{!error && provider === 'github' && githubMode === 'items'
|
|
? githubSourceErrors.map((sourceError) => {
|
|
const isRetrying = retryingGithubSourceRepoPaths.has(sourceError.repoPath)
|
|
return (
|
|
<View
|
|
key={`github-source-error:${sourceError.repoId}:${sourceError.source.owner}/${sourceError.source.repo}`}
|
|
style={styles.sourceErrorBanner}
|
|
>
|
|
<View style={styles.sourceErrorCopy}>
|
|
<Text style={styles.sourceErrorText}>
|
|
Couldn't load issues from{' '}
|
|
<Text style={styles.sourceErrorSlug}>
|
|
{sourceError.source.owner}/{sourceError.source.repo}
|
|
</Text>
|
|
.
|
|
</Text>
|
|
<Text style={styles.sourceErrorMessage} numberOfLines={2}>
|
|
{sourceError.message}
|
|
</Text>
|
|
</View>
|
|
<Pressable
|
|
accessibilityRole="button"
|
|
accessibilityLabel={`Retry loading issues from ${sourceError.source.owner}/${sourceError.source.repo}`}
|
|
style={styles.sourceErrorRetry}
|
|
disabled={loading || isRetrying}
|
|
onPress={() => void retryGitHubIssueSourceFetch(sourceError.repoPath)}
|
|
>
|
|
<Text style={styles.sourceErrorRetryText}>
|
|
{isRetrying ? 'Retrying...' : 'Retry'}
|
|
</Text>
|
|
</Pressable>
|
|
</View>
|
|
)
|
|
})
|
|
: null}
|
|
|
|
{!error &&
|
|
provider === 'github' &&
|
|
githubMode === 'project' &&
|
|
githubProjectTable?.parentFieldDropped === true ? (
|
|
<View style={styles.projectDataNotice}>
|
|
<AlertTriangle size={15} color={colors.statusAmber} />
|
|
<Text style={styles.projectDataNoticeText}>
|
|
Sub-issue data is unavailable for your token.
|
|
</Text>
|
|
</View>
|
|
) : null}
|
|
|
|
{!tasksSupported ? (
|
|
tasksUnsupported ? (
|
|
<View style={styles.centered}>
|
|
<Text style={styles.emptyText}>Update Orca desktop</Text>
|
|
<Text style={styles.centeredHint}>
|
|
This mobile Tasks view needs a newer desktop runtime.
|
|
</Text>
|
|
</View>
|
|
) : (
|
|
<View style={styles.centered}>
|
|
<ActivityIndicator size="small" color={colors.textSecondary} />
|
|
</View>
|
|
)
|
|
) : provider === 'linear' && !linearConnected ? (
|
|
<View style={styles.centered}>
|
|
<TaskProviderLogo provider="linear" size={32} color={colors.textSecondary} />
|
|
<Text style={styles.emptyText}>Connect your Linear account</Text>
|
|
<Text style={styles.centeredHint}>
|
|
Browse and start work on your assigned Linear issues directly from Tasks.
|
|
</Text>
|
|
<Pressable
|
|
style={[styles.targetButton, styles.centerActionButton]}
|
|
disabled={!taskUiReady}
|
|
onPress={() => {
|
|
if (!taskUiReady) {
|
|
return
|
|
}
|
|
setLinearApiKeyDraft('')
|
|
setLinearConnectState('idle')
|
|
setLinearConnectError('')
|
|
setShowLinearConnect(true)
|
|
}}
|
|
>
|
|
<Text style={styles.targetButtonText}>Connect Linear</Text>
|
|
</Pressable>
|
|
</View>
|
|
) : provider === 'github' && githubMode === 'project' ? (
|
|
githubProjectLoading ? (
|
|
<View style={styles.centered}>
|
|
<ActivityIndicator size="small" color={colors.textSecondary} />
|
|
</View>
|
|
) : !activeGitHubProject ? (
|
|
<View style={styles.centered}>
|
|
<Text style={styles.emptyText}>Choose a GitHub project</Text>
|
|
<Pressable
|
|
style={[styles.targetButton, styles.centerActionButton]}
|
|
disabled={!taskUiReady}
|
|
onPress={() => {
|
|
if (!taskUiReady) {
|
|
return
|
|
}
|
|
setShowGitHubProjectPicker(true)
|
|
}}
|
|
>
|
|
<Text style={styles.targetButtonText}>Browse projects</Text>
|
|
</Pressable>
|
|
</View>
|
|
) : githubProjectError ? (
|
|
<View style={styles.centered}>
|
|
<Text style={styles.emptyText}>{githubProjectError}</Text>
|
|
</View>
|
|
) : githubProjectTable && !githubProjectRepoSlugReady ? (
|
|
<View style={styles.centered}>
|
|
<ActivityIndicator size="small" color={colors.textSecondary} />
|
|
</View>
|
|
) : !githubProjectTable || visibleGitHubProjectRows.length === 0 ? (
|
|
<View style={styles.centered}>
|
|
<Text style={styles.emptyText}>No project items</Text>
|
|
</View>
|
|
) : (
|
|
<FlatList
|
|
data={githubProjectListEntries}
|
|
keyExtractor={(entry) =>
|
|
entry.type === 'group' ? `group:${entry.group.key}` : entry.row.id
|
|
}
|
|
ItemSeparatorComponent={() => <View style={styles.separator} />}
|
|
contentContainerStyle={[styles.list, { paddingBottom: spacing.lg + insets.bottom }]}
|
|
refreshing={githubProjectLoading}
|
|
onRefresh={() =>
|
|
void loadGitHubProjectTable({ queryOverride: appliedGithubProjectSearch })
|
|
}
|
|
renderItem={({ item: entry }) => {
|
|
if (entry.type === 'group') {
|
|
return (
|
|
<Pressable
|
|
style={styles.projectGroupHeader}
|
|
onPress={() =>
|
|
setCollapsedGitHubProjectGroups((current) => {
|
|
const next = new Set(current)
|
|
if (next.has(entry.group.key)) {
|
|
next.delete(entry.group.key)
|
|
} else {
|
|
next.add(entry.group.key)
|
|
}
|
|
return next
|
|
})
|
|
}
|
|
>
|
|
<ChevronDown
|
|
size={14}
|
|
color={colors.textMuted}
|
|
style={entry.collapsed ? styles.projectGroupChevronCollapsed : undefined}
|
|
/>
|
|
<Text style={styles.projectGroupTitle} numberOfLines={1}>
|
|
{entry.group.label || 'Items'}
|
|
</Text>
|
|
<Text style={styles.projectGroupMeta}>{projectGroupMeta(entry.group)}</Text>
|
|
</Pressable>
|
|
)
|
|
}
|
|
const row = entry.row
|
|
const repo = findProjectRowRepo(row)
|
|
return (
|
|
<Pressable
|
|
style={({ pressed }) => [styles.taskRow, pressed && styles.taskRowPressed]}
|
|
onPress={() => {
|
|
triggerMediumImpact()
|
|
setProjectRowItem(row)
|
|
}}
|
|
>
|
|
<View style={styles.taskIcon}>
|
|
<TaskProviderLogo provider="github" size={15} color={colors.textSecondary} />
|
|
</View>
|
|
<View style={styles.taskMain}>
|
|
<View style={styles.taskTitleRow}>
|
|
<Text style={styles.taskTitle} numberOfLines={2}>
|
|
{row.content.title}
|
|
</Text>
|
|
<Text style={styles.updatedAt}>{formatUpdatedAt(row.updatedAt)}</Text>
|
|
</View>
|
|
<View style={styles.metaRow}>
|
|
<View
|
|
style={[
|
|
styles.repoDot,
|
|
{
|
|
backgroundColor: getRepoBadgeColor(
|
|
repo ?? undefined,
|
|
row.content.repository ?? 'project'
|
|
)
|
|
}
|
|
]}
|
|
/>
|
|
<Text style={styles.subtitle} numberOfLines={1}>
|
|
{row.itemType === 'PULL_REQUEST'
|
|
? 'Pull request'
|
|
: row.itemType === 'ISSUE'
|
|
? 'Issue'
|
|
: 'Project item'}{' '}
|
|
· {row.content.repository ?? githubProjectTable.project.title}
|
|
{row.content.number ? ` #${row.content.number}` : ''}
|
|
</Text>
|
|
</View>
|
|
{githubProjectSummaryFields.length > 0 ? (
|
|
<View style={styles.projectFieldPillRow}>
|
|
{githubProjectSummaryFields.slice(0, 4).map((field) => {
|
|
const value = projectFieldDisplayLabel(row, field)
|
|
const isEmpty = value === 'Empty'
|
|
return (
|
|
<View key={field.id} style={styles.projectFieldPill}>
|
|
<Text style={styles.projectFieldPillText} numberOfLines={1}>
|
|
{field.name}:{' '}
|
|
<Text
|
|
style={isEmpty ? styles.projectFieldPillEmptyText : undefined}
|
|
>
|
|
{value}
|
|
</Text>
|
|
</Text>
|
|
</View>
|
|
)
|
|
})}
|
|
{githubProjectSummaryFields.length > 4 ? (
|
|
<View style={styles.projectFieldPill}>
|
|
<Text style={styles.projectFieldPillText}>
|
|
+{githubProjectSummaryFields.length - 4} fields
|
|
</Text>
|
|
</View>
|
|
) : null}
|
|
</View>
|
|
) : null}
|
|
</View>
|
|
<View style={styles.statusPill}>
|
|
<Text style={styles.statusText} numberOfLines={1}>
|
|
{projectRowStatusLabel(row)}
|
|
</Text>
|
|
</View>
|
|
</Pressable>
|
|
)
|
|
}}
|
|
/>
|
|
)
|
|
) : provider === 'linear' ? (
|
|
loading ? (
|
|
<View style={styles.centered}>
|
|
<ActivityIndicator size="small" color={colors.textSecondary} />
|
|
</View>
|
|
) : linearIssuesForView.length === 0 ? (
|
|
<View style={styles.centered}>
|
|
<Text style={styles.emptyText}>{emptyLabel}</Text>
|
|
</View>
|
|
) : linearViewMode === 'board' ? (
|
|
<ScrollView
|
|
horizontal
|
|
showsHorizontalScrollIndicator={false}
|
|
contentContainerStyle={[
|
|
styles.boardContainer,
|
|
{ paddingBottom: spacing.lg + insets.bottom }
|
|
]}
|
|
>
|
|
{linearBoardSections.map((section) => (
|
|
<View key={section.key} style={styles.boardColumn}>
|
|
<View style={styles.boardHeader}>
|
|
<View style={[styles.repoSectionDot, { backgroundColor: section.color }]} />
|
|
<Text style={styles.boardTitle} numberOfLines={1}>
|
|
{section.label}
|
|
</Text>
|
|
<Text style={styles.boardCount}>{section.issues.length}</Text>
|
|
</View>
|
|
<ScrollView showsVerticalScrollIndicator={false}>
|
|
{section.issues.map((issue) => (
|
|
<Pressable
|
|
key={issue.id}
|
|
style={({ pressed }) => [styles.boardCard, pressed && styles.taskRowPressed]}
|
|
onPress={() => {
|
|
triggerMediumImpact()
|
|
setActionItem(
|
|
createLinearTask(issue) as Extract<TaskItem, { provider: 'linear' }>
|
|
)
|
|
}}
|
|
>
|
|
<Text style={styles.taskTitle} numberOfLines={3}>
|
|
{issue.title}
|
|
</Text>
|
|
<Text style={styles.subtitle} numberOfLines={2}>
|
|
{linearIssueSecondaryParts(issue, effectiveLinearDisplayProperties).join(
|
|
' · '
|
|
)}
|
|
</Text>
|
|
{effectiveLinearDisplayProperties.has('state') ? (
|
|
<Pressable
|
|
style={[styles.statusPillSelf, styles.linearStatePill]}
|
|
disabled={mutatingStatus}
|
|
accessibilityRole="button"
|
|
accessibilityLabel={`Change status from ${issue.state.name}`}
|
|
onPress={(event) => {
|
|
event.stopPropagation()
|
|
triggerMediumImpact()
|
|
setLinearStatusPickerItem(
|
|
createLinearTask(issue) as Extract<TaskItem, { provider: 'linear' }>
|
|
)
|
|
}}
|
|
>
|
|
<View
|
|
style={[
|
|
styles.linearStateDot,
|
|
{ backgroundColor: issue.state.color || colors.textMuted }
|
|
]}
|
|
/>
|
|
<Text
|
|
style={[styles.statusText, styles.statusTextFlex]}
|
|
numberOfLines={1}
|
|
>
|
|
{issue.state.name}
|
|
</Text>
|
|
<ChevronDown size={12} color={colors.textSecondary} />
|
|
</Pressable>
|
|
) : null}
|
|
</Pressable>
|
|
))}
|
|
</ScrollView>
|
|
</View>
|
|
))}
|
|
</ScrollView>
|
|
) : (
|
|
<FlatList
|
|
data={linearListEntries}
|
|
keyExtractor={(entry) =>
|
|
entry.type === 'section' ? `linear-section:${entry.section.key}` : entry.issue.id
|
|
}
|
|
ItemSeparatorComponent={({ leadingItem, trailingItem }) =>
|
|
leadingItem?.type === 'section' || trailingItem?.type === 'section' ? null : (
|
|
<View style={styles.separator} />
|
|
)
|
|
}
|
|
contentContainerStyle={[styles.list, { paddingBottom: spacing.lg + insets.bottom }]}
|
|
refreshing={refreshing}
|
|
onRefresh={() => void loadTasks({ silent: true })}
|
|
renderItem={({ item: entry }) => {
|
|
if (entry.type === 'section') {
|
|
return (
|
|
<View style={styles.repoSectionHeader}>
|
|
<View
|
|
style={[styles.repoSectionDot, { backgroundColor: entry.section.color }]}
|
|
/>
|
|
<Text style={styles.repoSectionTitle} numberOfLines={1}>
|
|
{entry.section.label}
|
|
</Text>
|
|
<Text style={styles.boardCount}>{entry.section.issues.length}</Text>
|
|
</View>
|
|
)
|
|
}
|
|
const issue = entry.issue
|
|
const linearTask = createLinearTask(issue) as Extract<
|
|
TaskItem,
|
|
{ provider: 'linear' }
|
|
>
|
|
return (
|
|
<Pressable
|
|
style={({ pressed }) => [styles.taskRow, pressed && styles.taskRowPressed]}
|
|
onPress={() => {
|
|
triggerMediumImpact()
|
|
setActionItem(linearTask)
|
|
}}
|
|
>
|
|
<View style={styles.taskIcon}>
|
|
<TaskProviderLogo provider="linear" size={15} color={colors.textSecondary} />
|
|
</View>
|
|
<View style={styles.taskMain}>
|
|
<View style={styles.taskTitleRow}>
|
|
<Text style={styles.taskTitle} numberOfLines={2}>
|
|
{issue.title}
|
|
</Text>
|
|
{effectiveLinearDisplayProperties.has('updated') ? (
|
|
<Text style={styles.updatedAt}>{formatUpdatedAt(issue.updatedAt)}</Text>
|
|
) : null}
|
|
</View>
|
|
<View style={styles.metaRow}>
|
|
<View style={[styles.repoDot, { backgroundColor: issue.state.color }]} />
|
|
<Text style={styles.subtitle} numberOfLines={1}>
|
|
{linearIssueSecondaryParts(issue, effectiveLinearDisplayProperties).join(
|
|
' · '
|
|
)}
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
<View style={styles.linearListTrailing}>
|
|
{effectiveLinearDisplayProperties.has('state') ? (
|
|
<Pressable
|
|
style={[styles.statusPill, styles.linearStatePill]}
|
|
disabled={mutatingStatus}
|
|
accessibilityRole="button"
|
|
accessibilityLabel={`Change status from ${issue.state.name}`}
|
|
onPress={(event) => {
|
|
event.stopPropagation()
|
|
triggerMediumImpact()
|
|
setLinearStatusPickerItem(linearTask)
|
|
}}
|
|
>
|
|
<View
|
|
style={[
|
|
styles.linearStateDot,
|
|
{ backgroundColor: issue.state.color || colors.textMuted }
|
|
]}
|
|
/>
|
|
<Text style={[styles.statusText, styles.statusTextFlex]} numberOfLines={1}>
|
|
{issue.state.name}
|
|
</Text>
|
|
<ChevronDown size={12} color={colors.textSecondary} />
|
|
</Pressable>
|
|
) : null}
|
|
</View>
|
|
</Pressable>
|
|
)
|
|
}}
|
|
/>
|
|
)
|
|
) : loading ? (
|
|
<View style={styles.centered}>
|
|
<ActivityIndicator size="small" color={colors.textSecondary} />
|
|
</View>
|
|
) : sortedItems.length === 0 ? (
|
|
<View style={styles.centered}>
|
|
<Text style={styles.emptyText}>{emptyLabel}</Text>
|
|
</View>
|
|
) : (
|
|
<FlatList
|
|
data={displayedEntries}
|
|
keyExtractor={(entry) => entry.key}
|
|
ItemSeparatorComponent={({ leadingItem, trailingItem }) =>
|
|
leadingItem?.type === 'section' || trailingItem?.type === 'section' ? null : (
|
|
<View style={styles.separator} />
|
|
)
|
|
}
|
|
contentContainerStyle={[styles.list, { paddingBottom: spacing.lg + insets.bottom }]}
|
|
refreshing={refreshing}
|
|
onRefresh={() => void loadTasks({ silent: true })}
|
|
ListFooterComponent={
|
|
provider === 'github' && githubMode === 'items' && githubCanShowPagination ? (
|
|
<View style={styles.paginationFooter}>
|
|
<Pressable
|
|
accessibilityRole="button"
|
|
accessibilityLabel="Previous page"
|
|
accessibilityState={{
|
|
disabled: githubCurrentPage === 0 || githubPaginationLoading
|
|
}}
|
|
style={[
|
|
styles.paginationButton,
|
|
(githubCurrentPage === 0 || githubPaginationLoading) &&
|
|
styles.paginationButtonDisabled
|
|
]}
|
|
disabled={githubCurrentPage === 0 || githubPaginationLoading}
|
|
onPress={() => void handleGitHubPageChange(githubCurrentPage - 1)}
|
|
>
|
|
<ChevronLeft size={17} color={colors.textPrimary} />
|
|
</Pressable>
|
|
<Pressable
|
|
accessibilityRole="button"
|
|
accessibilityLabel={
|
|
githubTotalCount === null
|
|
? `Select page, Page ${githubCurrentPage + 1}`
|
|
: `Select page, Page ${githubCurrentPage + 1} of ${githubTotalPages}`
|
|
}
|
|
accessibilityState={{ disabled: githubPaginationLoading }}
|
|
style={styles.paginationLabelButton}
|
|
disabled={githubPaginationLoading}
|
|
onPress={() => {
|
|
if (!taskUiReady) {
|
|
return
|
|
}
|
|
setShowGitHubPagePicker(true)
|
|
}}
|
|
>
|
|
<Text style={styles.paginationLabel}>
|
|
{githubTotalCount === null
|
|
? `Page ${githubCurrentPage + 1}`
|
|
: `Page ${githubCurrentPage + 1} of ${githubTotalPages}`}
|
|
</Text>
|
|
</Pressable>
|
|
<Pressable
|
|
accessibilityRole="button"
|
|
accessibilityLabel="Next page"
|
|
accessibilityState={{
|
|
disabled:
|
|
(!githubCanLoadUncountedNextPage &&
|
|
githubCurrentPage >= githubTotalPages - 1) ||
|
|
githubPaginationLoading
|
|
}}
|
|
style={[
|
|
styles.paginationButton,
|
|
((!githubCanLoadUncountedNextPage &&
|
|
githubCurrentPage >= githubTotalPages - 1) ||
|
|
githubPaginationLoading) &&
|
|
styles.paginationButtonDisabled
|
|
]}
|
|
disabled={
|
|
(!githubCanLoadUncountedNextPage &&
|
|
githubCurrentPage >= githubTotalPages - 1) ||
|
|
githubPaginationLoading
|
|
}
|
|
onPress={() => void handleGitHubPageChange(githubCurrentPage + 1)}
|
|
>
|
|
{githubLoadingTargetPage === githubCurrentPage + 1 ? (
|
|
<ActivityIndicator size="small" color={colors.textPrimary} />
|
|
) : (
|
|
<ChevronRight size={17} color={colors.textPrimary} />
|
|
)}
|
|
</Pressable>
|
|
</View>
|
|
) : null
|
|
}
|
|
renderItem={({ item: entry }) => {
|
|
if (entry.type === 'section') {
|
|
return (
|
|
<View style={styles.repoSectionHeader}>
|
|
<View style={[styles.repoSectionDot, { backgroundColor: entry.color }]} />
|
|
<Text style={styles.repoSectionTitle} numberOfLines={1}>
|
|
{entry.label}
|
|
</Text>
|
|
</View>
|
|
)
|
|
}
|
|
const item = entry.item
|
|
const repo = taskRepositoryMeta(item, reposById)
|
|
const isGitHubPr = item.provider === 'github' && item.source.type === 'pr'
|
|
const isGitLabMr = item.provider === 'gitlab' && item.source.type === 'mr'
|
|
const githubPrDelta = isGitHubPr ? formatGitHubPRDelta(item.source) : null
|
|
const branchSummary = hostedBranchSummary(item)
|
|
return (
|
|
<Pressable
|
|
style={({ pressed }) => [styles.taskRow, pressed && styles.taskRowPressed]}
|
|
onPress={() => {
|
|
triggerMediumImpact()
|
|
if (item.provider === 'gitlabTodo') {
|
|
void Linking.openURL(item.source.targetUrl)
|
|
return
|
|
}
|
|
setActionItem(item)
|
|
}}
|
|
>
|
|
<View style={styles.taskIcon}>
|
|
<TaskProviderLogo
|
|
provider={item.provider === 'gitlabTodo' ? 'gitlab' : item.provider}
|
|
size={15}
|
|
color={colors.textSecondary}
|
|
/>
|
|
</View>
|
|
<View style={styles.taskMain}>
|
|
<View style={styles.taskTitleRow}>
|
|
<Text style={styles.taskTitle} numberOfLines={2}>
|
|
{item.title}
|
|
</Text>
|
|
<Text style={styles.updatedAt}>{formatUpdatedAt(item.updatedAt)}</Text>
|
|
</View>
|
|
<View style={styles.metaRow}>
|
|
<View style={[styles.repoDot, { backgroundColor: repo.color }]} />
|
|
<Text style={styles.subtitle} numberOfLines={1}>
|
|
{taskKindLabel(item)} · {item.subtitle}
|
|
</Text>
|
|
</View>
|
|
{branchSummary ? (
|
|
<View style={styles.branchMetaRow}>
|
|
<GitBranch size={11} color={colors.textMuted} />
|
|
<Text style={styles.branchMetaText} numberOfLines={1}>
|
|
{branchSummary.head}
|
|
</Text>
|
|
<Text style={styles.branchMetaBase} numberOfLines={1}>
|
|
into {branchSummary.base}
|
|
</Text>
|
|
</View>
|
|
) : null}
|
|
{isGitHubPr || isGitLabMr ? (
|
|
<View style={styles.prSignalRow}>
|
|
{isGitHubPr && githubPrDelta ? (
|
|
<View style={styles.prSignalChip}>
|
|
<Text style={styles.prSignalText} numberOfLines={1}>
|
|
{githubPrDelta}
|
|
</Text>
|
|
</View>
|
|
) : null}
|
|
{isGitHubPr || isGitLabMr ? (
|
|
<View
|
|
style={[
|
|
styles.prSignalChip,
|
|
getPrSignalToneStyle(getHostedReviewSignalTone(item.source, 'review'))
|
|
]}
|
|
>
|
|
<Text style={styles.prSignalText} numberOfLines={1}>
|
|
{isGitHubPr
|
|
? getGitHubReviewSummary(item.source)
|
|
: getHostedReviewLabel(item.source)}
|
|
</Text>
|
|
</View>
|
|
) : null}
|
|
<View
|
|
style={[
|
|
styles.prSignalChip,
|
|
getPrSignalToneStyle(getHostedReviewSignalTone(item.source, 'checks'))
|
|
]}
|
|
>
|
|
<Text style={styles.prSignalText} numberOfLines={1}>
|
|
{getHostedChecksLabel(item.source)}
|
|
</Text>
|
|
</View>
|
|
{isGitHubPr || isGitLabMr ? (
|
|
<View
|
|
style={[
|
|
styles.prSignalChip,
|
|
getPrSignalToneStyle(getHostedReviewSignalTone(item.source, 'merge'))
|
|
]}
|
|
>
|
|
<Text style={styles.prSignalText} numberOfLines={1}>
|
|
{isGitHubPr
|
|
? getGitHubMergeLabel(item.source)
|
|
: getHostedMergeLabel(item.source)}
|
|
</Text>
|
|
</View>
|
|
) : null}
|
|
</View>
|
|
) : null}
|
|
</View>
|
|
<View style={styles.taskRowTrailing}>
|
|
<View style={styles.statusPill}>
|
|
<Text style={styles.statusText} numberOfLines={1}>
|
|
{item.status}
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
</Pressable>
|
|
)
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
<PickerModal
|
|
visible={taskUiReady && showProviderPicker}
|
|
title="Task Source"
|
|
options={providerOptions}
|
|
selected={provider}
|
|
onSelect={(next) => {
|
|
const resume = taskResumeRef.current
|
|
persistTaskSource(next)
|
|
setProvider(next)
|
|
setItems([])
|
|
if (next === 'github') {
|
|
const nextMode = resume.githubMode === 'project' ? 'project' : 'items'
|
|
setGithubMode(nextMode)
|
|
if (nextMode === 'project') {
|
|
setQuery('')
|
|
setAppliedQuery('')
|
|
return
|
|
}
|
|
const preset =
|
|
resume.githubItemsPreset === null
|
|
? githubPreset
|
|
: normalizeGitHubPreset(resume.githubItemsPreset ?? githubPreset)
|
|
const nextQuery =
|
|
resume.githubItemsPreset === null
|
|
? (resume.githubItemsQuery ?? '')
|
|
: getTaskPresetQuery(preset)
|
|
const nextKind = githubKindFromQuery(nextQuery, preset)
|
|
setGithubPreset(preset)
|
|
setGithubKind(nextKind)
|
|
setQuery(nextQuery)
|
|
setAppliedQuery(scopeGitHubTaskSearch(nextQuery, nextKind))
|
|
} else if (next === 'linear') {
|
|
const nextQuery = resume.linearQuery ?? ''
|
|
setLinearFilter(normalizeLinearFilter(resume.linearPreset))
|
|
setQuery(nextQuery)
|
|
setAppliedQuery(nextQuery.trim())
|
|
} else {
|
|
setQuery('')
|
|
setAppliedQuery('')
|
|
}
|
|
}}
|
|
onClose={() => setShowProviderPicker(false)}
|
|
/>
|
|
|
|
<BottomDrawer
|
|
visible={taskUiReady && showRepoPicker}
|
|
onClose={() => setShowRepoPicker(false)}
|
|
>
|
|
<View style={styles.sheetHeader}>
|
|
<Text style={styles.sheetTitle}>Repositories</Text>
|
|
<Text style={styles.sheetSubtitle}>Choose which repositories to query.</Text>
|
|
</View>
|
|
|
|
<View style={styles.repoPickerGroup}>
|
|
<Pressable
|
|
style={styles.repoPickerRow}
|
|
onPress={() => {
|
|
const allSelection = new Set<string>()
|
|
setSelectedRepoIds(allSelection)
|
|
persistRepoSelection(allSelection, hostedRepos)
|
|
}}
|
|
>
|
|
<View style={styles.repoPickerTextWrap}>
|
|
<Text style={styles.repoPickerTitle}>All repositories</Text>
|
|
<Text style={styles.repoPickerSubtitle}>{repositoryCount(hostedRepos.length)}</Text>
|
|
</View>
|
|
{selectedRepoIds.size === 0 ? <Check size={15} color={colors.textPrimary} /> : null}
|
|
</Pressable>
|
|
|
|
{hostedRepos.map((repo) => {
|
|
const selected = selectedRepoIds.has(repo.id)
|
|
return (
|
|
<View key={repo.id}>
|
|
<View style={styles.actionSeparator} />
|
|
<Pressable
|
|
style={styles.repoPickerRow}
|
|
onPress={() => toggleRepoSelection(repo.id)}
|
|
>
|
|
<View
|
|
style={[
|
|
styles.pickerRepoDot,
|
|
{ backgroundColor: getRepoBadgeColor(repo, repo.displayName) }
|
|
]}
|
|
/>
|
|
<View style={styles.repoPickerTextWrap}>
|
|
<Text style={styles.repoPickerTitle} numberOfLines={1}>
|
|
{repo.displayName}
|
|
</Text>
|
|
<Text style={styles.repoPickerSubtitle} numberOfLines={1}>
|
|
{repo.path}
|
|
</Text>
|
|
</View>
|
|
{selected ? <Check size={15} color={colors.textPrimary} /> : null}
|
|
</Pressable>
|
|
</View>
|
|
)
|
|
})}
|
|
</View>
|
|
</BottomDrawer>
|
|
|
|
<BottomDrawer
|
|
visible={taskUiReady && showGitHubIssueSourcePicker}
|
|
onClose={() => setShowGitHubIssueSourcePicker(false)}
|
|
>
|
|
<View style={styles.sheetHeader}>
|
|
<Text style={styles.sheetTitle}>GitHub Issue Sources</Text>
|
|
<Text style={styles.sheetSubtitle}>
|
|
Choose whether each repository queries and creates work from upstream or origin.
|
|
</Text>
|
|
</View>
|
|
|
|
<View style={styles.repoPickerGroup}>
|
|
{githubIssueSourceRows.length === 0 ? (
|
|
<View style={styles.drawerLoadingRow}>
|
|
<Text style={styles.detailMuted}>No alternate issue sources available.</Text>
|
|
</View>
|
|
) : (
|
|
githubIssueSourceRows.map(({ repo, sources }, index) => {
|
|
const selectedPreference =
|
|
repo.issueSourcePreference === 'origin' || repo.issueSourcePreference === 'upstream'
|
|
? repo.issueSourcePreference
|
|
: 'upstream'
|
|
return (
|
|
<View key={repo.id}>
|
|
{index > 0 ? <View style={styles.actionSeparator} /> : null}
|
|
<View style={styles.issueSourceBox}>
|
|
<View style={styles.repoPickerTextWrap}>
|
|
<Text style={styles.repoPickerTitle} numberOfLines={1}>
|
|
{repo.displayName}
|
|
</Text>
|
|
<Text style={styles.issueSourceHint} numberOfLines={2}>
|
|
Querying{' '}
|
|
{issueSourceSlug(
|
|
selectedPreference === 'origin' ? sources.prs : sources.upstreamCandidate
|
|
)}
|
|
</Text>
|
|
</View>
|
|
<View style={styles.issueSourceSegment}>
|
|
{(['upstream', 'origin'] as const).map((preference) => {
|
|
const selected = selectedPreference === preference
|
|
const slug =
|
|
preference === 'upstream'
|
|
? issueSourceSlug(sources.upstreamCandidate)
|
|
: issueSourceSlug(sources.prs)
|
|
return (
|
|
<Pressable
|
|
key={preference}
|
|
style={[
|
|
styles.issueSourceSegmentButton,
|
|
selected && styles.issueSourceSegmentButtonActive
|
|
]}
|
|
accessibilityState={{ selected }}
|
|
onPress={() => void setGitHubIssueSourcePreference(repo, preference)}
|
|
>
|
|
<Text
|
|
style={[
|
|
styles.issueSourceSegmentText,
|
|
selected && styles.issueSourceSegmentTextActive
|
|
]}
|
|
>
|
|
{preference === 'upstream' ? 'Upstream' : 'Origin'}
|
|
</Text>
|
|
<Text style={styles.issueSourceSlug} numberOfLines={1}>
|
|
{slug}
|
|
</Text>
|
|
</Pressable>
|
|
)
|
|
})}
|
|
</View>
|
|
</View>
|
|
</View>
|
|
)
|
|
})
|
|
)}
|
|
</View>
|
|
</BottomDrawer>
|
|
|
|
<PickerModal
|
|
visible={taskUiReady && showGitHubKindPicker}
|
|
title="GitHub View"
|
|
options={GITHUB_KIND_OPTIONS}
|
|
selected={githubMode === 'project' ? 'project' : githubKind}
|
|
onSelect={(kind) => {
|
|
if (kind === 'project') {
|
|
setGithubMode('project')
|
|
setItems([])
|
|
persistTaskResumeState({ githubMode: 'project' })
|
|
return
|
|
}
|
|
const preset = kind === 'prs' ? 'prs' : 'issues'
|
|
const nextQuery = getTaskPresetQuery(preset)
|
|
setGithubMode('items')
|
|
setGithubKind(kind)
|
|
setGithubPreset(preset)
|
|
setQuery(nextQuery)
|
|
setAppliedQuery(nextQuery)
|
|
persistTaskResumeState({
|
|
githubMode: 'items',
|
|
githubItemsPreset: preset,
|
|
githubItemsQuery: nextQuery
|
|
})
|
|
}}
|
|
onClose={() => setShowGitHubKindPicker(false)}
|
|
/>
|
|
|
|
<PickerModal
|
|
visible={taskUiReady && showGitHubPresetPicker}
|
|
title={githubKind === 'prs' ? 'Pull Requests' : 'Issues'}
|
|
options={githubPresetPickerOptions}
|
|
selected={githubPreset}
|
|
onSelect={(preset) => {
|
|
const nextQuery = getTaskPresetQuery(preset)
|
|
setGithubMode('items')
|
|
setGithubKind(preset === 'issues' || preset === 'my-issues' ? 'issues' : 'prs')
|
|
setGithubPreset(preset)
|
|
setQuery(nextQuery)
|
|
setAppliedQuery(nextQuery)
|
|
persistTaskResumeState({
|
|
githubItemsPreset: preset,
|
|
githubItemsQuery: nextQuery
|
|
})
|
|
}}
|
|
onLongSelect={persistDefaultGitHubPreset}
|
|
onClose={() => setShowGitHubPresetPicker(false)}
|
|
/>
|
|
|
|
<BottomDrawer
|
|
visible={taskUiReady && showGitHubPagePicker}
|
|
onClose={() => setShowGitHubPagePicker(false)}
|
|
>
|
|
<View style={styles.sheetHeader}>
|
|
<Text style={styles.sheetTitle}>GitHub Pages</Text>
|
|
<Text style={styles.sheetSubtitle}>Jump to a loaded or available result page.</Text>
|
|
</View>
|
|
<ScrollView style={styles.pagePickerList}>
|
|
{githubPagePickerPages.map((index) => {
|
|
const selected = index === githubCurrentPage
|
|
const loaded = index < githubPages.length
|
|
return (
|
|
<Pressable
|
|
key={`github-page:${index}`}
|
|
style={[styles.pickerRow, selected && styles.pickerRowSelected]}
|
|
disabled={githubPaginationLoading}
|
|
onPress={() => {
|
|
setShowGitHubPagePicker(false)
|
|
void handleGitHubPageChange(index)
|
|
}}
|
|
>
|
|
<View style={styles.pickerRowContent}>
|
|
<Text style={styles.pickerRowLabel}>Page {index + 1}</Text>
|
|
<Text style={styles.pickerRowSubtitle}>
|
|
{loaded ? 'Loaded' : 'Loads older results'}
|
|
</Text>
|
|
</View>
|
|
{selected ? <Check size={16} color={colors.textPrimary} /> : null}
|
|
</Pressable>
|
|
)
|
|
})}
|
|
</ScrollView>
|
|
</BottomDrawer>
|
|
|
|
<BottomDrawer
|
|
visible={taskUiReady && showGitHubProjectPicker}
|
|
onClose={() => setShowGitHubProjectPicker(false)}
|
|
>
|
|
<View style={styles.sheetHeader}>
|
|
<Text style={styles.sheetTitle}>GitHub Projects</Text>
|
|
<Text style={styles.sheetSubtitle}>Choose a project view for the Tasks page.</Text>
|
|
</View>
|
|
|
|
<View style={styles.projectPickerControls}>
|
|
<TextInput
|
|
style={styles.input}
|
|
value={githubProjectPickerSearch}
|
|
onChangeText={setGithubProjectPickerSearch}
|
|
placeholder="Search projects"
|
|
placeholderTextColor={colors.textMuted}
|
|
autoCapitalize="none"
|
|
autoCorrect={false}
|
|
/>
|
|
<View style={styles.projectPasteRow}>
|
|
<TextInput
|
|
style={[styles.input, styles.projectPasteInput]}
|
|
value={githubProjectPasteInput}
|
|
onChangeText={(next) => {
|
|
setGithubProjectPasteInput(next)
|
|
setGithubProjectPasteError('')
|
|
}}
|
|
onSubmitEditing={() => void resolveGitHubProjectFromInput()}
|
|
placeholder="Add by URL or owner/number"
|
|
placeholderTextColor={colors.textMuted}
|
|
autoCapitalize="none"
|
|
autoCorrect={false}
|
|
/>
|
|
<Pressable
|
|
style={[styles.inlineSaveButtonCompact, styles.projectPasteButton]}
|
|
disabled={githubProjectPasteBusy || githubProjectPasteInput.trim().length === 0}
|
|
onPress={() => void resolveGitHubProjectFromInput()}
|
|
>
|
|
<Text style={styles.inlineSaveText}>
|
|
{githubProjectPasteBusy ? 'Adding...' : 'Add'}
|
|
</Text>
|
|
</Pressable>
|
|
</View>
|
|
{githubProjectPasteError ? (
|
|
<Text style={styles.detailError}>{githubProjectPasteError}</Text>
|
|
) : null}
|
|
</View>
|
|
|
|
{githubProjectError ? <Text style={styles.detailError}>{githubProjectError}</Text> : null}
|
|
|
|
{githubProjectPartialFailures.length > 0 ? (
|
|
<View style={styles.projectWarningBanner}>
|
|
<AlertTriangle size={15} color={colors.statusAmber} />
|
|
<View style={styles.projectWarningTextWrap}>
|
|
<Text style={styles.projectWarningTitle}>
|
|
{githubProjectPartialFailures.length === 1 &&
|
|
githubProjectPartialFailures[0]!.owner !== '*'
|
|
? `Couldn't load projects from ${githubProjectPartialFailures[0]!.owner}.`
|
|
: `Some organizations didn't load (${githubProjectPartialFailures.length}).`}
|
|
</Text>
|
|
<Text style={styles.projectWarningText}>
|
|
Use Add by URL to reach missing projects.
|
|
</Text>
|
|
<Text style={styles.projectWarningText} numberOfLines={2}>
|
|
{githubProjectPartialFailures
|
|
.map(
|
|
(failure) =>
|
|
`${failure.owner === '*' ? 'orgs' : failure.owner}: ${failure.message}`
|
|
)
|
|
.join(' · ')}
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
) : null}
|
|
|
|
<View style={styles.repoPickerGroup}>
|
|
{githubProjectLoading && githubProjects.length === 0 ? (
|
|
<View style={styles.drawerLoadingRow}>
|
|
<ActivityIndicator size="small" color={colors.textSecondary} />
|
|
</View>
|
|
) : githubProjects.length === 0 &&
|
|
pinnedGitHubProjects.length === 0 &&
|
|
recentGitHubProjects.length === 0 ? (
|
|
<Pressable
|
|
style={styles.repoPickerRow}
|
|
onPress={() =>
|
|
void loadGitHubProjects().catch((err) => {
|
|
setGithubProjectError(
|
|
err instanceof Error ? err.message : 'Failed to load projects'
|
|
)
|
|
})
|
|
}
|
|
>
|
|
<View style={styles.repoPickerTextWrap}>
|
|
<Text style={styles.repoPickerTitle}>No projects loaded</Text>
|
|
<Text style={styles.repoPickerSubtitle}>Tap to retry.</Text>
|
|
</View>
|
|
</Pressable>
|
|
) : (
|
|
<>
|
|
{pinnedGitHubProjects.length > 0 ? (
|
|
<>
|
|
<Text style={styles.linearStatesTitle}>Pinned</Text>
|
|
{pinnedGitHubProjects.map((project, index) => {
|
|
const key = githubProjectKey(project)
|
|
const selected =
|
|
activeGitHubProject !== null && githubProjectKey(activeGitHubProject) === key
|
|
return (
|
|
<View key={`pinned:${key}`}>
|
|
{index > 0 ? <View style={styles.actionSeparator} /> : null}
|
|
<Pressable
|
|
style={styles.repoPickerRow}
|
|
onPress={() => {
|
|
setShowGitHubProjectPicker(false)
|
|
void selectGitHubProject(project)
|
|
}}
|
|
>
|
|
<View style={styles.repoPickerTextWrap}>
|
|
<Text style={styles.repoPickerTitle} numberOfLines={1}>
|
|
{project.summary?.title ?? `#${project.number}`}
|
|
</Text>
|
|
<Text style={styles.repoPickerSubtitle} numberOfLines={1}>
|
|
{project.owner} · #{project.number}
|
|
</Text>
|
|
</View>
|
|
<Pressable
|
|
style={styles.inlineSaveButtonCompact}
|
|
onPress={(event) => {
|
|
event.stopPropagation()
|
|
persistGitHubProjectSettings({
|
|
...githubProjectSettings,
|
|
pinned: githubProjectSettings.pinned.filter(
|
|
(entry) => githubProjectKey(entry) !== key
|
|
)
|
|
})
|
|
}}
|
|
>
|
|
<Text style={styles.inlineSaveText}>Remove</Text>
|
|
</Pressable>
|
|
{selected ? <Check size={15} color={colors.textPrimary} /> : null}
|
|
</Pressable>
|
|
</View>
|
|
)
|
|
})}
|
|
</>
|
|
) : null}
|
|
|
|
{recentGitHubProjects.length > 0 ? (
|
|
<>
|
|
<Text style={styles.linearStatesTitle}>Recent</Text>
|
|
{recentGitHubProjects.map((project, index) => {
|
|
const key = githubProjectKey(project)
|
|
const selected =
|
|
activeGitHubProject !== null && githubProjectKey(activeGitHubProject) === key
|
|
return (
|
|
<View key={`recent:${key}`}>
|
|
{index > 0 ? <View style={styles.actionSeparator} /> : null}
|
|
<Pressable
|
|
style={styles.repoPickerRow}
|
|
onPress={() => {
|
|
setShowGitHubProjectPicker(false)
|
|
void selectGitHubProject(project)
|
|
}}
|
|
>
|
|
<View style={styles.repoPickerTextWrap}>
|
|
<Text style={styles.repoPickerTitle} numberOfLines={1}>
|
|
{project.summary?.title ?? `#${project.number}`}
|
|
</Text>
|
|
<Text style={styles.repoPickerSubtitle} numberOfLines={1}>
|
|
{project.owner} · #{project.number}
|
|
</Text>
|
|
</View>
|
|
{githubProjectSettings.lastViewByProject[key]?.viewId ? (
|
|
<Pressable
|
|
style={styles.inlineSaveButtonCompact}
|
|
onPress={(event) => {
|
|
event.stopPropagation()
|
|
persistGitHubProjectSettings({
|
|
...githubProjectSettings,
|
|
pinned: [
|
|
...githubProjectSettings.pinned,
|
|
{
|
|
owner: project.owner,
|
|
ownerType: project.ownerType,
|
|
number: project.number
|
|
}
|
|
].slice(0, 20)
|
|
})
|
|
}}
|
|
>
|
|
<Text style={styles.inlineSaveText}>Pin</Text>
|
|
</Pressable>
|
|
) : null}
|
|
{selected ? <Check size={15} color={colors.textPrimary} /> : null}
|
|
</Pressable>
|
|
</View>
|
|
)
|
|
})}
|
|
</>
|
|
) : null}
|
|
|
|
<Text style={styles.linearStatesTitle}>
|
|
{githubProjectLoading ? 'Browse all (loading...)' : 'Browse all'}
|
|
</Text>
|
|
{browseGitHubProjects.length === 0 ? (
|
|
<Text style={styles.emptyInlineText}>
|
|
{githubProjectPickerSearch.trim() ? 'No matching projects.' : 'No more projects.'}
|
|
</Text>
|
|
) : (
|
|
browseGitHubProjects.map((project, index) => {
|
|
const selected =
|
|
activeGitHubProject !== null &&
|
|
githubProjectKey(activeGitHubProject) === githubProjectKey(project)
|
|
return (
|
|
<View key={project.id}>
|
|
{index > 0 ? <View style={styles.actionSeparator} /> : null}
|
|
<Pressable
|
|
style={styles.repoPickerRow}
|
|
onPress={() => {
|
|
setShowGitHubProjectPicker(false)
|
|
void selectGitHubProject(project)
|
|
}}
|
|
>
|
|
<View style={styles.repoPickerTextWrap}>
|
|
<Text style={styles.repoPickerTitle} numberOfLines={1}>
|
|
{project.title}
|
|
</Text>
|
|
<Text style={styles.repoPickerSubtitle} numberOfLines={1}>
|
|
{project.owner} · #{project.number}
|
|
</Text>
|
|
</View>
|
|
{selected ? <Check size={15} color={colors.textPrimary} /> : null}
|
|
</Pressable>
|
|
</View>
|
|
)
|
|
})
|
|
)}
|
|
</>
|
|
)}
|
|
</View>
|
|
</BottomDrawer>
|
|
|
|
<PickerModal
|
|
visible={taskUiReady && showGitHubProjectViewPicker}
|
|
title={pendingGitHubProjectViewSelection ? 'Choose Project View' : 'Project View'}
|
|
options={githubProjectViewOptions}
|
|
selected={pendingGitHubProjectViewSelection ? '' : (activeGitHubProjectViewId ?? '')}
|
|
onSelect={(viewId) => {
|
|
const view = githubProjectViews.find((candidate) => candidate.id === viewId)
|
|
if (view && view.layout !== 'TABLE_LAYOUT') {
|
|
setGithubProjectError("Orca doesn't support this GitHub Project layout yet.")
|
|
return
|
|
}
|
|
if (pendingGitHubProjectViewSelection) {
|
|
commitGitHubProjectView(pendingGitHubProjectViewSelection, viewId)
|
|
setPendingGitHubProjectViewSelection(null)
|
|
return
|
|
}
|
|
if (!activeGitHubProject || !activeGitHubProjectKey) {
|
|
return
|
|
}
|
|
commitGitHubProjectView(activeGitHubProject, viewId)
|
|
}}
|
|
onClose={() => {
|
|
setShowGitHubProjectViewPicker(false)
|
|
if (pendingGitHubProjectViewSelection) {
|
|
setPendingGitHubProjectViewSelection(null)
|
|
}
|
|
}}
|
|
/>
|
|
|
|
<PickerModal
|
|
visible={taskUiReady && showGitHubProjectSortPicker}
|
|
title="Project Sort"
|
|
options={githubProjectSortOptions}
|
|
selected={githubProjectSortOverride?.fieldId ?? PROJECT_VIEW_DEFAULT_SORT}
|
|
onSelect={(fieldId) => {
|
|
if (fieldId === PROJECT_VIEW_DEFAULT_SORT) {
|
|
setGithubProjectSortOverride(null)
|
|
return
|
|
}
|
|
setGithubProjectSortOverride((current) => {
|
|
if (!current || current.fieldId !== fieldId) {
|
|
return { fieldId, direction: 'ASC' }
|
|
}
|
|
if (current.direction === 'ASC') {
|
|
return { fieldId, direction: 'DESC' }
|
|
}
|
|
return null
|
|
})
|
|
}}
|
|
onClose={() => setShowGitHubProjectSortPicker(false)}
|
|
/>
|
|
|
|
<BottomDrawer
|
|
visible={taskUiReady && showGitHubProjectFieldsPicker}
|
|
onClose={() => setShowGitHubProjectFieldsPicker(false)}
|
|
>
|
|
<View style={styles.sheetHeader}>
|
|
<Text style={styles.sheetTitle}>Project Fields</Text>
|
|
<Text style={styles.sheetSubtitle}>
|
|
Choose which Project fields appear on item cards.
|
|
</Text>
|
|
</View>
|
|
<View style={styles.repoPickerGroup}>
|
|
{githubProjectAvailableSummaryFields.length === 0 ? (
|
|
<Text style={styles.repoPickerSubtitle}>This view has no extra fields to show.</Text>
|
|
) : (
|
|
githubProjectAvailableSummaryFields.map((field, index) => {
|
|
const visible = !githubProjectHiddenFieldIds.has(field.id)
|
|
return (
|
|
<View key={field.id}>
|
|
{index > 0 ? <View style={styles.actionSeparator} /> : null}
|
|
<Pressable
|
|
style={styles.repoPickerRow}
|
|
onPress={() => toggleGitHubProjectFieldVisibility(field.id)}
|
|
>
|
|
<View style={styles.repoPickerTextWrap}>
|
|
<Text style={styles.repoPickerTitle} numberOfLines={1}>
|
|
{field.name}
|
|
</Text>
|
|
<Text style={styles.repoPickerSubtitle} numberOfLines={1}>
|
|
{visible ? 'Shown on cards' : 'Hidden from cards'}
|
|
</Text>
|
|
</View>
|
|
{visible ? <Check size={15} color={colors.textPrimary} /> : null}
|
|
</Pressable>
|
|
</View>
|
|
)
|
|
})
|
|
)}
|
|
</View>
|
|
</BottomDrawer>
|
|
|
|
<PickerModal
|
|
visible={taskUiReady && showGitLabViewPicker}
|
|
title="GitLab View"
|
|
options={GITLAB_VIEW_OPTIONS}
|
|
selected={gitlabView}
|
|
onSelect={(view) => {
|
|
setGitlabView(view)
|
|
if (view === 'todos') {
|
|
// Why: GitLab Todos is a server-side pending-todos stream; the
|
|
// runtime method has no search query, so clear item-search state
|
|
// when entering that view instead of carrying an invisible filter.
|
|
setQuery('')
|
|
setAppliedQuery('')
|
|
}
|
|
}}
|
|
onClose={() => setShowGitLabViewPicker(false)}
|
|
/>
|
|
|
|
<PickerModal
|
|
visible={taskUiReady && showGitLabFilterPicker}
|
|
title="GitLab Filter"
|
|
options={GITLAB_FILTER_OPTIONS}
|
|
selected={gitlabFilter}
|
|
onSelect={setGitlabFilter}
|
|
onClose={() => setShowGitLabFilterPicker(false)}
|
|
/>
|
|
|
|
<PickerModal
|
|
visible={taskUiReady && showLinearFilterPicker}
|
|
title="Linear Filter"
|
|
options={LINEAR_FILTER_OPTIONS}
|
|
selected={linearFilter}
|
|
onSelect={(filter) => {
|
|
setLinearFilter(filter)
|
|
setQuery('')
|
|
setAppliedQuery('')
|
|
persistTaskResumeState({ linearPreset: filter, linearQuery: '' })
|
|
}}
|
|
onClose={() => setShowLinearFilterPicker(false)}
|
|
/>
|
|
|
|
<PickerModal
|
|
visible={taskUiReady && showLinearWorkspacePicker}
|
|
title="Linear Workspace"
|
|
options={linearWorkspaceOptions}
|
|
selected={selectedLinearWorkspaceId ?? ''}
|
|
onSelect={(workspaceId) => {
|
|
setSelectedLinearWorkspaceId(workspaceId)
|
|
setSelectedLinearTeamIds(new Set())
|
|
if (client) {
|
|
void client
|
|
.sendRequest('linear.selectWorkspace', { workspaceId })
|
|
.then(() => loadLinearContext())
|
|
.catch((err) => {
|
|
setError(err instanceof Error ? err.message : 'Failed to switch workspace')
|
|
})
|
|
}
|
|
}}
|
|
onClose={() => setShowLinearWorkspacePicker(false)}
|
|
/>
|
|
|
|
<BottomDrawer
|
|
visible={taskUiReady && showLinearTeamPicker}
|
|
onClose={() => setShowLinearTeamPicker(false)}
|
|
>
|
|
<View style={styles.sheetHeader}>
|
|
<Text style={styles.sheetTitle}>Linear Teams</Text>
|
|
<Text style={styles.sheetSubtitle}>Choose which teams appear in Tasks.</Text>
|
|
</View>
|
|
<View style={styles.repoPickerGroup}>
|
|
<Pressable
|
|
style={styles.repoPickerRow}
|
|
onPress={() => {
|
|
const next = new Set(linearTeams.map((team) => team.id))
|
|
setSelectedLinearTeamIds(next)
|
|
persistLinearTeamSelection(next, linearTeams)
|
|
}}
|
|
>
|
|
<View style={styles.repoPickerTextWrap}>
|
|
<Text style={styles.repoPickerTitle}>All teams</Text>
|
|
<Text style={styles.repoPickerSubtitle}>{linearTeams.length} teams</Text>
|
|
</View>
|
|
{selectedLinearTeamIds.size === linearTeams.length ? (
|
|
<Check size={15} color={colors.textPrimary} />
|
|
) : null}
|
|
</Pressable>
|
|
{linearTeams.map((team) => {
|
|
const selected = selectedLinearTeamIds.has(team.id)
|
|
return (
|
|
<View key={team.id}>
|
|
<View style={styles.actionSeparator} />
|
|
<Pressable
|
|
style={styles.repoPickerRow}
|
|
onPress={() => {
|
|
const next = new Set(selectedLinearTeamIds)
|
|
if (next.has(team.id)) {
|
|
next.delete(team.id)
|
|
} else {
|
|
next.add(team.id)
|
|
}
|
|
const normalized =
|
|
next.size === 0 || next.size === linearTeams.length
|
|
? new Set(linearTeams.map((entry) => entry.id))
|
|
: next
|
|
setSelectedLinearTeamIds(normalized)
|
|
persistLinearTeamSelection(normalized, linearTeams)
|
|
}}
|
|
>
|
|
<View style={styles.repoPickerTextWrap}>
|
|
<Text style={styles.repoPickerTitle} numberOfLines={1}>
|
|
{team.name}
|
|
</Text>
|
|
<Text style={styles.repoPickerSubtitle} numberOfLines={1}>
|
|
{team.workspaceName ?? team.key}
|
|
</Text>
|
|
</View>
|
|
{selected ? <Check size={15} color={colors.textPrimary} /> : null}
|
|
</Pressable>
|
|
</View>
|
|
)
|
|
})}
|
|
</View>
|
|
</BottomDrawer>
|
|
|
|
<BottomDrawer
|
|
visible={taskUiReady && linearStatusPickerItem !== null}
|
|
onClose={() => setLinearStatusPickerItem(null)}
|
|
zIndex={TASK_SECONDARY_DRAWER_Z_INDEX}
|
|
>
|
|
<View style={styles.sheetHeader}>
|
|
<Text style={styles.sheetTitle}>Change Status</Text>
|
|
<Text style={styles.sheetSubtitle}>
|
|
{linearStatusPickerItem?.source.identifier ?? 'Linear issue'}
|
|
</Text>
|
|
</View>
|
|
<View style={styles.repoPickerGroup}>
|
|
{linearStatesLoading ? (
|
|
<View style={styles.detailLoadingInline}>
|
|
<ActivityIndicator size="small" color={colors.textSecondary} />
|
|
<Text style={styles.detailMuted}>Loading states...</Text>
|
|
</View>
|
|
) : linearStates.length === 0 ? (
|
|
<Text style={styles.emptyInlineText}>No states available</Text>
|
|
) : (
|
|
linearStates.map((state, index) => {
|
|
const selected =
|
|
state.name === linearStatusPickerItem?.source.state.name &&
|
|
state.type === linearStatusPickerItem?.source.state.type
|
|
return (
|
|
<View key={state.id}>
|
|
{index > 0 ? <View style={styles.actionSeparator} /> : null}
|
|
<Pressable
|
|
style={styles.repoPickerRow}
|
|
disabled={mutatingStatus}
|
|
onPress={() => {
|
|
if (!linearStatusPickerItem) {
|
|
return
|
|
}
|
|
void setLinearStatus(linearStatusPickerItem, state, {
|
|
closeDetail: false
|
|
}).then(() => setLinearStatusPickerItem(null))
|
|
}}
|
|
>
|
|
<View
|
|
style={[
|
|
styles.pickerRepoDot,
|
|
{ backgroundColor: state.color || colors.textMuted }
|
|
]}
|
|
/>
|
|
<View style={styles.repoPickerTextWrap}>
|
|
<Text style={styles.repoPickerTitle} numberOfLines={1}>
|
|
{state.name}
|
|
</Text>
|
|
<Text style={styles.repoPickerSubtitle} numberOfLines={1}>
|
|
{state.type}
|
|
</Text>
|
|
</View>
|
|
{selected ? <Check size={15} color={colors.textPrimary} /> : null}
|
|
</Pressable>
|
|
</View>
|
|
)
|
|
})
|
|
)}
|
|
</View>
|
|
</BottomDrawer>
|
|
|
|
<PickerModal
|
|
visible={taskUiReady && showLinearViewPicker}
|
|
title="Linear View"
|
|
options={LINEAR_VIEW_OPTIONS}
|
|
selected={linearViewMode}
|
|
onSelect={setLinearViewMode}
|
|
onClose={() => setShowLinearViewPicker(false)}
|
|
/>
|
|
|
|
<PickerModal
|
|
visible={taskUiReady && showLinearGroupPicker}
|
|
title="Group Linear Issues"
|
|
options={LINEAR_GROUP_OPTIONS}
|
|
selected={linearGroupBy}
|
|
onSelect={setLinearGroupBy}
|
|
onClose={() => setShowLinearGroupPicker(false)}
|
|
/>
|
|
|
|
<PickerModal
|
|
visible={taskUiReady && showLinearOrderPicker}
|
|
title="Order Linear Issues"
|
|
options={LINEAR_ORDER_OPTIONS}
|
|
selected={linearOrderBy}
|
|
onSelect={setLinearOrderBy}
|
|
onClose={() => setShowLinearOrderPicker(false)}
|
|
/>
|
|
|
|
<BottomDrawer
|
|
visible={taskUiReady && showLinearDisplayPicker}
|
|
onClose={() => setShowLinearDisplayPicker(false)}
|
|
>
|
|
<View style={styles.sheetHeader}>
|
|
<Text style={styles.sheetTitle}>Display Properties</Text>
|
|
</View>
|
|
<View style={styles.repoPickerGroup}>
|
|
{LINEAR_DISPLAY_OPTIONS.map((property, index) => {
|
|
const selected = effectiveLinearDisplayProperties.has(property.value)
|
|
return (
|
|
<View key={property.value}>
|
|
{index > 0 ? <View style={styles.actionSeparator} /> : null}
|
|
<Pressable
|
|
style={styles.repoPickerRow}
|
|
onPress={() => {
|
|
if (property.value === 'team') {
|
|
setLinearTeamPropertyTouched(true)
|
|
}
|
|
setLinearDisplayProperties((current) => {
|
|
const next = new Set(current)
|
|
if (next.has(property.value)) {
|
|
next.delete(property.value)
|
|
} else {
|
|
next.add(property.value)
|
|
}
|
|
return next
|
|
})
|
|
}}
|
|
>
|
|
<View style={styles.repoPickerTextWrap}>
|
|
<Text style={styles.repoPickerTitle}>{property.label}</Text>
|
|
</View>
|
|
{selected ? <Check size={15} color={colors.textPrimary} /> : null}
|
|
</Pressable>
|
|
</View>
|
|
)
|
|
})}
|
|
</View>
|
|
</BottomDrawer>
|
|
|
|
<PickerModal
|
|
visible={taskUiReady && showSortPicker}
|
|
title="Sort Tasks"
|
|
options={SORT_OPTIONS}
|
|
selected={taskSort}
|
|
onSelect={setTaskSort}
|
|
onClose={() => setShowSortPicker(false)}
|
|
/>
|
|
|
|
<BottomDrawer
|
|
visible={taskUiReady && showCreateTask}
|
|
onClose={() => {
|
|
setShowCreateTargetPicker(false)
|
|
setShowCreateTask(false)
|
|
}}
|
|
>
|
|
<View style={styles.sheetHeader}>
|
|
<View style={styles.sheetTitleRow}>
|
|
<TaskProviderLogo provider={provider} size={16} color={colors.textPrimary} />
|
|
<Text style={styles.sheetTitle}>New {providerLabel} Issue</Text>
|
|
</View>
|
|
<Text style={styles.sheetSubtitle}>
|
|
{provider === 'github' || provider === 'gitlab'
|
|
? 'Create an issue in the selected repository.'
|
|
: 'Create an issue in the selected Linear team.'}
|
|
</Text>
|
|
</View>
|
|
|
|
<View style={styles.createForm}>
|
|
<Text style={styles.fieldLabel}>
|
|
{provider === 'github' || provider === 'gitlab' ? 'Repository' : 'Team'}
|
|
</Text>
|
|
<Pressable
|
|
style={styles.targetButton}
|
|
disabled={!taskUiReady}
|
|
onPress={() => {
|
|
if (!taskUiReady) {
|
|
return
|
|
}
|
|
setShowCreateTargetPicker(true)
|
|
}}
|
|
>
|
|
{provider === 'github' || provider === 'gitlab' ? (
|
|
<View
|
|
style={[
|
|
styles.pickerRepoDot,
|
|
selectedCreateTarget
|
|
? {
|
|
backgroundColor: getRepoBadgeColor(
|
|
selectedCreateTarget as RepoSummary,
|
|
(selectedCreateTarget as RepoSummary).displayName
|
|
)
|
|
}
|
|
: undefined
|
|
]}
|
|
/>
|
|
) : null}
|
|
<Text style={styles.targetButtonText} numberOfLines={1}>
|
|
{selectedCreateTargetLabel}
|
|
</Text>
|
|
<ChevronDown size={14} color={colors.textMuted} />
|
|
</Pressable>
|
|
|
|
{provider === 'github' &&
|
|
selectedCreateRepo &&
|
|
hasGitHubIssueSourceChoice(selectedCreateGitHubSources) ? (
|
|
<View style={styles.issueSourceBox}>
|
|
<Text style={styles.fieldLabel}>Issue source</Text>
|
|
<Text style={styles.issueSourceHint} numberOfLines={2}>
|
|
File in{' '}
|
|
{selectedCreateIssuePreference === 'origin'
|
|
? issueSourceSlug(selectedCreateGitHubSources?.prs)
|
|
: issueSourceSlug(selectedCreateGitHubSources?.upstreamCandidate)}
|
|
</Text>
|
|
<View style={styles.issueSourceSegment}>
|
|
{(['upstream', 'origin'] as const).map((preference) => {
|
|
const selected = selectedCreateIssuePreference === preference
|
|
const slug =
|
|
preference === 'upstream'
|
|
? issueSourceSlug(selectedCreateGitHubSources?.upstreamCandidate)
|
|
: issueSourceSlug(selectedCreateGitHubSources?.prs)
|
|
return (
|
|
<Pressable
|
|
key={preference}
|
|
style={[
|
|
styles.issueSourceSegmentButton,
|
|
selected && styles.issueSourceSegmentButtonActive
|
|
]}
|
|
accessibilityState={{ selected }}
|
|
onPress={() =>
|
|
void setGitHubIssueSourcePreference(selectedCreateRepo, preference)
|
|
}
|
|
>
|
|
<Text
|
|
style={[
|
|
styles.issueSourceSegmentText,
|
|
selected && styles.issueSourceSegmentTextActive
|
|
]}
|
|
>
|
|
{preference === 'upstream' ? 'Upstream' : 'Origin'}
|
|
</Text>
|
|
<Text style={styles.issueSourceSlug} numberOfLines={1}>
|
|
{slug}
|
|
</Text>
|
|
</Pressable>
|
|
)
|
|
})}
|
|
</View>
|
|
</View>
|
|
) : null}
|
|
|
|
<Text style={styles.fieldLabel}>Title</Text>
|
|
<TextInput
|
|
style={styles.input}
|
|
value={createTitle}
|
|
onChangeText={setCreateTitle}
|
|
placeholder="Task title"
|
|
placeholderTextColor={colors.textMuted}
|
|
autoCapitalize="sentences"
|
|
returnKeyType="next"
|
|
/>
|
|
|
|
<Text style={styles.fieldLabel}>Description</Text>
|
|
<TextInput
|
|
style={[styles.input, styles.bodyInput]}
|
|
value={createBody}
|
|
onChangeText={setCreateBody}
|
|
placeholder="Add context"
|
|
placeholderTextColor={colors.textMuted}
|
|
multiline
|
|
textAlignVertical="top"
|
|
/>
|
|
|
|
<Pressable
|
|
style={[
|
|
styles.createButton,
|
|
(!taskUiReady || !createTitle.trim() || creatingTask) && styles.createButtonDisabled
|
|
]}
|
|
disabled={!taskUiReady || !createTitle.trim() || creatingTask}
|
|
onPress={() => void createTask()}
|
|
>
|
|
{creatingTask ? (
|
|
<ActivityIndicator size="small" color={colors.bgBase} />
|
|
) : (
|
|
<Text style={styles.createButtonText}>Create Issue</Text>
|
|
)}
|
|
</Pressable>
|
|
</View>
|
|
</BottomDrawer>
|
|
|
|
<PickerModal
|
|
visible={taskUiReady && showCreateTask && showCreateTargetPicker}
|
|
title={provider === 'linear' ? 'Linear Team' : 'Repository'}
|
|
options={createTargetOptions}
|
|
selected={
|
|
provider === 'github' || provider === 'gitlab'
|
|
? ((selectedCreateTarget as RepoSummary | null)?.id ?? '')
|
|
: ((selectedCreateTarget as LinearTeam | null)?.id ?? '')
|
|
}
|
|
onSelect={(value) => {
|
|
if (provider === 'github' || provider === 'gitlab') {
|
|
setCreateRepoId(value)
|
|
} else {
|
|
setCreateTeamId(value)
|
|
}
|
|
}}
|
|
onClose={() => setShowCreateTargetPicker(false)}
|
|
/>
|
|
|
|
<BottomDrawer
|
|
visible={taskUiReady && showLinearConnect}
|
|
onClose={() => {
|
|
if (linearConnectState !== 'connecting') {
|
|
setShowLinearConnect(false)
|
|
}
|
|
}}
|
|
>
|
|
<View style={styles.sheetHeader}>
|
|
<View style={styles.sheetTitleRow}>
|
|
<TaskProviderLogo provider="linear" size={16} color={colors.textPrimary} />
|
|
<Text style={styles.sheetTitle}>Connect Linear workspace</Text>
|
|
</View>
|
|
<Text style={styles.sheetSubtitle}>
|
|
Paste a Personal API key to browse issues from that workspace.
|
|
</Text>
|
|
</View>
|
|
<View style={styles.createForm}>
|
|
<Text style={styles.fieldLabel}>Personal API key</Text>
|
|
<TextInput
|
|
style={styles.input}
|
|
value={linearApiKeyDraft}
|
|
onChangeText={(next) => {
|
|
setLinearApiKeyDraft(next)
|
|
if (linearConnectState === 'error') {
|
|
setLinearConnectState('idle')
|
|
setLinearConnectError('')
|
|
}
|
|
}}
|
|
placeholder="lin_api_..."
|
|
placeholderTextColor={colors.textMuted}
|
|
autoCapitalize="none"
|
|
autoCorrect={false}
|
|
secureTextEntry
|
|
editable={linearConnectState !== 'connecting'}
|
|
onSubmitEditing={() => void connectLinearAccount()}
|
|
/>
|
|
{linearConnectState === 'error' && linearConnectError ? (
|
|
<Text style={styles.detailError}>{linearConnectError}</Text>
|
|
) : null}
|
|
<Pressable
|
|
style={styles.inlineTextLink}
|
|
onPress={() => void Linking.openURL('https://linear.app/settings/account/security')}
|
|
>
|
|
<ExternalLink size={13} color={colors.textSecondary} />
|
|
<Text style={styles.inlineTextLinkText}>Linear Settings / Security / New API key</Text>
|
|
</Pressable>
|
|
<View style={styles.securityHintRow}>
|
|
<Lock size={13} color={colors.textMuted} />
|
|
<Text style={styles.securityHintText}>
|
|
Your key is encrypted via the host OS keychain and stored locally.
|
|
</Text>
|
|
</View>
|
|
<Pressable
|
|
style={[
|
|
styles.createButton,
|
|
(!linearApiKeyDraft.trim() || linearConnectState === 'connecting') &&
|
|
styles.createButtonDisabled
|
|
]}
|
|
disabled={!linearApiKeyDraft.trim() || linearConnectState === 'connecting'}
|
|
onPress={() => void connectLinearAccount()}
|
|
>
|
|
{linearConnectState === 'connecting' ? (
|
|
<ActivityIndicator size="small" color={colors.bgBase} />
|
|
) : (
|
|
<Text style={styles.createButtonText}>Connect</Text>
|
|
)}
|
|
</Pressable>
|
|
</View>
|
|
</BottomDrawer>
|
|
|
|
<PickerModal
|
|
visible={taskUiReady && workspaceRepoPickerItem != null}
|
|
title="Create Workspace In"
|
|
options={workspaceRepoOptions}
|
|
selected={workspaceRepos[0]?.id ?? ''}
|
|
onSelect={(repoId) => {
|
|
if (workspaceRepoPickerItem) {
|
|
openWorkspaceCreate(workspaceRepoPickerItem, repoId)
|
|
}
|
|
setWorkspaceRepoPickerItem(null)
|
|
}}
|
|
onClose={() => setWorkspaceRepoPickerItem(null)}
|
|
zIndex={TASK_SECONDARY_DRAWER_Z_INDEX}
|
|
/>
|
|
|
|
<BottomDrawer
|
|
visible={taskUiReady && workspaceCreateDraft != null}
|
|
onClose={() => setWorkspaceCreateDraft(null)}
|
|
zIndex={TASK_SECONDARY_DRAWER_Z_INDEX}
|
|
>
|
|
{workspaceCreateDraft ? (
|
|
<View>
|
|
<View style={styles.sheetHeader}>
|
|
<Text style={styles.sheetTitle}>Create Workspace</Text>
|
|
<Text style={styles.sheetSubtitle} numberOfLines={2}>
|
|
{workspaceCreateDraft.item.title}
|
|
</Text>
|
|
</View>
|
|
|
|
<View style={styles.workspaceCreateForm}>
|
|
<View style={styles.workspaceCreateField}>
|
|
<Text style={styles.workspaceCreateLabel}>Repository</Text>
|
|
<Pressable
|
|
style={styles.fieldButton}
|
|
disabled={!workspaceCreateCanPickRepo}
|
|
onPress={() => setShowWorkspaceCreateRepoPicker(true)}
|
|
>
|
|
{workspaceCreateTargetRepo ? (
|
|
<View
|
|
style={[
|
|
styles.pickerRepoDot,
|
|
{
|
|
backgroundColor: getRepoBadgeColor(
|
|
workspaceCreateTargetRepo,
|
|
workspaceCreateTargetRepo.displayName
|
|
)
|
|
}
|
|
]}
|
|
/>
|
|
) : null}
|
|
<Text
|
|
style={[
|
|
styles.fieldButtonText,
|
|
!workspaceCreateTargetRepo ? styles.fieldButtonPlaceholder : undefined
|
|
]}
|
|
numberOfLines={1}
|
|
>
|
|
{workspaceCreateTargetRepo?.displayName ?? 'Select a repository'}
|
|
</Text>
|
|
{workspaceCreateCanPickRepo ? (
|
|
<ChevronDown size={14} color={colors.textMuted} />
|
|
) : null}
|
|
</Pressable>
|
|
</View>
|
|
|
|
{workspaceCreateTargetConnectionId ? (
|
|
<View style={styles.workspaceCreateField}>
|
|
<Text style={styles.workspaceCreateLabel}>SSH Connection</Text>
|
|
<View style={styles.sshConnectCard}>
|
|
<View style={styles.sshStatusRow}>
|
|
<View
|
|
style={[
|
|
styles.sshStatusDot,
|
|
workspaceCreateSshStatus === 'connected'
|
|
? styles.sshStatusDotConnected
|
|
: workspaceCreateSshConnectInProgress
|
|
? styles.sshStatusDotProgress
|
|
: styles.sshStatusDotDisconnected
|
|
]}
|
|
/>
|
|
<View style={styles.sshStatusCopy}>
|
|
<Text style={styles.sshStatusTitle} numberOfLines={1}>
|
|
{workspaceCreateTargetRepo?.displayName ?? 'Remote repository'}
|
|
</Text>
|
|
<Text style={styles.detailMuted}>
|
|
{workspaceSshStatusLabel(workspaceCreateSshStatus)}
|
|
</Text>
|
|
</View>
|
|
{workspaceCreateSshStatus === 'connected' ? null : (
|
|
<Pressable
|
|
style={[
|
|
styles.inlineSaveButtonCompact,
|
|
workspaceCreateSshConnectInProgress
|
|
? styles.fieldButtonDisabled
|
|
: undefined
|
|
]}
|
|
disabled={workspaceCreateSshConnectInProgress}
|
|
onPress={() => void connectWorkspaceSshRepo()}
|
|
>
|
|
<Text style={styles.inlineSaveText}>
|
|
{workspaceCreateSshConnectInProgress ? 'Connecting...' : 'Connect'}
|
|
</Text>
|
|
</Pressable>
|
|
)}
|
|
</View>
|
|
{workspaceCreateSshError ? (
|
|
<Text style={styles.detailError}>{workspaceCreateSshError}</Text>
|
|
) : null}
|
|
</View>
|
|
</View>
|
|
) : null}
|
|
|
|
<View style={styles.workspaceCreateField}>
|
|
<Text style={styles.workspaceCreateLabel}>
|
|
Workspace Name <Text style={styles.workspaceCreateLabelHint}>[Optional]</Text>
|
|
</Text>
|
|
<MobileWorkspaceNameInput
|
|
style={styles.input}
|
|
value={workspaceNameDraft}
|
|
onChangeText={handleWorkspaceNameDraftChange}
|
|
placeholderTextColor={colors.textMuted}
|
|
shouldAutoFocus={taskUiReady && workspaceCreateDraft !== null}
|
|
/>
|
|
</View>
|
|
|
|
<View style={styles.workspaceCreateField}>
|
|
<Text style={styles.workspaceCreateLabel}>Agent</Text>
|
|
<Pressable
|
|
style={[
|
|
styles.fieldButton,
|
|
workspaceCreateRequiresSshConnection ? styles.fieldButtonDisabled : undefined
|
|
]}
|
|
disabled={workspaceCreateRequiresSshConnection}
|
|
onPress={() => setShowWorkspaceAgentPicker(true)}
|
|
>
|
|
<MobileAgentIcon
|
|
agentId={workspaceAgentIconId(resolvedWorkspaceAgent)}
|
|
size={16}
|
|
/>
|
|
<Text style={styles.fieldButtonText} numberOfLines={1}>
|
|
{workspaceCreateRequiresSshConnection
|
|
? 'Connect repository first'
|
|
: workspaceAgentDetectionPending
|
|
? 'Detecting agents...'
|
|
: workspaceAgentLabel(resolvedWorkspaceAgent)}
|
|
</Text>
|
|
<ChevronDown size={14} color={colors.textMuted} />
|
|
</Pressable>
|
|
</View>
|
|
|
|
<Pressable
|
|
style={styles.workspaceAdvancedToggle}
|
|
onPress={() => setShowWorkspaceAdvanced((current) => !current)}
|
|
>
|
|
<Text style={styles.workspaceAdvancedText}>Advanced</Text>
|
|
{showWorkspaceAdvanced ? (
|
|
<ChevronUp size={14} color={colors.textSecondary} />
|
|
) : (
|
|
<ChevronDown size={14} color={colors.textSecondary} />
|
|
)}
|
|
</Pressable>
|
|
|
|
{showWorkspaceAdvanced ? (
|
|
<View style={styles.workspaceCreateField}>
|
|
<Text style={styles.workspaceCreateLabel}>Start from</Text>
|
|
<Pressable
|
|
style={styles.fieldButton}
|
|
onPress={() => {
|
|
setWorkspaceBaseBranchQuery(workspaceBaseBranch?.refName ?? '')
|
|
setShowWorkspaceBaseBranchPicker(true)
|
|
}}
|
|
>
|
|
<GitBranch size={14} color={colors.textMuted} />
|
|
<Text style={styles.fieldButtonText} numberOfLines={1}>
|
|
{workspaceBaseBranch?.refName ?? 'Default branch'}
|
|
</Text>
|
|
<ChevronDown size={14} color={colors.textMuted} />
|
|
</Pressable>
|
|
{workspaceBaseBranch ? (
|
|
<Text style={styles.detailMuted} numberOfLines={1}>
|
|
Create from {workspaceBaseBranch.refName}
|
|
</Text>
|
|
) : null}
|
|
</View>
|
|
) : null}
|
|
</View>
|
|
|
|
{workspaceCreateTargetRepo ? null : (
|
|
<Text style={styles.detailError}>
|
|
{workspaceCreateDraft.item.provider === 'linear'
|
|
? 'Add a Git repository before creating a Linear workspace.'
|
|
: 'Repository not found.'}
|
|
</Text>
|
|
)}
|
|
|
|
<View style={styles.workspaceCreateActions}>
|
|
<Pressable
|
|
style={[
|
|
styles.createButton,
|
|
styles.workspaceCreateButton,
|
|
(!workspaceCreateTargetRepo ||
|
|
workspaceCreateRequiresSshConnection ||
|
|
workspaceAgentDetectionPending ||
|
|
creatingKey === workspaceCreateDraft.item.key) &&
|
|
styles.createButtonDisabled
|
|
]}
|
|
disabled={
|
|
!workspaceCreateTargetRepo ||
|
|
workspaceCreateRequiresSshConnection ||
|
|
workspaceAgentDetectionPending ||
|
|
creatingKey === workspaceCreateDraft.item.key
|
|
}
|
|
onPress={() => {
|
|
// Why: this compact issue-to-workspace flow should match the
|
|
// basic create workspace path; sparse checkout can return later.
|
|
void createWorkspace(
|
|
workspaceCreateDraft.item,
|
|
workspaceCreateDraft.repoIdOverride,
|
|
undefined,
|
|
resolvedWorkspaceAgent,
|
|
workspaceNameDraft.trim(),
|
|
undefined,
|
|
workspaceBaseBranch?.refName,
|
|
workspaceBranchNameOverride &&
|
|
workspaceNameDraft.trim() === workspaceBranchAutoName
|
|
? workspaceBranchNameOverride
|
|
: undefined,
|
|
undefined
|
|
)
|
|
}}
|
|
>
|
|
{creatingKey === workspaceCreateDraft.item.key ? (
|
|
<ActivityIndicator size="small" color={colors.bgBase} />
|
|
) : (
|
|
<Text style={styles.createButtonText}>
|
|
{workspaceAgentDetectionPending
|
|
? 'Detecting agents...'
|
|
: workspaceCreateRequiresSshConnection
|
|
? 'Connect Repository'
|
|
: 'Create Workspace'}
|
|
</Text>
|
|
)}
|
|
</Pressable>
|
|
</View>
|
|
</View>
|
|
) : null}
|
|
</BottomDrawer>
|
|
|
|
<PickerModal
|
|
visible={taskUiReady && workspaceCreateDraft != null && showWorkspaceCreateRepoPicker}
|
|
title="Repository"
|
|
options={workspaceRepoOptions}
|
|
selected={workspaceCreateTargetRepo?.id ?? ''}
|
|
onSelect={(repoId) => {
|
|
setWorkspaceCreateDraft((current) =>
|
|
current ? { ...current, repoIdOverride: repoId } : current
|
|
)
|
|
setShowWorkspaceCreateRepoPicker(false)
|
|
}}
|
|
onClose={() => setShowWorkspaceCreateRepoPicker(false)}
|
|
zIndex={TASK_SECONDARY_DRAWER_Z_INDEX + 1}
|
|
/>
|
|
|
|
<PickerModal
|
|
visible={taskUiReady && workspaceCreateDraft != null && showWorkspaceAgentPicker}
|
|
title="Agent"
|
|
options={workspaceAgentOptions}
|
|
selected={resolvedWorkspaceAgent}
|
|
onSelect={(agent) => {
|
|
setWorkspaceAgentOverridden(true)
|
|
setWorkspaceAgent(agent)
|
|
setShowWorkspaceAgentPicker(false)
|
|
}}
|
|
onClose={() => setShowWorkspaceAgentPicker(false)}
|
|
zIndex={TASK_SECONDARY_DRAWER_Z_INDEX + 1}
|
|
/>
|
|
|
|
<BottomDrawer
|
|
visible={taskUiReady && workspaceCreateDraft != null && showWorkspaceBaseBranchPicker}
|
|
onClose={() => setShowWorkspaceBaseBranchPicker(false)}
|
|
zIndex={TASK_SECONDARY_DRAWER_Z_INDEX + 1}
|
|
>
|
|
<View>
|
|
<View style={styles.sheetHeader}>
|
|
<Text style={styles.sheetTitle}>Start From</Text>
|
|
<Text style={styles.sheetSubtitle}>Pick an existing branch or ref.</Text>
|
|
</View>
|
|
<View style={styles.detailGroup}>
|
|
<TextInput
|
|
style={styles.input}
|
|
value={workspaceBaseBranchQuery}
|
|
onChangeText={setWorkspaceBaseBranchQuery}
|
|
placeholder="Search branches"
|
|
placeholderTextColor={colors.textMuted}
|
|
autoCapitalize="none"
|
|
autoCorrect={false}
|
|
/>
|
|
<Pressable
|
|
style={styles.pickerRow}
|
|
onPress={() => {
|
|
clearWorkspaceBaseBranch()
|
|
}}
|
|
>
|
|
<View style={styles.pickerCheck}>
|
|
{workspaceBaseBranch === null ? (
|
|
<Check size={16} color={colors.textPrimary} />
|
|
) : null}
|
|
</View>
|
|
<View style={styles.pickerContent}>
|
|
<Text style={styles.pickerLabel}>Default branch</Text>
|
|
<Text style={styles.pickerSubtitle}>Use this repository's configured base</Text>
|
|
</View>
|
|
</Pressable>
|
|
{workspaceBaseBranchLoading ? (
|
|
<View style={styles.drawerLoadingRow}>
|
|
<ActivityIndicator size="small" color={colors.textSecondary} />
|
|
</View>
|
|
) : workspaceBaseBranchError ? (
|
|
<Text style={styles.detailError}>{workspaceBaseBranchError}</Text>
|
|
) : workspaceBaseBranchQuery.trim() && workspaceBaseBranchResults.length === 0 ? (
|
|
<Text style={styles.detailMuted}>No branches match.</Text>
|
|
) : null}
|
|
{workspaceBaseBranchResults.map((branch) => (
|
|
<View key={`${branch.refName}:${branch.localBranchName}`}>
|
|
<View style={styles.groupSeparator} />
|
|
<Pressable
|
|
style={styles.pickerRow}
|
|
onPress={() => {
|
|
selectWorkspaceBaseBranch(branch)
|
|
}}
|
|
>
|
|
<View style={styles.pickerCheck}>
|
|
{workspaceBaseBranch?.refName === branch.refName ? (
|
|
<Check size={16} color={colors.textPrimary} />
|
|
) : null}
|
|
</View>
|
|
<View style={styles.pickerContent}>
|
|
<Text style={[styles.pickerLabel, styles.monoText]} numberOfLines={1}>
|
|
{branch.refName}
|
|
</Text>
|
|
{branch.localBranchName !== branch.refName ? (
|
|
<Text style={styles.pickerSubtitle} numberOfLines={1}>
|
|
Branch name: {branch.localBranchName}
|
|
</Text>
|
|
) : null}
|
|
</View>
|
|
</Pressable>
|
|
</View>
|
|
))}
|
|
</View>
|
|
</View>
|
|
</BottomDrawer>
|
|
|
|
<BottomDrawer
|
|
visible={taskUiReady && workspaceCreateDraft != null && showWorkspaceSparsePicker}
|
|
onClose={() => setShowWorkspaceSparsePicker(false)}
|
|
zIndex={TASK_SECONDARY_DRAWER_Z_INDEX + 1}
|
|
>
|
|
<View>
|
|
<View style={styles.sheetHeader}>
|
|
<Text style={styles.sheetTitle}>Sparse Checkout</Text>
|
|
</View>
|
|
<View style={styles.detailGroup}>
|
|
<Pressable
|
|
style={styles.pickerRow}
|
|
onPress={() => {
|
|
setWorkspaceSparsePresetId(null)
|
|
setShowWorkspaceSparsePicker(false)
|
|
}}
|
|
>
|
|
<View style={styles.pickerCheck}>
|
|
{workspaceSparsePresetId === null ? (
|
|
<Check size={16} color={colors.textPrimary} />
|
|
) : null}
|
|
</View>
|
|
<View style={styles.pickerContent}>
|
|
<Text style={styles.pickerLabel}>Full checkout</Text>
|
|
<Text style={styles.pickerSubtitle}>Use the whole repository</Text>
|
|
</View>
|
|
</Pressable>
|
|
{workspaceSparsePresets.map((preset) => (
|
|
<View key={preset.id}>
|
|
<View style={styles.groupSeparator} />
|
|
<View style={styles.pickerRowWithAction}>
|
|
<Pressable
|
|
style={styles.pickerRowMain}
|
|
onPress={() => {
|
|
setWorkspaceSparsePresetId(preset.id)
|
|
setShowWorkspaceSparsePicker(false)
|
|
}}
|
|
>
|
|
<View style={styles.pickerCheck}>
|
|
{workspaceSparsePresetId === preset.id ? (
|
|
<Check size={16} color={colors.textPrimary} />
|
|
) : null}
|
|
</View>
|
|
<View style={styles.pickerContent}>
|
|
<Text style={styles.pickerLabel} numberOfLines={1}>
|
|
{preset.name}
|
|
</Text>
|
|
<Text style={styles.pickerSubtitle} numberOfLines={2}>
|
|
{preset.directories.join(', ')}
|
|
</Text>
|
|
</View>
|
|
</Pressable>
|
|
<Pressable
|
|
style={styles.iconActionButton}
|
|
accessibilityRole="button"
|
|
accessibilityLabel={`Edit ${preset.name}`}
|
|
onPress={() => startEditWorkspaceSparsePreset(preset)}
|
|
>
|
|
<Pencil size={15} color={colors.textMuted} />
|
|
</Pressable>
|
|
</View>
|
|
</View>
|
|
))}
|
|
</View>
|
|
<Pressable
|
|
style={[
|
|
styles.inlineSaveButton,
|
|
!workspaceSparsePresetsLoaded || workspaceSparsePresetsLoading
|
|
? styles.fieldButtonDisabled
|
|
: undefined
|
|
]}
|
|
disabled={!workspaceSparsePresetsLoaded || workspaceSparsePresetsLoading}
|
|
onPress={startNewWorkspaceSparsePreset}
|
|
>
|
|
<Text style={styles.inlineSaveText}>New preset</Text>
|
|
</Pressable>
|
|
</View>
|
|
</BottomDrawer>
|
|
|
|
<BottomDrawer
|
|
visible={taskUiReady && workspaceCreateDraft != null && workspaceSparseDraft != null}
|
|
onClose={() => {
|
|
if (!workspaceSparseSaving) {
|
|
setWorkspaceSparseDraft(null)
|
|
}
|
|
}}
|
|
zIndex={TASK_SECONDARY_DRAWER_Z_INDEX + 2}
|
|
>
|
|
{workspaceSparseDraft ? (
|
|
<View>
|
|
<View style={styles.sheetHeader}>
|
|
<Text style={styles.sheetTitle}>
|
|
{workspaceSparseDraft.mode === 'new' ? 'New Sparse Preset' : 'Edit Sparse Preset'}
|
|
</Text>
|
|
</View>
|
|
<View style={styles.detailGroup}>
|
|
<View style={styles.detailSection}>
|
|
<Text style={styles.detailSectionTitle}>Name</Text>
|
|
<TextInput
|
|
style={styles.input}
|
|
value={workspaceSparseDraft.name}
|
|
onChangeText={(name) =>
|
|
setWorkspaceSparseDraft({ ...workspaceSparseDraft, name })
|
|
}
|
|
placeholder="Renderer UI"
|
|
placeholderTextColor={colors.textMuted}
|
|
autoCapitalize="none"
|
|
autoCorrect={false}
|
|
maxLength={80}
|
|
/>
|
|
</View>
|
|
<View style={styles.detailSection}>
|
|
<Text style={styles.detailSectionTitle}>Directories</Text>
|
|
<TextInput
|
|
style={[styles.input, styles.bodyInput, styles.monoInput]}
|
|
value={workspaceSparseDraft.directoriesText}
|
|
onChangeText={(directoriesText) =>
|
|
setWorkspaceSparseDraft({ ...workspaceSparseDraft, directoriesText })
|
|
}
|
|
placeholder={'src/renderer\npackages/ui'}
|
|
placeholderTextColor={colors.textMuted}
|
|
autoCapitalize="none"
|
|
autoCorrect={false}
|
|
multiline
|
|
textAlignVertical="top"
|
|
/>
|
|
</View>
|
|
<Text style={workspaceSparseDraftError ? styles.detailError : styles.detailMuted}>
|
|
{workspaceSparseDraftError ??
|
|
(workspaceSparseDraftParsed?.directories.length === 1
|
|
? '1 directory'
|
|
: `${workspaceSparseDraftParsed?.directories.length ?? 0} directories`)}
|
|
</Text>
|
|
</View>
|
|
<View style={styles.drawerActionRow}>
|
|
<Pressable
|
|
style={styles.secondaryActionButton}
|
|
disabled={workspaceSparseSaving}
|
|
onPress={() => setWorkspaceSparseDraft(null)}
|
|
>
|
|
<Text style={styles.secondaryActionText}>Cancel</Text>
|
|
</Pressable>
|
|
<Pressable
|
|
style={[
|
|
styles.primaryActionButton,
|
|
!canSaveWorkspaceSparseDraft ? styles.fieldButtonDisabled : undefined
|
|
]}
|
|
disabled={!canSaveWorkspaceSparseDraft}
|
|
onPress={() => void saveWorkspaceSparsePreset()}
|
|
>
|
|
{workspaceSparseSaving ? (
|
|
<ActivityIndicator size="small" color={colors.bgBase} />
|
|
) : null}
|
|
<Text style={styles.primaryActionText}>Save</Text>
|
|
</Pressable>
|
|
</View>
|
|
</View>
|
|
) : null}
|
|
</BottomDrawer>
|
|
|
|
<BottomDrawer
|
|
visible={taskUiReady && setupPrompt != null}
|
|
onClose={() => setSetupPrompt(null)}
|
|
zIndex={TASK_SECONDARY_DRAWER_Z_INDEX + 1}
|
|
>
|
|
{setupPrompt ? (
|
|
<View>
|
|
<View style={styles.sheetHeader}>
|
|
<Text style={styles.sheetTitle}>Run Setup Script?</Text>
|
|
<Text style={styles.sheetSubtitle}>
|
|
{setupPrompt.repoName} requires a setup choice before creating this workspace.
|
|
</Text>
|
|
</View>
|
|
|
|
<View style={styles.setupPromptBox}>
|
|
<View style={styles.detailSectionHeader}>
|
|
<Text style={styles.detailSectionTitle}>
|
|
{setupSourceLabel(setupPrompt.source)}
|
|
</Text>
|
|
</View>
|
|
<Text style={styles.setupPromptCommand}>{setupPrompt.command}</Text>
|
|
</View>
|
|
|
|
<View style={styles.actionGroup}>
|
|
<Pressable
|
|
style={styles.actionRow}
|
|
disabled={creatingKey === setupPrompt.item.key}
|
|
onPress={() =>
|
|
void createWorkspace(
|
|
setupPrompt.item,
|
|
setupPrompt.repoIdOverride,
|
|
'run',
|
|
setupPrompt.agentOverride,
|
|
setupPrompt.workspaceNameOverride,
|
|
setupPrompt.noteOverride,
|
|
setupPrompt.baseBranchOverride,
|
|
setupPrompt.branchNameOverride,
|
|
setupPrompt.sparseCheckoutOverride
|
|
)
|
|
}
|
|
>
|
|
<Check size={16} color={colors.textPrimary} />
|
|
<Text style={styles.actionText}>
|
|
{creatingKey === setupPrompt.item.key ? 'Creating...' : 'Run setup and create'}
|
|
</Text>
|
|
</Pressable>
|
|
<View style={styles.actionSeparator} />
|
|
<Pressable
|
|
style={styles.actionRow}
|
|
disabled={creatingKey === setupPrompt.item.key}
|
|
onPress={() =>
|
|
void createWorkspace(
|
|
setupPrompt.item,
|
|
setupPrompt.repoIdOverride,
|
|
'skip',
|
|
setupPrompt.agentOverride,
|
|
setupPrompt.workspaceNameOverride,
|
|
setupPrompt.noteOverride,
|
|
setupPrompt.baseBranchOverride,
|
|
setupPrompt.branchNameOverride,
|
|
setupPrompt.sparseCheckoutOverride
|
|
)
|
|
}
|
|
>
|
|
<X size={16} color={colors.textPrimary} />
|
|
<Text style={styles.actionText}>Skip setup and create</Text>
|
|
</Pressable>
|
|
</View>
|
|
</View>
|
|
) : null}
|
|
</BottomDrawer>
|
|
|
|
<BottomDrawer
|
|
visible={taskUiReady && orcaYamlTrustPrompt != null}
|
|
onClose={() => setOrcaYamlTrustPrompt(null)}
|
|
zIndex={TASK_SECONDARY_DRAWER_Z_INDEX + 1}
|
|
>
|
|
{orcaYamlTrustPrompt ? (
|
|
<View>
|
|
<View style={styles.sheetHeader}>
|
|
<Text style={styles.sheetTitle}>
|
|
{orcaYamlTrustPrompt.previouslyApproved
|
|
? `${orcaYamlTrustPrompt.repoName}'s setup script changed`
|
|
: `Run setup from ${orcaYamlTrustPrompt.repoName}?`}
|
|
</Text>
|
|
<Text style={styles.sheetSubtitle}>
|
|
This repository's orca.yaml runs on your machine before the workspace starts. Only
|
|
run it if you trust this repository.
|
|
</Text>
|
|
</View>
|
|
|
|
<View style={styles.setupPromptBox}>
|
|
<View style={styles.detailSectionHeader}>
|
|
<Text style={styles.detailSectionTitle}>
|
|
{orcaYamlTrustPrompt.previouslyApproved ? 'New setup script' : 'Setup script'}
|
|
</Text>
|
|
</View>
|
|
<Text style={styles.setupPromptCommand}>{orcaYamlTrustPrompt.scriptContent}</Text>
|
|
</View>
|
|
|
|
<View style={styles.actionGroup}>
|
|
<Pressable
|
|
style={styles.actionRow}
|
|
disabled={creatingKey === orcaYamlTrustPrompt.item.key}
|
|
onPress={() =>
|
|
void (async () => {
|
|
try {
|
|
await persistSetupHookTrust(
|
|
orcaYamlTrustPrompt.repoId,
|
|
orcaYamlTrustPrompt.contentHash,
|
|
false
|
|
)
|
|
setOrcaYamlTrustPrompt(null)
|
|
await createWorkspace(
|
|
orcaYamlTrustPrompt.item,
|
|
orcaYamlTrustPrompt.repoIdOverride,
|
|
'run',
|
|
orcaYamlTrustPrompt.agentOverride,
|
|
orcaYamlTrustPrompt.workspaceNameOverride,
|
|
orcaYamlTrustPrompt.noteOverride,
|
|
orcaYamlTrustPrompt.baseBranchOverride,
|
|
orcaYamlTrustPrompt.branchNameOverride,
|
|
orcaYamlTrustPrompt.sparseCheckoutOverride,
|
|
orcaYamlTrustPrompt.contentHash
|
|
)
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Failed to trust setup script.')
|
|
}
|
|
})()
|
|
}
|
|
>
|
|
<Check size={16} color={colors.textPrimary} />
|
|
<Text style={styles.actionText}>Run hooks</Text>
|
|
</Pressable>
|
|
<View style={styles.actionSeparator} />
|
|
<Pressable
|
|
style={styles.actionRow}
|
|
disabled={creatingKey === orcaYamlTrustPrompt.item.key}
|
|
onPress={() =>
|
|
void (async () => {
|
|
try {
|
|
await persistSetupHookTrust(
|
|
orcaYamlTrustPrompt.repoId,
|
|
orcaYamlTrustPrompt.contentHash,
|
|
true
|
|
)
|
|
setOrcaYamlTrustPrompt(null)
|
|
await createWorkspace(
|
|
orcaYamlTrustPrompt.item,
|
|
orcaYamlTrustPrompt.repoIdOverride,
|
|
'run',
|
|
orcaYamlTrustPrompt.agentOverride,
|
|
orcaYamlTrustPrompt.workspaceNameOverride,
|
|
orcaYamlTrustPrompt.noteOverride,
|
|
orcaYamlTrustPrompt.baseBranchOverride,
|
|
orcaYamlTrustPrompt.branchNameOverride,
|
|
orcaYamlTrustPrompt.sparseCheckoutOverride,
|
|
orcaYamlTrustPrompt.contentHash
|
|
)
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Failed to trust setup script.')
|
|
}
|
|
})()
|
|
}
|
|
>
|
|
<Check size={16} color={colors.textPrimary} />
|
|
<Text style={styles.actionText}>Always trust and run</Text>
|
|
</Pressable>
|
|
<View style={styles.actionSeparator} />
|
|
<Pressable
|
|
style={styles.actionRow}
|
|
disabled={creatingKey === orcaYamlTrustPrompt.item.key}
|
|
onPress={() => {
|
|
const prompt = orcaYamlTrustPrompt
|
|
setOrcaYamlTrustPrompt(null)
|
|
void createWorkspace(
|
|
prompt.item,
|
|
prompt.repoIdOverride,
|
|
'skip',
|
|
prompt.agentOverride,
|
|
prompt.workspaceNameOverride,
|
|
prompt.noteOverride,
|
|
prompt.baseBranchOverride,
|
|
prompt.branchNameOverride,
|
|
prompt.sparseCheckoutOverride
|
|
)
|
|
}}
|
|
>
|
|
<X size={16} color={colors.textPrimary} />
|
|
<Text style={styles.actionText}>Don't run</Text>
|
|
</Pressable>
|
|
</View>
|
|
</View>
|
|
) : null}
|
|
</BottomDrawer>
|
|
|
|
<BottomDrawer
|
|
visible={taskUiReady && projectRepoNotInOrca != null}
|
|
onClose={() => {
|
|
setProjectRepoNotInOrca(null)
|
|
}}
|
|
>
|
|
{projectRepoNotInOrca ? (
|
|
<View>
|
|
<View style={styles.sheetHeader}>
|
|
<Text style={styles.sheetTitle}>Repository not in Orca</Text>
|
|
<Text style={styles.sheetSubtitle}>
|
|
{projectRepoNotInOrca.owner}/{projectRepoNotInOrca.repo} is not added to Orca. Add
|
|
this repository from the desktop app, then refresh mobile Tasks.
|
|
</Text>
|
|
</View>
|
|
|
|
<View style={styles.actionGroup}>
|
|
{projectRepoNotInOrca.url ? (
|
|
<Pressable
|
|
style={styles.actionRow}
|
|
onPress={() => {
|
|
if (projectRepoNotInOrca.url) {
|
|
void Linking.openURL(projectRepoNotInOrca.url)
|
|
}
|
|
}}
|
|
>
|
|
<ExternalLink size={16} color={colors.textPrimary} />
|
|
<Text style={styles.actionText}>Open in GitHub</Text>
|
|
</Pressable>
|
|
) : null}
|
|
{projectRepoNotInOrca.url ? <View style={styles.actionSeparator} /> : null}
|
|
<Pressable
|
|
style={styles.actionRow}
|
|
onPress={() =>
|
|
void copyTextToClipboard(
|
|
`project-repo:${projectRepoNotInOrca.owner}/${projectRepoNotInOrca.repo}`,
|
|
`${projectRepoNotInOrca.owner}/${projectRepoNotInOrca.repo}`
|
|
)
|
|
}
|
|
>
|
|
<Copy size={16} color={colors.textPrimary} />
|
|
<Text style={styles.actionText}>
|
|
{copiedLinkKey ===
|
|
`project-repo:${projectRepoNotInOrca.owner}/${projectRepoNotInOrca.repo}`
|
|
? 'Copied'
|
|
: 'Copy repository'}
|
|
</Text>
|
|
</Pressable>
|
|
</View>
|
|
</View>
|
|
) : null}
|
|
</BottomDrawer>
|
|
|
|
<BottomDrawer
|
|
visible={taskUiReady && projectRowItem != null}
|
|
onClose={() => setProjectRowItem(null)}
|
|
>
|
|
{projectRowItem ? (
|
|
<View>
|
|
<View style={styles.sheetHeader}>
|
|
<View style={styles.sheetTitleRow}>
|
|
<TaskProviderLogo provider="github" size={16} color={colors.textPrimary} />
|
|
<Text style={styles.sheetTitle} numberOfLines={2}>
|
|
{projectRowItem.content.title}
|
|
</Text>
|
|
</View>
|
|
<Text style={styles.sheetSubtitle}>
|
|
GitHub Project · {projectRowItem.content.repository ?? activeProjectLabel}
|
|
{projectRowItem.content.number ? ` #${projectRowItem.content.number}` : ''}
|
|
</Text>
|
|
</View>
|
|
|
|
<View style={styles.detailGroup}>
|
|
<View style={styles.detailMetaGrid}>
|
|
<View style={styles.detailMetaItem}>
|
|
<Text style={styles.detailMetaLabel}>Type</Text>
|
|
<Text style={styles.detailMetaValue}>
|
|
{projectRowItem.itemType === 'PULL_REQUEST'
|
|
? 'Pull request'
|
|
: projectRowItem.itemType === 'ISSUE'
|
|
? 'Issue'
|
|
: projectRowItem.itemType === 'DRAFT_ISSUE'
|
|
? 'Draft issue'
|
|
: 'Project item'}
|
|
</Text>
|
|
</View>
|
|
<View style={styles.detailMetaItem}>
|
|
<Text style={styles.detailMetaLabel}>Status</Text>
|
|
<Text style={styles.detailMetaValue}>
|
|
{projectRowStatusLabel(projectRowItem)}
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
{SHOW_MOBILE_DETAIL_LABEL_CHIPS &&
|
|
(projectRowDetail?.provider === 'github'
|
|
? projectRowDetail.labels
|
|
: projectRowItem.content.labels.map((label) => label.name)
|
|
).length > 0 ? (
|
|
<View style={styles.chipRow}>
|
|
{(projectRowDetail?.provider === 'github'
|
|
? projectRowDetail.labels
|
|
: projectRowItem.content.labels.map((label) => label.name)
|
|
)
|
|
.slice(0, 6)
|
|
.map((label) => (
|
|
<View key={label} style={styles.detailChip}>
|
|
<Text style={styles.detailChipText}>{label}</Text>
|
|
</View>
|
|
))}
|
|
</View>
|
|
) : null}
|
|
{SHOW_MOBILE_PROJECT_METADATA_EDITORS && projectRowItem.itemType === 'ISSUE' ? (
|
|
<View style={styles.detailSection}>
|
|
<View style={styles.detailSectionHeader}>
|
|
<Text style={styles.detailSectionTitle}>Issue type</Text>
|
|
<Text style={styles.detailSectionMeta}>
|
|
{projectRowItem.content.issueType?.name ?? 'No type'}
|
|
</Text>
|
|
</View>
|
|
{projectIssueTypesLoading ? (
|
|
<View style={styles.detailLoadingInline}>
|
|
<ActivityIndicator size="small" color={colors.textSecondary} />
|
|
<Text style={styles.detailMuted}>Loading issue types...</Text>
|
|
</View>
|
|
) : projectIssueTypesError ? (
|
|
<Text style={styles.detailError}>{projectIssueTypesError}</Text>
|
|
) : projectIssueTypes.length === 0 ? (
|
|
<Text style={styles.detailMuted}>
|
|
No issue types configured for this repository.
|
|
</Text>
|
|
) : (
|
|
<View style={styles.chipRow}>
|
|
{projectIssueTypes.map((issueType) => {
|
|
const selected = projectRowItem.content.issueType?.id === issueType.id
|
|
return (
|
|
<Pressable
|
|
key={issueType.id}
|
|
style={[
|
|
styles.detailChip,
|
|
selected ? styles.detailChipSelected : undefined
|
|
]}
|
|
disabled={projectMutating || selected}
|
|
onPress={() =>
|
|
void mutateProjectRowIssueType(projectRowItem, issueType)
|
|
}
|
|
>
|
|
<View style={styles.issueTypeChipContent}>
|
|
<View
|
|
style={[
|
|
styles.issueTypeDot,
|
|
{ backgroundColor: githubProjectOptionColor(issueType.color) }
|
|
]}
|
|
/>
|
|
<Text style={styles.detailChipText}>{issueType.name}</Text>
|
|
</View>
|
|
</Pressable>
|
|
)
|
|
})}
|
|
{projectRowItem.content.issueType ? (
|
|
<Pressable
|
|
style={styles.detailChip}
|
|
disabled={projectMutating}
|
|
onPress={() => void mutateProjectRowIssueType(projectRowItem, null)}
|
|
>
|
|
<Text style={styles.detailChipText}>Clear type</Text>
|
|
</Pressable>
|
|
) : null}
|
|
</View>
|
|
)}
|
|
</View>
|
|
) : null}
|
|
{SHOW_MOBILE_PROJECT_METADATA_EDITORS &&
|
|
editableProjectFields(githubProjectTable).length > 0 ? (
|
|
<View style={styles.detailSection}>
|
|
<Text style={styles.detailSectionTitle}>Project fields</Text>
|
|
{editableProjectFields(githubProjectTable).map((field) => {
|
|
const currentLabel = projectFieldValueLabel(projectRowItem, field)
|
|
const draftValue = projectFieldDrafts[field.id] ?? ''
|
|
const saveTextField = (): void => {
|
|
if (field.dataType === 'NUMBER') {
|
|
const number = Number(draftValue)
|
|
if (!Number.isFinite(number)) {
|
|
setProjectRowDetailError('Enter a valid number.')
|
|
return
|
|
}
|
|
void mutateProjectRowField(projectRowItem, field, {
|
|
kind: 'number',
|
|
number
|
|
})
|
|
return
|
|
}
|
|
if (field.dataType === 'DATE') {
|
|
const date = draftValue.trim()
|
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
|
|
setProjectRowDetailError('Enter a date as YYYY-MM-DD.')
|
|
return
|
|
}
|
|
void mutateProjectRowField(projectRowItem, field, { kind: 'date', date })
|
|
return
|
|
}
|
|
void mutateProjectRowField(projectRowItem, field, {
|
|
kind: 'text',
|
|
text: draftValue
|
|
})
|
|
}
|
|
return (
|
|
<View key={field.id} style={styles.projectFieldCard}>
|
|
<View style={styles.detailSectionHeader}>
|
|
<Text style={styles.projectFieldName}>{field.name}</Text>
|
|
<Text style={styles.projectFieldValue} numberOfLines={1}>
|
|
{currentLabel}
|
|
</Text>
|
|
</View>
|
|
{field.dataType === 'SINGLE_SELECT' && field.kind === 'single-select' ? (
|
|
<View style={styles.chipRow}>
|
|
{field.options.map((option) => {
|
|
const fieldValue = projectRowItem.fieldValuesByFieldId?.[field.id]
|
|
const selected =
|
|
fieldValue?.kind === 'single-select' &&
|
|
fieldValue.optionId === option.id
|
|
return (
|
|
<Pressable
|
|
key={option.id}
|
|
style={[
|
|
styles.detailChip,
|
|
selected ? styles.detailChipSelected : undefined
|
|
]}
|
|
disabled={projectMutating}
|
|
accessibilityState={{ selected }}
|
|
onPress={() =>
|
|
void mutateProjectRowField(projectRowItem, field, {
|
|
kind: 'single-select',
|
|
optionId: option.id
|
|
})
|
|
}
|
|
>
|
|
<View style={styles.issueTypeChipContent}>
|
|
{selected ? (
|
|
<Check size={12} color={colors.accentBlue} />
|
|
) : null}
|
|
<Text style={styles.detailChipText}>{option.name}</Text>
|
|
</View>
|
|
</Pressable>
|
|
)
|
|
})}
|
|
</View>
|
|
) : field.dataType === 'ITERATION' && field.kind === 'iteration' ? (
|
|
<View style={styles.projectIterationList}>
|
|
{field.iterations.length === 0 ? (
|
|
<Text style={styles.detailMuted}>No iterations available.</Text>
|
|
) : (
|
|
field.iterations.map((iteration) => {
|
|
const fieldValue = projectRowItem.fieldValuesByFieldId?.[field.id]
|
|
const selected =
|
|
fieldValue?.kind === 'iteration' &&
|
|
fieldValue.iterationId === iteration.id
|
|
return (
|
|
<Pressable
|
|
key={iteration.id}
|
|
style={styles.actionRow}
|
|
disabled={projectMutating}
|
|
accessibilityState={{ selected }}
|
|
onPress={() =>
|
|
void mutateProjectRowField(projectRowItem, field, {
|
|
kind: 'iteration',
|
|
iterationId: iteration.id
|
|
})
|
|
}
|
|
>
|
|
<View style={styles.projectIterationCopy}>
|
|
<Text style={styles.actionText}>{iteration.title}</Text>
|
|
<Text style={styles.detailMuted}>
|
|
{iteration.completed ? 'Completed' : 'Current & upcoming'} ·{' '}
|
|
{iteration.startDate} · {iteration.duration}d
|
|
</Text>
|
|
</View>
|
|
{selected ? (
|
|
<Check size={14} color={colors.textSecondary} />
|
|
) : null}
|
|
</Pressable>
|
|
)
|
|
})
|
|
)}
|
|
</View>
|
|
) : (
|
|
<>
|
|
<TextInput
|
|
style={styles.input}
|
|
value={draftValue}
|
|
onChangeText={(next) =>
|
|
setProjectFieldDrafts((current) => ({
|
|
...current,
|
|
[field.id]: next
|
|
}))
|
|
}
|
|
placeholder={
|
|
field.dataType === 'DATE'
|
|
? 'YYYY-MM-DD'
|
|
: field.dataType === 'NUMBER'
|
|
? 'Number'
|
|
: 'Text'
|
|
}
|
|
placeholderTextColor={colors.textMuted}
|
|
keyboardType={field.dataType === 'NUMBER' ? 'numeric' : 'default'}
|
|
autoCapitalize="none"
|
|
/>
|
|
<Pressable
|
|
style={styles.inlineSaveButton}
|
|
disabled={projectMutating}
|
|
onPress={saveTextField}
|
|
>
|
|
<Text style={styles.inlineSaveText}>Save field</Text>
|
|
</Pressable>
|
|
</>
|
|
)}
|
|
<Pressable
|
|
style={styles.inlineSaveButton}
|
|
disabled={projectMutating || currentLabel === 'Empty'}
|
|
onPress={() => void mutateProjectRowField(projectRowItem, field, null)}
|
|
>
|
|
<Text style={styles.inlineSaveText}>Clear field</Text>
|
|
</Pressable>
|
|
</View>
|
|
)
|
|
})}
|
|
</View>
|
|
) : null}
|
|
{SHOW_MOBILE_PROJECT_METADATA_EDITORS && projectRowType(projectRowItem) ? (
|
|
<View style={styles.detailSection}>
|
|
<View style={styles.detailSectionHeader}>
|
|
<Text style={styles.detailSectionTitle}>Labels</Text>
|
|
<Text style={styles.detailSectionMeta}>
|
|
{(projectRowDetail?.provider === 'github'
|
|
? projectRowDetail.labels
|
|
: projectRowItem.content.labels.map((label) => label.name)
|
|
).length || 'None'}
|
|
</Text>
|
|
</View>
|
|
{projectLabelsLoading ? (
|
|
<View style={styles.detailLoadingInline}>
|
|
<ActivityIndicator size="small" color={colors.textSecondary} />
|
|
<Text style={styles.detailMuted}>Loading labels...</Text>
|
|
</View>
|
|
) : projectLabelsError ? (
|
|
<Text style={styles.detailError}>{projectLabelsError}</Text>
|
|
) : projectAvailableLabels.length === 0 ? (
|
|
<Text style={styles.detailMuted}>No labels in this repository.</Text>
|
|
) : (
|
|
<View style={styles.chipRow}>
|
|
{[
|
|
...new Set([
|
|
...projectAvailableLabels,
|
|
...(projectRowDetail?.provider === 'github'
|
|
? projectRowDetail.labels
|
|
: projectRowItem.content.labels.map((label) => label.name))
|
|
])
|
|
].map((label) => {
|
|
const selected = (
|
|
projectRowDetail?.provider === 'github'
|
|
? projectRowDetail.labels
|
|
: projectRowItem.content.labels.map((entry) => entry.name)
|
|
).includes(label)
|
|
return (
|
|
<Pressable
|
|
key={label}
|
|
style={[
|
|
styles.detailChip,
|
|
selected ? styles.detailChipSelected : undefined
|
|
]}
|
|
disabled={projectMutating}
|
|
onPress={() =>
|
|
void mutateProjectRowMetadata(
|
|
projectRowItem,
|
|
selected ? { removeLabels: [label] } : { addLabels: [label] }
|
|
)
|
|
}
|
|
>
|
|
<View style={styles.issueTypeChipContent}>
|
|
{selected ? <Check size={12} color={colors.accentBlue} /> : null}
|
|
<Text style={styles.detailChipText}>{label}</Text>
|
|
</View>
|
|
</Pressable>
|
|
)
|
|
})}
|
|
</View>
|
|
)}
|
|
</View>
|
|
) : null}
|
|
{SHOW_MOBILE_PROJECT_METADATA_EDITORS && projectRowType(projectRowItem) ? (
|
|
<View style={styles.detailSection}>
|
|
<View style={styles.detailSectionHeader}>
|
|
<Text style={styles.detailSectionTitle}>Assignees</Text>
|
|
<Text style={styles.detailSectionMeta}>
|
|
{(projectRowDetail?.provider === 'github'
|
|
? projectRowDetail.assignees
|
|
: projectRowItem.content.assignees.map((assignee) => assignee.login)
|
|
).length || 'None'}
|
|
</Text>
|
|
</View>
|
|
{projectAssignableUsersLoading ? (
|
|
<View style={styles.detailLoadingInline}>
|
|
<ActivityIndicator size="small" color={colors.textSecondary} />
|
|
<Text style={styles.detailMuted}>Loading assignees...</Text>
|
|
</View>
|
|
) : projectAssignableUsersError ? (
|
|
<Text style={styles.detailError}>{projectAssignableUsersError}</Text>
|
|
) : projectAssignableUsers.length === 0 ? (
|
|
<Text style={styles.detailMuted}>
|
|
No assignable users found for this repository.
|
|
</Text>
|
|
) : (
|
|
<View style={styles.chipRow}>
|
|
{[
|
|
...new Map(
|
|
[
|
|
...projectAssignableUsers,
|
|
...projectRowItem.content.assignees,
|
|
...(projectRowDetail?.provider === 'github'
|
|
? projectRowDetail.assignees.map((login) => ({
|
|
login,
|
|
name: null,
|
|
avatarUrl: null
|
|
}))
|
|
: [])
|
|
].map((user) => [user.login, user])
|
|
).values()
|
|
].map((user) => {
|
|
const selected = (
|
|
projectRowDetail?.provider === 'github'
|
|
? projectRowDetail.assignees
|
|
: projectRowItem.content.assignees.map((assignee) => assignee.login)
|
|
).includes(user.login)
|
|
return (
|
|
<Pressable
|
|
key={user.login}
|
|
style={[
|
|
styles.detailChip,
|
|
selected ? styles.detailChipSelected : undefined
|
|
]}
|
|
disabled={projectMutating}
|
|
onPress={() =>
|
|
void mutateProjectRowMetadata(
|
|
projectRowItem,
|
|
selected
|
|
? { removeAssignees: [user.login] }
|
|
: { addAssignees: [user.login] }
|
|
)
|
|
}
|
|
>
|
|
<View style={styles.issueTypeChipContent}>
|
|
{selected ? <Check size={12} color={colors.accentBlue} /> : null}
|
|
<Text style={styles.detailChipText}>{user.login}</Text>
|
|
</View>
|
|
</Pressable>
|
|
)
|
|
})}
|
|
</View>
|
|
)}
|
|
</View>
|
|
) : null}
|
|
{projectRowDetailLoading ? (
|
|
<View style={styles.detailLoading}>
|
|
<ActivityIndicator size="small" color={colors.textSecondary} />
|
|
</View>
|
|
) : projectRowDetailError ? (
|
|
<Text style={styles.detailError}>{projectRowDetailError}</Text>
|
|
) : (
|
|
<>
|
|
{SHOW_MOBILE_PROJECT_METADATA_EDITORS && projectRowType(projectRowItem) ? (
|
|
<>
|
|
<View style={styles.detailSection}>
|
|
<Text style={styles.detailSectionTitle}>Title</Text>
|
|
<TextInput
|
|
style={styles.input}
|
|
value={projectTitleDraft}
|
|
onChangeText={setProjectTitleDraft}
|
|
placeholder="Title"
|
|
placeholderTextColor={colors.textMuted}
|
|
/>
|
|
<Pressable
|
|
style={styles.inlineSaveButton}
|
|
disabled={
|
|
projectMutating ||
|
|
projectTitleDraft.trim() === projectRowItem.content.title
|
|
}
|
|
onPress={() =>
|
|
void mutateProjectRowIssueOrPr(projectRowItem, {
|
|
title: projectTitleDraft.trim()
|
|
})
|
|
}
|
|
>
|
|
<Text style={styles.inlineSaveText}>Save title</Text>
|
|
</Pressable>
|
|
</View>
|
|
<View style={styles.detailSection}>
|
|
<Text style={styles.detailSectionTitle}>Description</Text>
|
|
<TextInput
|
|
style={[styles.input, styles.bodyInput]}
|
|
value={projectBodyDraft}
|
|
onChangeText={setProjectBodyDraft}
|
|
placeholder="Description"
|
|
placeholderTextColor={colors.textMuted}
|
|
multiline
|
|
textAlignVertical="top"
|
|
/>
|
|
<Pressable
|
|
style={styles.inlineSaveButton}
|
|
disabled={
|
|
projectMutating ||
|
|
projectBodyDraft ===
|
|
(projectRowDetail?.provider === 'github'
|
|
? projectRowDetail.body
|
|
: (projectRowItem.content.body ?? ''))
|
|
}
|
|
onPress={() =>
|
|
void mutateProjectRowIssueOrPr(projectRowItem, {
|
|
body: projectBodyDraft
|
|
})
|
|
}
|
|
>
|
|
<Text style={styles.inlineSaveText}>Save description</Text>
|
|
</Pressable>
|
|
<MobileMarkdown content={projectBodyDraft} fallback="No description." />
|
|
</View>
|
|
</>
|
|
) : (
|
|
<>
|
|
<View style={styles.detailSection}>
|
|
<Text style={styles.detailSectionTitle}>Title</Text>
|
|
<Text style={styles.detailLine}>{projectRowItem.content.title}</Text>
|
|
</View>
|
|
<View style={styles.detailSection}>
|
|
<Text style={styles.detailSectionTitle}>Description</Text>
|
|
<MobileMarkdown content={projectBodyDraft} fallback="No description." />
|
|
</View>
|
|
</>
|
|
)}
|
|
{SHOW_MOBILE_PROJECT_REVIEW_PANELS &&
|
|
projectRowItem.itemType === 'PULL_REQUEST' &&
|
|
projectRowDetail?.provider === 'github' &&
|
|
projectRowHostedRepo ? (
|
|
<>
|
|
<View style={styles.detailSection}>
|
|
<View style={styles.detailSectionHeader}>
|
|
<Text style={styles.detailSectionTitle}>Reviewers</Text>
|
|
<Text style={styles.detailSectionMeta}>
|
|
{getGitHubReviewSummary(projectRowDetail)}
|
|
</Text>
|
|
</View>
|
|
{getGitHubReviewerRows(projectRowDetail).length === 0 ? (
|
|
<Text style={styles.detailMuted}>No reviewers requested.</Text>
|
|
) : (
|
|
getGitHubReviewerRows(projectRowDetail).map((reviewer) => (
|
|
<View key={reviewer.login} style={styles.reviewerRow}>
|
|
<View style={styles.reviewerAvatar}>
|
|
<Text style={styles.reviewerAvatarText}>
|
|
{reviewer.login.slice(0, 1).toUpperCase()}
|
|
</Text>
|
|
</View>
|
|
<View style={styles.reviewerInfo}>
|
|
<Text style={styles.reviewerName} numberOfLines={1}>
|
|
{reviewer.login}
|
|
</Text>
|
|
{reviewer.name ? (
|
|
<Text style={styles.reviewerMeta} numberOfLines={1}>
|
|
{reviewer.name}
|
|
</Text>
|
|
) : null}
|
|
</View>
|
|
<Text style={styles.reviewerState}>{reviewer.stateLabel}</Text>
|
|
</View>
|
|
))
|
|
)}
|
|
{projectAssignableUsersLoading ? (
|
|
<View style={styles.detailLoadingInline}>
|
|
<ActivityIndicator size="small" color={colors.textSecondary} />
|
|
<Text style={styles.detailMuted}>Loading reviewers...</Text>
|
|
</View>
|
|
) : projectAssignableUsersError ? (
|
|
<Text style={styles.detailError}>{projectAssignableUsersError}</Text>
|
|
) : projectReviewerCandidates.length === 0 ? (
|
|
<Text style={styles.detailMuted}>No reviewer suggestions found.</Text>
|
|
) : (
|
|
<View style={styles.chipRow}>
|
|
{projectReviewerCandidates.map((user) => {
|
|
const selected = projectSelectedReviewerLogins.has(
|
|
user.login.trim().toLowerCase()
|
|
)
|
|
return (
|
|
<Pressable
|
|
key={user.login}
|
|
style={[
|
|
styles.detailChip,
|
|
selected ? styles.detailChipSelected : undefined
|
|
]}
|
|
disabled={projectMutating || selected}
|
|
onPress={() =>
|
|
void requestProjectGitHubReviewers(projectRowItem, [user.login])
|
|
}
|
|
>
|
|
<View style={styles.issueTypeChipContent}>
|
|
{selected ? (
|
|
<Check size={12} color={colors.accentBlue} />
|
|
) : null}
|
|
<Text style={styles.detailChipText}>{user.login}</Text>
|
|
</View>
|
|
</Pressable>
|
|
)
|
|
})}
|
|
</View>
|
|
)}
|
|
<TextInput
|
|
style={styles.input}
|
|
value={projectReviewersDraft}
|
|
onChangeText={setProjectReviewersDraft}
|
|
placeholder="Request reviewers"
|
|
placeholderTextColor={colors.textMuted}
|
|
autoCapitalize="none"
|
|
/>
|
|
<Pressable
|
|
style={styles.inlineSaveButton}
|
|
disabled={
|
|
projectMutating || splitReviewerList(projectReviewersDraft).length === 0
|
|
}
|
|
onPress={() => void requestProjectGitHubReviewers(projectRowItem)}
|
|
>
|
|
<Text style={styles.inlineSaveText}>Request review</Text>
|
|
</Pressable>
|
|
</View>
|
|
|
|
{projectRowType(projectRowItem) === 'pr' ? (
|
|
<View style={styles.detailSection}>
|
|
<View style={styles.detailSectionHeader}>
|
|
<Text style={styles.detailSectionTitle}>Checks</Text>
|
|
<View style={styles.inlineActionRow}>
|
|
<Pressable
|
|
style={styles.inlineSaveButtonCompact}
|
|
disabled={projectMutating}
|
|
onPress={() => void refreshProjectGitHubChecks(projectRowItem)}
|
|
>
|
|
<Text style={styles.inlineSaveText}>Refresh</Text>
|
|
</Pressable>
|
|
<Pressable
|
|
style={styles.inlineSaveButtonCompact}
|
|
disabled={
|
|
projectMutating ||
|
|
!projectRowDetail.checks.some(isFailedGitHubCheck)
|
|
}
|
|
onPress={() => void rerunProjectGitHubChecks(projectRowItem, true)}
|
|
>
|
|
<Text style={styles.inlineSaveText}>Rerun failed</Text>
|
|
</Pressable>
|
|
<Pressable
|
|
style={styles.inlineSaveButtonCompact}
|
|
disabled={projectMutating || projectRowDetail.checks.length === 0}
|
|
onPress={() => void rerunProjectGitHubChecks(projectRowItem, false)}
|
|
>
|
|
<Text style={styles.inlineSaveText}>Rerun all</Text>
|
|
</Pressable>
|
|
</View>
|
|
</View>
|
|
{projectRowDetail.checks.length === 0 ? (
|
|
<Text style={styles.detailMuted}>No checks found.</Text>
|
|
) : (
|
|
projectRowDetail.checks.map((check) => (
|
|
<Pressable
|
|
key={`${check.name}:${check.status}:${check.url ?? ''}`}
|
|
style={styles.fileActionRow}
|
|
disabled={!check.url}
|
|
onPress={() => {
|
|
if (check.url) {
|
|
void Linking.openURL(check.url)
|
|
}
|
|
}}
|
|
>
|
|
<Text style={styles.detailLine} numberOfLines={2}>
|
|
{check.name} · {check.conclusion ?? check.status}
|
|
</Text>
|
|
{check.url ? (
|
|
<ExternalLink size={14} color={colors.textSecondary} />
|
|
) : null}
|
|
</Pressable>
|
|
))
|
|
)}
|
|
</View>
|
|
) : null}
|
|
|
|
{projectRowDetail.files.length > 0 ? (
|
|
<View style={styles.detailSection}>
|
|
<Text style={styles.detailSectionTitle}>Changed files</Text>
|
|
{projectRowDetail.files.map((file) => (
|
|
<View key={file.path} style={styles.fileCard}>
|
|
<Pressable
|
|
style={styles.fileActionRow}
|
|
disabled={projectMutating}
|
|
onPress={() =>
|
|
void toggleProjectGitHubFileExpansion(projectRowItem, file)
|
|
}
|
|
>
|
|
<Text style={styles.detailLine}>
|
|
{file.path}
|
|
{typeof file.additions === 'number' ||
|
|
typeof file.deletions === 'number'
|
|
? ` · +${file.additions ?? 0} -${file.deletions ?? 0}`
|
|
: ''}
|
|
</Text>
|
|
<Text style={styles.detailSectionMeta}>
|
|
{expandedPrFilePath === file.path ? 'Hide' : 'View'}
|
|
</Text>
|
|
</Pressable>
|
|
<Pressable
|
|
style={styles.inlineSaveButtonCompact}
|
|
disabled={projectMutating || !projectRowDetail.pullRequestId}
|
|
onPress={() =>
|
|
void toggleProjectGitHubFileViewed(projectRowItem, file)
|
|
}
|
|
>
|
|
<Text style={styles.inlineSaveText}>
|
|
{file.viewerViewedState === 'VIEWED'
|
|
? 'Mark unviewed'
|
|
: 'Mark viewed'}
|
|
</Text>
|
|
</Pressable>
|
|
{expandedPrFilePath === file.path ? (
|
|
<View style={styles.filePreview}>
|
|
{prFileLoadingPath === file.path ? (
|
|
<ActivityIndicator size="small" color={colors.textSecondary} />
|
|
) : prFileContents[file.path]?.originalIsBinary ||
|
|
prFileContents[file.path]?.modifiedIsBinary ? (
|
|
<Text style={styles.detailMuted}>Binary file.</Text>
|
|
) : prFileContents[file.path] ? (
|
|
<GitHubPrFileDiff
|
|
filePath={file.path}
|
|
contents={prFileContents[file.path]}
|
|
commentDrafts={prFileCommentDrafts}
|
|
disabled={projectMutating}
|
|
onCommentDraftChange={(draftKey, next) =>
|
|
setPrFileCommentDrafts((current) => ({
|
|
...current,
|
|
[draftKey]: next
|
|
}))
|
|
}
|
|
onSubmitComment={(line) =>
|
|
void addProjectGitHubFileReviewComment(
|
|
projectRowItem,
|
|
file,
|
|
line
|
|
)
|
|
}
|
|
/>
|
|
) : (
|
|
<Text style={styles.detailMuted}>
|
|
File contents unavailable.
|
|
</Text>
|
|
)}
|
|
</View>
|
|
) : null}
|
|
</View>
|
|
))}
|
|
</View>
|
|
) : null}
|
|
</>
|
|
) : null}
|
|
{projectRowDetail?.provider === 'github' ? (
|
|
<View style={styles.detailSection}>
|
|
<View style={styles.detailSectionHeader}>
|
|
<Text style={styles.detailSectionTitle}>Discussion</Text>
|
|
<Text style={styles.detailSectionMeta}>
|
|
{discussionSummary(projectRowDetail.comments.length)}
|
|
</Text>
|
|
</View>
|
|
{projectRowDetail.comments.length === 0 ? (
|
|
<Text style={styles.detailMuted}>No comments.</Text>
|
|
) : (
|
|
projectDetailCommentGroups.map((group) => {
|
|
const groupId = detailCommentGroupId(group)
|
|
const root = detailCommentGroupRoot(group)
|
|
const count = detailCommentGroupCount(group)
|
|
const isCollapsedResolved =
|
|
isResolvedDetailCommentGroup(group) &&
|
|
!expandedResolvedCommentGroups.has(groupId)
|
|
if (isCollapsedResolved) {
|
|
return (
|
|
<Pressable
|
|
key={groupId}
|
|
style={styles.resolvedCommentSummary}
|
|
onPress={() =>
|
|
setExpandedResolvedCommentGroups((current) => {
|
|
const next = new Set(current)
|
|
next.add(groupId)
|
|
return next
|
|
})
|
|
}
|
|
>
|
|
<Text style={styles.resolvedCommentTitle} numberOfLines={1}>
|
|
Resolved {group.kind === 'thread' ? 'thread' : 'comment'} by{' '}
|
|
{commentAuthor(root)}
|
|
</Text>
|
|
<Text style={styles.detailSectionMeta}>
|
|
{count > 1 ? `${count} comments` : 'Show'}
|
|
</Text>
|
|
</Pressable>
|
|
)
|
|
}
|
|
|
|
const renderProjectComment = (
|
|
comment: DetailComment,
|
|
options: { nested?: boolean } = {}
|
|
): ReactNode => {
|
|
const commentId = String(comment.id)
|
|
const isEditingComment =
|
|
SHOW_MOBILE_COMMENT_THREAD_TOOLS &&
|
|
projectEditingCommentId === commentId
|
|
return (
|
|
<View
|
|
key={commentId}
|
|
style={[
|
|
styles.commentBlock,
|
|
options.nested && styles.commentReplyBlock,
|
|
comment.isResolved && styles.commentResolvedBlock
|
|
]}
|
|
>
|
|
<Text style={styles.commentSource} numberOfLines={1}>
|
|
{commentSourceLabel(comment)}
|
|
</Text>
|
|
<Text style={styles.commentMeta}>
|
|
{commentAuthor(comment)}
|
|
{commentDate(comment.createdAt)
|
|
? ` · ${commentDate(comment.createdAt)}`
|
|
: ''}
|
|
</Text>
|
|
{isEditingComment ? (
|
|
<>
|
|
<TextInput
|
|
style={[styles.input, styles.commentInput]}
|
|
value={projectEditingCommentDraft}
|
|
onChangeText={setProjectEditingCommentDraft}
|
|
placeholder="Edit comment"
|
|
placeholderTextColor={colors.textMuted}
|
|
multiline
|
|
textAlignVertical="top"
|
|
/>
|
|
<View style={styles.inlineActionRow}>
|
|
<Pressable
|
|
style={styles.inlineSaveButtonCompact}
|
|
disabled={
|
|
projectMutating ||
|
|
projectEditingCommentDraft.trim().length === 0
|
|
}
|
|
onPress={() =>
|
|
void updateProjectRowComment(projectRowItem, comment)
|
|
}
|
|
>
|
|
<Text style={styles.inlineSaveText}>Save</Text>
|
|
</Pressable>
|
|
<Pressable
|
|
style={styles.inlineSaveButtonCompact}
|
|
disabled={projectMutating}
|
|
onPress={() => {
|
|
setProjectEditingCommentId(null)
|
|
setProjectEditingCommentDraft('')
|
|
}}
|
|
>
|
|
<Text style={styles.inlineSaveText}>Cancel</Text>
|
|
</Pressable>
|
|
</View>
|
|
</>
|
|
) : (
|
|
<>
|
|
<MobileMarkdown content={comment.body} />
|
|
{renderCommentReactions(comment)}
|
|
{SHOW_MOBILE_COMMENT_THREAD_TOOLS ? (
|
|
<View style={styles.inlineActionRow}>
|
|
{projectRowType(projectRowItem) === 'pr' &&
|
|
comment.threadId ? (
|
|
<Pressable
|
|
style={styles.inlineSaveButtonCompact}
|
|
disabled={projectMutating}
|
|
onPress={() =>
|
|
void toggleProjectGitHubReviewThread(
|
|
projectRowItem,
|
|
comment
|
|
)
|
|
}
|
|
>
|
|
<Text style={styles.inlineSaveText}>
|
|
{comment.isResolved
|
|
? 'Reopen thread'
|
|
: 'Resolve thread'}
|
|
</Text>
|
|
</Pressable>
|
|
) : null}
|
|
<TextInput
|
|
style={[styles.input, styles.replyInput]}
|
|
value={itemReplyDrafts[commentId] ?? ''}
|
|
onChangeText={(next) =>
|
|
setItemReplyDrafts((current) => ({
|
|
...current,
|
|
[commentId]: next
|
|
}))
|
|
}
|
|
placeholder="Reply"
|
|
placeholderTextColor={colors.textMuted}
|
|
multiline
|
|
textAlignVertical="top"
|
|
/>
|
|
<Pressable
|
|
style={styles.inlineSaveButtonCompact}
|
|
disabled={
|
|
projectMutating ||
|
|
!(itemReplyDrafts[commentId] ?? '').trim()
|
|
}
|
|
onPress={() =>
|
|
void replyToProjectGitHubComment(
|
|
projectRowItem,
|
|
comment
|
|
)
|
|
}
|
|
>
|
|
<Text style={styles.inlineSaveText}>Reply</Text>
|
|
</Pressable>
|
|
<Pressable
|
|
style={styles.inlineSaveButtonCompact}
|
|
disabled={projectMutating}
|
|
onPress={() => {
|
|
setProjectEditingCommentId(commentId)
|
|
setProjectEditingCommentDraft(comment.body)
|
|
}}
|
|
>
|
|
<Text style={styles.inlineSaveText}>Edit</Text>
|
|
</Pressable>
|
|
<Pressable
|
|
style={styles.inlineSaveButtonCompact}
|
|
disabled={projectMutating}
|
|
onPress={() =>
|
|
void deleteProjectRowComment(projectRowItem, comment)
|
|
}
|
|
>
|
|
<Text style={styles.inlineDeleteText}>Delete</Text>
|
|
</Pressable>
|
|
</View>
|
|
) : null}
|
|
</>
|
|
)}
|
|
</View>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<View key={groupId} style={styles.commentThreadGroup}>
|
|
{group.kind === 'thread'
|
|
? [
|
|
renderProjectComment(group.root),
|
|
...group.replies.map((reply) =>
|
|
renderProjectComment(reply, { nested: true })
|
|
)
|
|
]
|
|
: renderProjectComment(group.comment)}
|
|
</View>
|
|
)
|
|
})
|
|
)}
|
|
{renderCommentComposer({
|
|
value: projectCommentDraft,
|
|
onChangeText: setProjectCommentDraft,
|
|
disabled: projectMutating,
|
|
onSubmit: () => void addProjectRowComment(projectRowItem)
|
|
})}
|
|
</View>
|
|
) : null}
|
|
</>
|
|
)}
|
|
</View>
|
|
|
|
<View style={styles.actionGroup}>
|
|
{canCreateWorkspaceFromProjectRow(projectRowItem) ? (
|
|
<Pressable
|
|
style={styles.actionRow}
|
|
disabled={creatingKey === `github-project:${projectRowItem.id}`}
|
|
onPress={() => void createWorkspaceFromProjectRow(projectRowItem)}
|
|
>
|
|
<Plus size={16} color={colors.textPrimary} />
|
|
<Text style={styles.actionText}>Create Workspace</Text>
|
|
</Pressable>
|
|
) : (
|
|
<Text style={styles.emptyInlineText}>
|
|
Workspaces can only be created from GitHub issues and pull requests.
|
|
</Text>
|
|
)}
|
|
|
|
{projectRowItem.content.url ? (
|
|
<>
|
|
{canCreateWorkspaceFromProjectRow(projectRowItem) ? (
|
|
<View style={styles.actionSeparator} />
|
|
) : null}
|
|
<Pressable
|
|
style={styles.actionRow}
|
|
onPress={() => {
|
|
if (projectRowItem.content.url) {
|
|
void Linking.openURL(projectRowItem.content.url)
|
|
}
|
|
}}
|
|
>
|
|
<ExternalLink size={16} color={colors.textPrimary} />
|
|
<Text style={styles.actionText}>Open in GitHub</Text>
|
|
</Pressable>
|
|
<View style={styles.actionSeparator} />
|
|
<Pressable
|
|
style={styles.actionRow}
|
|
onPress={() =>
|
|
projectRowItem.content.url
|
|
? void copyTaskLink(
|
|
`github-project:${projectRowItem.id}`,
|
|
projectRowItem.content.url
|
|
)
|
|
: undefined
|
|
}
|
|
>
|
|
<Copy size={16} color={colors.textPrimary} />
|
|
<Text style={styles.actionText}>
|
|
{copiedLinkKey === `github-project:${projectRowItem.id}`
|
|
? 'Copied'
|
|
: 'Copy GitHub link'}
|
|
</Text>
|
|
</Pressable>
|
|
</>
|
|
) : null}
|
|
{projectRowType(projectRowItem) &&
|
|
projectRowItem.content.state !== 'MERGED' &&
|
|
projectRowItem.itemType !== 'DRAFT_ISSUE' ? (
|
|
<>
|
|
<View style={styles.actionSeparator} />
|
|
<Pressable
|
|
style={styles.actionRow}
|
|
disabled={projectMutating}
|
|
onPress={() => {
|
|
const nextState =
|
|
projectRowItem.content.state === 'CLOSED' ? 'open' : 'closed'
|
|
if (projectRowItem.itemType === 'PULL_REQUEST') {
|
|
setPendingHostedStateChange({
|
|
source: 'project',
|
|
row: projectRowItem,
|
|
nextState
|
|
})
|
|
return
|
|
}
|
|
setPendingHostedStateChange({
|
|
source: 'project',
|
|
row: projectRowItem,
|
|
nextState
|
|
})
|
|
}}
|
|
>
|
|
{projectRowItem.content.state === 'CLOSED' ? (
|
|
<RefreshCw size={16} color={colors.textPrimary} />
|
|
) : (
|
|
<X size={16} color={colors.textPrimary} />
|
|
)}
|
|
<Text style={styles.actionText}>
|
|
{projectRowItem.content.state === 'CLOSED' ? 'Reopen item' : 'Close item'}
|
|
</Text>
|
|
</Pressable>
|
|
</>
|
|
) : null}
|
|
{projectRowItem.itemType === 'PULL_REQUEST' &&
|
|
projectRowItem.content.state !== 'CLOSED' &&
|
|
projectRowItem.content.state !== 'MERGED' ? (
|
|
<>
|
|
<View style={styles.actionSeparator} />
|
|
<Pressable
|
|
style={styles.actionRow}
|
|
disabled={projectMutating || !projectRowHostedRepo}
|
|
onPress={() => setMergeMethodProjectRow(projectRowItem)}
|
|
>
|
|
<GitBranch size={16} color={colors.textPrimary} />
|
|
<Text style={styles.actionText}>Merge pull request</Text>
|
|
</Pressable>
|
|
{!projectRowHostedRepo ? (
|
|
<Text style={styles.emptyInlineText}>
|
|
Merge requires this repository in Orca.
|
|
</Text>
|
|
) : null}
|
|
</>
|
|
) : null}
|
|
</View>
|
|
</View>
|
|
) : null}
|
|
</BottomDrawer>
|
|
|
|
<BottomDrawer visible={taskUiReady && actionItem != null} onClose={() => setActionItem(null)}>
|
|
{actionItem ? (
|
|
<View>
|
|
<View style={styles.sheetHeader}>
|
|
<View style={styles.sheetTitleRow}>
|
|
<TaskProviderLogo
|
|
provider={actionItem.provider}
|
|
size={16}
|
|
color={colors.textPrimary}
|
|
/>
|
|
<Text style={styles.sheetTitle} numberOfLines={2}>
|
|
{actionItem.title}
|
|
</Text>
|
|
<Pressable
|
|
style={styles.iconButton}
|
|
disabled={detailLoading}
|
|
accessibilityLabel="Refresh details"
|
|
onPress={() => setDetailRefreshSeq((current) => current + 1)}
|
|
>
|
|
<RefreshCw
|
|
size={16}
|
|
color={detailLoading ? colors.textMuted : colors.textSecondary}
|
|
/>
|
|
</Pressable>
|
|
</View>
|
|
<Text style={styles.sheetSubtitle}>
|
|
{taskKindLabel(actionItem)} · {actionItem.subtitle}
|
|
</Text>
|
|
</View>
|
|
|
|
<View style={styles.detailGroup}>
|
|
{detailLoading ? (
|
|
<View style={styles.detailLoading}>
|
|
<ActivityIndicator size="small" color={colors.textSecondary} />
|
|
</View>
|
|
) : detailError ? (
|
|
<Text style={styles.detailError}>{detailError}</Text>
|
|
) : detailPayload ? (
|
|
<>
|
|
<View style={styles.detailMetaGrid}>
|
|
<View style={styles.detailMetaItem}>
|
|
<Text style={styles.detailMetaLabel}>Type</Text>
|
|
<Text style={styles.detailMetaValue}>{taskKindLabel(actionItem)}</Text>
|
|
</View>
|
|
<View style={styles.detailMetaItem}>
|
|
<Text style={styles.detailMetaLabel}>Status</Text>
|
|
<Text style={styles.detailMetaValue}>{actionItem.status}</Text>
|
|
</View>
|
|
{detailPayload.provider === 'linear' && detailPayload.assignee ? (
|
|
<View style={styles.detailMetaItem}>
|
|
<Text style={styles.detailMetaLabel}>Assignee</Text>
|
|
<Text style={styles.detailMetaValue}>{detailPayload.assignee}</Text>
|
|
</View>
|
|
) : null}
|
|
{detailPayload.provider === 'linear' && detailPayload.project ? (
|
|
<View style={styles.detailMetaItem}>
|
|
<Text style={styles.detailMetaLabel}>Project</Text>
|
|
<Text style={styles.detailMetaValue}>{detailPayload.project.name}</Text>
|
|
</View>
|
|
) : null}
|
|
{(detailPayload.provider === 'github' || detailPayload.provider === 'gitlab') &&
|
|
detailPayload.assignees.length > 0 ? (
|
|
<View style={styles.detailMetaItem}>
|
|
<Text style={styles.detailMetaLabel}>Assignees</Text>
|
|
<Text style={styles.detailMetaValue}>
|
|
{detailPayload.assignees.join(', ')}
|
|
</Text>
|
|
</View>
|
|
) : null}
|
|
</View>
|
|
|
|
{SHOW_MOBILE_DETAIL_LABEL_CHIPS && detailPayload.labels.length > 0 ? (
|
|
<View style={styles.chipRow}>
|
|
{detailPayload.labels.map((label) => (
|
|
<View key={label} style={styles.detailChip}>
|
|
<Text style={styles.detailChipText}>{label}</Text>
|
|
</View>
|
|
))}
|
|
</View>
|
|
) : null}
|
|
|
|
<View style={styles.detailSection}>
|
|
<Text style={styles.detailSectionTitle}>Description</Text>
|
|
{SHOW_MOBILE_DETAIL_METADATA_EDITORS &&
|
|
((actionItem.provider === 'github' &&
|
|
detailPayload.provider === 'github' &&
|
|
(actionItem.source.type === 'issue' || actionItem.source.type === 'pr')) ||
|
|
(actionItem.provider === 'gitlab' &&
|
|
detailPayload.provider === 'gitlab' &&
|
|
(actionItem.source.type === 'issue' ||
|
|
actionItem.source.type === 'mr'))) ? (
|
|
<>
|
|
<TextInput
|
|
style={[styles.input, styles.bodyInput]}
|
|
value={itemBodyDraft}
|
|
onChangeText={setItemBodyDraft}
|
|
placeholder="Description"
|
|
placeholderTextColor={colors.textMuted}
|
|
multiline
|
|
textAlignVertical="top"
|
|
/>
|
|
<Pressable
|
|
style={styles.inlineSaveButton}
|
|
disabled={mutatingStatus || itemBodyDraft === detailPayload.body}
|
|
onPress={() => {
|
|
if (
|
|
actionItem.provider === 'github' &&
|
|
actionItem.source.type === 'pr'
|
|
) {
|
|
void updateGitHubPullRequestMetadata(actionItem, {
|
|
body: itemBodyDraft
|
|
})
|
|
return
|
|
}
|
|
if (
|
|
actionItem.provider === 'github' &&
|
|
actionItem.source.type === 'issue'
|
|
) {
|
|
void updateGitHubIssueMetadata(actionItem, {
|
|
body: itemBodyDraft
|
|
})
|
|
return
|
|
}
|
|
if (
|
|
actionItem.provider === 'gitlab' &&
|
|
(actionItem.source.type === 'issue' ||
|
|
actionItem.source.type === 'mr')
|
|
) {
|
|
void updateGitLabIssueMetadata(actionItem, {
|
|
body: itemBodyDraft
|
|
})
|
|
}
|
|
}}
|
|
>
|
|
<Text style={styles.inlineSaveText}>Save description</Text>
|
|
</Pressable>
|
|
<MobileMarkdown content={itemBodyDraft} fallback="No description." />
|
|
</>
|
|
) : (
|
|
<MobileMarkdown
|
|
content={
|
|
detailPayload.provider === 'linear'
|
|
? detailPayload.description
|
|
: detailPayload.body
|
|
}
|
|
fallback="No description."
|
|
/>
|
|
)}
|
|
</View>
|
|
|
|
{SHOW_MOBILE_DETAIL_METADATA_EDITORS &&
|
|
((actionItem.provider === 'github' &&
|
|
detailPayload.provider === 'github' &&
|
|
(actionItem.source.type === 'issue' || actionItem.source.type === 'pr')) ||
|
|
(actionItem.provider === 'gitlab' &&
|
|
(actionItem.source.type === 'issue' || actionItem.source.type === 'mr') &&
|
|
detailPayload.provider === 'gitlab')) ? (
|
|
<>
|
|
<View style={styles.detailSection}>
|
|
<Text style={styles.detailSectionTitle}>Title</Text>
|
|
<TextInput
|
|
style={styles.input}
|
|
value={itemTitleDraft}
|
|
onChangeText={setItemTitleDraft}
|
|
placeholder="Title"
|
|
placeholderTextColor={colors.textMuted}
|
|
/>
|
|
<Pressable
|
|
style={styles.inlineSaveButton}
|
|
disabled={
|
|
mutatingStatus ||
|
|
itemTitleDraft.trim().length === 0 ||
|
|
itemTitleDraft.trim() === actionItem.title
|
|
}
|
|
onPress={() => {
|
|
if (
|
|
actionItem.provider === 'github' &&
|
|
actionItem.source.type === 'pr'
|
|
) {
|
|
void updateGitHubPullRequestMetadata(actionItem, {
|
|
title: itemTitleDraft.trim()
|
|
})
|
|
return
|
|
}
|
|
if (actionItem.provider === 'github') {
|
|
void updateGitHubIssueMetadata(actionItem, {
|
|
title: itemTitleDraft.trim()
|
|
})
|
|
return
|
|
}
|
|
if (actionItem.provider === 'gitlab') {
|
|
void updateGitLabIssueMetadata(actionItem, {
|
|
title: itemTitleDraft.trim()
|
|
})
|
|
}
|
|
}}
|
|
>
|
|
<Text style={styles.inlineSaveText}>Save title</Text>
|
|
</Pressable>
|
|
</View>
|
|
|
|
<View style={styles.detailSection}>
|
|
<View style={styles.detailSectionHeader}>
|
|
<Text style={styles.detailSectionTitle}>Labels</Text>
|
|
<Text style={styles.detailSectionMeta}>
|
|
{detailPayload.labels.length || 'None'}
|
|
</Text>
|
|
</View>
|
|
{actionItem.provider === 'github' ? (
|
|
itemLabelsLoading ? (
|
|
<View style={styles.detailLoadingInline}>
|
|
<ActivityIndicator size="small" color={colors.textSecondary} />
|
|
<Text style={styles.detailMuted}>Loading labels...</Text>
|
|
</View>
|
|
) : itemLabelsError ? (
|
|
<Text style={styles.detailError}>{itemLabelsError}</Text>
|
|
) : itemAvailableLabels.length === 0 ? (
|
|
<Text style={styles.detailMuted}>No labels in this repository.</Text>
|
|
) : (
|
|
<View style={styles.chipRow}>
|
|
{[...new Set([...itemAvailableLabels, ...detailPayload.labels])].map(
|
|
(label) => {
|
|
const selected = detailPayload.labels.includes(label)
|
|
return (
|
|
<Pressable
|
|
key={label}
|
|
style={[
|
|
styles.detailChip,
|
|
selected ? styles.detailChipSelected : undefined
|
|
]}
|
|
disabled={mutatingStatus}
|
|
onPress={() =>
|
|
void updateGitHubIssueMetadata(
|
|
actionItem,
|
|
selected
|
|
? { removeLabels: [label] }
|
|
: { addLabels: [label] }
|
|
)
|
|
}
|
|
>
|
|
<View style={styles.issueTypeChipContent}>
|
|
{selected ? (
|
|
<Check size={12} color={colors.accentBlue} />
|
|
) : null}
|
|
<Text style={styles.detailChipText}>{label}</Text>
|
|
</View>
|
|
</Pressable>
|
|
)
|
|
}
|
|
)}
|
|
</View>
|
|
)
|
|
) : (
|
|
<>
|
|
<TextInput
|
|
style={styles.input}
|
|
value={itemAddLabelsDraft}
|
|
onChangeText={setItemAddLabelsDraft}
|
|
placeholder="Add labels, comma separated"
|
|
placeholderTextColor={colors.textMuted}
|
|
autoCapitalize="none"
|
|
/>
|
|
<TextInput
|
|
style={[styles.input, styles.stackedInput]}
|
|
value={itemRemoveLabelsDraft}
|
|
onChangeText={setItemRemoveLabelsDraft}
|
|
placeholder="Remove labels, comma separated"
|
|
placeholderTextColor={colors.textMuted}
|
|
autoCapitalize="none"
|
|
/>
|
|
<Pressable
|
|
style={styles.inlineSaveButton}
|
|
disabled={
|
|
mutatingStatus ||
|
|
(splitCommaList(itemAddLabelsDraft).length === 0 &&
|
|
splitCommaList(itemRemoveLabelsDraft).length === 0)
|
|
}
|
|
onPress={() =>
|
|
void updateGitLabIssueMetadata(actionItem, {
|
|
addLabels: splitCommaList(itemAddLabelsDraft),
|
|
removeLabels: splitCommaList(itemRemoveLabelsDraft)
|
|
})
|
|
}
|
|
>
|
|
<Text style={styles.inlineSaveText}>Update labels</Text>
|
|
</Pressable>
|
|
</>
|
|
)}
|
|
</View>
|
|
|
|
<View style={styles.detailSection}>
|
|
<View style={styles.detailSectionHeader}>
|
|
<Text style={styles.detailSectionTitle}>Assignees</Text>
|
|
<Text style={styles.detailSectionMeta}>
|
|
{detailPayload.assignees.length || 'None'}
|
|
</Text>
|
|
</View>
|
|
{actionItem.provider === 'github' ? (
|
|
itemAssignableUsersLoading ? (
|
|
<View style={styles.detailLoadingInline}>
|
|
<ActivityIndicator size="small" color={colors.textSecondary} />
|
|
<Text style={styles.detailMuted}>Loading assignees...</Text>
|
|
</View>
|
|
) : itemAssignableUsersError ? (
|
|
<Text style={styles.detailError}>{itemAssignableUsersError}</Text>
|
|
) : itemAssignableUsers.length === 0 ? (
|
|
<Text style={styles.detailMuted}>
|
|
No assignable users found for this repository.
|
|
</Text>
|
|
) : (
|
|
<View style={styles.chipRow}>
|
|
{[
|
|
...new Map(
|
|
[
|
|
...itemAssignableUsers,
|
|
...detailPayload.assignees.map((login) => ({
|
|
login,
|
|
name: null,
|
|
avatarUrl: null
|
|
}))
|
|
].map((user) => [user.login, user])
|
|
).values()
|
|
].map((user) => {
|
|
const selected = detailPayload.assignees.includes(user.login)
|
|
return (
|
|
<Pressable
|
|
key={user.login}
|
|
style={[
|
|
styles.detailChip,
|
|
selected ? styles.detailChipSelected : undefined
|
|
]}
|
|
disabled={mutatingStatus}
|
|
onPress={() =>
|
|
void updateGitHubIssueMetadata(
|
|
actionItem,
|
|
selected
|
|
? { removeAssignees: [user.login] }
|
|
: { addAssignees: [user.login] }
|
|
)
|
|
}
|
|
>
|
|
<View style={styles.issueTypeChipContent}>
|
|
{selected ? (
|
|
<Check size={12} color={colors.accentBlue} />
|
|
) : null}
|
|
<Text style={styles.detailChipText}>{user.login}</Text>
|
|
</View>
|
|
</Pressable>
|
|
)
|
|
})}
|
|
</View>
|
|
)
|
|
) : actionItem.source.type === 'issue' ? (
|
|
<>
|
|
<TextInput
|
|
style={styles.input}
|
|
value={itemAddAssigneesDraft}
|
|
onChangeText={setItemAddAssigneesDraft}
|
|
placeholder="Add usernames, comma separated"
|
|
placeholderTextColor={colors.textMuted}
|
|
autoCapitalize="none"
|
|
/>
|
|
<TextInput
|
|
style={[styles.input, styles.stackedInput]}
|
|
value={itemRemoveAssigneesDraft}
|
|
onChangeText={setItemRemoveAssigneesDraft}
|
|
placeholder="Remove usernames, comma separated"
|
|
placeholderTextColor={colors.textMuted}
|
|
autoCapitalize="none"
|
|
/>
|
|
<Pressable
|
|
style={styles.inlineSaveButton}
|
|
disabled={
|
|
mutatingStatus ||
|
|
(splitCommaList(itemAddAssigneesDraft).length === 0 &&
|
|
splitCommaList(itemRemoveAssigneesDraft).length === 0)
|
|
}
|
|
onPress={() =>
|
|
void updateGitLabIssueMetadata(actionItem, {
|
|
addAssignees: splitCommaList(itemAddAssigneesDraft),
|
|
removeAssignees: splitCommaList(itemRemoveAssigneesDraft)
|
|
})
|
|
}
|
|
>
|
|
<Text style={styles.inlineSaveText}>Update assignees</Text>
|
|
</Pressable>
|
|
</>
|
|
) : null}
|
|
</View>
|
|
</>
|
|
) : null}
|
|
|
|
{SHOW_MOBILE_DETAIL_REVIEW_PANELS &&
|
|
actionItem.provider === 'github' &&
|
|
actionItem.source.type === 'pr' ? (
|
|
<View style={styles.detailSection}>
|
|
<View style={styles.detailSectionHeader}>
|
|
<Text style={styles.detailSectionTitle}>Reviewers</Text>
|
|
{detailPayload.provider === 'github' ? (
|
|
<Text style={styles.detailSectionMeta}>
|
|
{getGitHubReviewSummary(detailPayload)}
|
|
</Text>
|
|
) : null}
|
|
</View>
|
|
{detailPayload.provider === 'github' ? (
|
|
getGitHubReviewerRows(detailPayload).length === 0 ? (
|
|
<Text style={styles.detailMuted}>No reviewers requested.</Text>
|
|
) : (
|
|
getGitHubReviewerRows(detailPayload).map((reviewer) => (
|
|
<View key={reviewer.login} style={styles.reviewerRow}>
|
|
<View style={styles.reviewerAvatar}>
|
|
<Text style={styles.reviewerAvatarText}>
|
|
{reviewer.login.slice(0, 1).toUpperCase()}
|
|
</Text>
|
|
</View>
|
|
<View style={styles.reviewerInfo}>
|
|
<Text style={styles.reviewerName} numberOfLines={1}>
|
|
{reviewer.login}
|
|
</Text>
|
|
{reviewer.name ? (
|
|
<Text style={styles.reviewerMeta} numberOfLines={1}>
|
|
{reviewer.name}
|
|
</Text>
|
|
) : null}
|
|
</View>
|
|
<Text style={styles.reviewerState}>{reviewer.stateLabel}</Text>
|
|
</View>
|
|
))
|
|
)
|
|
) : null}
|
|
{itemAssignableUsersLoading ? (
|
|
<View style={styles.detailLoadingInline}>
|
|
<ActivityIndicator size="small" color={colors.textSecondary} />
|
|
<Text style={styles.detailMuted}>Loading reviewers...</Text>
|
|
</View>
|
|
) : itemAssignableUsersError ? (
|
|
<Text style={styles.detailError}>{itemAssignableUsersError}</Text>
|
|
) : itemReviewerCandidates.length === 0 ? (
|
|
<Text style={styles.detailMuted}>No reviewer suggestions found.</Text>
|
|
) : (
|
|
<View style={styles.chipRow}>
|
|
{itemReviewerCandidates.map((user) => {
|
|
const selected = itemSelectedReviewerLogins.has(
|
|
user.login.trim().toLowerCase()
|
|
)
|
|
return (
|
|
<Pressable
|
|
key={user.login}
|
|
style={[
|
|
styles.detailChip,
|
|
selected ? styles.detailChipSelected : undefined
|
|
]}
|
|
disabled={mutatingStatus || selected}
|
|
onPress={() =>
|
|
void requestGitHubReviewers(actionItem, [user.login])
|
|
}
|
|
>
|
|
<View style={styles.issueTypeChipContent}>
|
|
{selected ? <Check size={12} color={colors.accentBlue} /> : null}
|
|
<Text style={styles.detailChipText}>{user.login}</Text>
|
|
</View>
|
|
</Pressable>
|
|
)
|
|
})}
|
|
</View>
|
|
)}
|
|
<TextInput
|
|
style={styles.input}
|
|
value={itemReviewersDraft}
|
|
onChangeText={setItemReviewersDraft}
|
|
placeholder="Request reviewers"
|
|
placeholderTextColor={colors.textMuted}
|
|
autoCapitalize="none"
|
|
/>
|
|
<Pressable
|
|
style={styles.inlineSaveButton}
|
|
disabled={
|
|
mutatingStatus || splitReviewerList(itemReviewersDraft).length === 0
|
|
}
|
|
onPress={() => void requestGitHubReviewers(actionItem)}
|
|
>
|
|
<Text style={styles.inlineSaveText}>Request review</Text>
|
|
</Pressable>
|
|
</View>
|
|
) : null}
|
|
|
|
{SHOW_MOBILE_DETAIL_REVIEW_PANELS &&
|
|
detailPayload.provider === 'github' &&
|
|
actionItem.provider === 'github' &&
|
|
actionItem.source.type === 'pr' ? (
|
|
<View style={styles.detailSection}>
|
|
<View style={styles.detailSectionHeader}>
|
|
<Text style={styles.detailSectionTitle}>Checks</Text>
|
|
<View style={styles.inlineActionRow}>
|
|
<Pressable
|
|
style={styles.inlineSaveButtonCompact}
|
|
disabled={mutatingStatus}
|
|
onPress={() => void refreshGitHubChecks(actionItem)}
|
|
>
|
|
<Text style={styles.inlineSaveText}>Refresh</Text>
|
|
</Pressable>
|
|
<Pressable
|
|
style={styles.inlineSaveButtonCompact}
|
|
disabled={
|
|
mutatingStatus || !detailPayload.checks.some(isFailedGitHubCheck)
|
|
}
|
|
onPress={() => void rerunGitHubChecks(actionItem, true)}
|
|
>
|
|
<Text style={styles.inlineSaveText}>Rerun failed</Text>
|
|
</Pressable>
|
|
<Pressable
|
|
style={styles.inlineSaveButtonCompact}
|
|
disabled={mutatingStatus || detailPayload.checks.length === 0}
|
|
onPress={() => void rerunGitHubChecks(actionItem, false)}
|
|
>
|
|
<Text style={styles.inlineSaveText}>Rerun all</Text>
|
|
</Pressable>
|
|
</View>
|
|
</View>
|
|
{detailPayload.checks.length === 0 ? (
|
|
<Text style={styles.detailMuted}>No checks found.</Text>
|
|
) : (
|
|
detailPayload.checks.map((check) => (
|
|
<Pressable
|
|
key={`${check.name}:${check.status}:${check.url ?? ''}`}
|
|
style={styles.fileActionRow}
|
|
disabled={!check.url}
|
|
onPress={() => {
|
|
if (check.url) {
|
|
void Linking.openURL(check.url)
|
|
}
|
|
}}
|
|
>
|
|
<Text style={styles.detailLine} numberOfLines={2}>
|
|
{check.name} · {check.conclusion ?? check.status}
|
|
</Text>
|
|
{check.url ? (
|
|
<ExternalLink size={14} color={colors.textSecondary} />
|
|
) : null}
|
|
</Pressable>
|
|
))
|
|
)}
|
|
</View>
|
|
) : null}
|
|
|
|
{SHOW_MOBILE_DETAIL_REVIEW_PANELS &&
|
|
detailPayload.provider === 'github' &&
|
|
detailPayload.files.length > 0 ? (
|
|
<View style={styles.detailSection}>
|
|
<Text style={styles.detailSectionTitle}>Changed files</Text>
|
|
{detailPayload.files.map((file) =>
|
|
actionItem.provider === 'github' && actionItem.source.type === 'pr' ? (
|
|
<View key={file.path} style={styles.fileCard}>
|
|
<Pressable
|
|
style={styles.fileActionRow}
|
|
disabled={mutatingStatus}
|
|
onPress={() => void toggleGitHubFileExpansion(actionItem, file)}
|
|
>
|
|
<Text style={styles.detailLine}>
|
|
{file.path}
|
|
{typeof file.additions === 'number' ||
|
|
typeof file.deletions === 'number'
|
|
? ` · +${file.additions ?? 0} -${file.deletions ?? 0}`
|
|
: ''}
|
|
</Text>
|
|
<Text style={styles.detailSectionMeta}>
|
|
{expandedPrFilePath === file.path ? 'Hide' : 'View'}
|
|
</Text>
|
|
</Pressable>
|
|
<Pressable
|
|
style={styles.inlineSaveButtonCompact}
|
|
disabled={mutatingStatus || !detailPayload.pullRequestId}
|
|
onPress={() => void toggleGitHubFileViewed(actionItem, file)}
|
|
>
|
|
<Text style={styles.inlineSaveText}>
|
|
{file.viewerViewedState === 'VIEWED'
|
|
? 'Mark unviewed'
|
|
: 'Mark viewed'}
|
|
</Text>
|
|
</Pressable>
|
|
{expandedPrFilePath === file.path ? (
|
|
<View style={styles.filePreview}>
|
|
{prFileLoadingPath === file.path ? (
|
|
<ActivityIndicator size="small" color={colors.textSecondary} />
|
|
) : prFileContents[file.path]?.originalIsBinary ||
|
|
prFileContents[file.path]?.modifiedIsBinary ? (
|
|
<Text style={styles.detailMuted}>Binary file.</Text>
|
|
) : prFileContents[file.path] ? (
|
|
<GitHubPrFileDiff
|
|
filePath={file.path}
|
|
contents={prFileContents[file.path]}
|
|
commentDrafts={prFileCommentDrafts}
|
|
disabled={mutatingStatus}
|
|
onCommentDraftChange={(draftKey, next) =>
|
|
setPrFileCommentDrafts((current) => ({
|
|
...current,
|
|
[draftKey]: next
|
|
}))
|
|
}
|
|
onSubmitComment={(line) =>
|
|
void addGitHubFileReviewComment(actionItem, file, line)
|
|
}
|
|
/>
|
|
) : (
|
|
<Text style={styles.detailMuted}>File contents unavailable.</Text>
|
|
)}
|
|
</View>
|
|
) : null}
|
|
</View>
|
|
) : (
|
|
<Text key={file.path} style={styles.detailLine}>
|
|
{file.path}
|
|
{typeof file.additions === 'number' ||
|
|
typeof file.deletions === 'number'
|
|
? ` · +${file.additions ?? 0} -${file.deletions ?? 0}`
|
|
: ''}
|
|
</Text>
|
|
)
|
|
)}
|
|
</View>
|
|
) : null}
|
|
|
|
{SHOW_MOBILE_DETAIL_REVIEW_PANELS &&
|
|
detailPayload.provider === 'gitlab' &&
|
|
actionItem.provider === 'gitlab' &&
|
|
actionItem.source.type === 'mr' ? (
|
|
<View style={styles.detailSection}>
|
|
<View style={styles.detailSectionHeader}>
|
|
<Text style={styles.detailSectionTitle}>Pipeline</Text>
|
|
<Text style={styles.detailSectionMeta}>
|
|
{detailPayload.pipelineJobs.length
|
|
? `${detailPayload.pipelineJobs.length} jobs`
|
|
: 'None'}
|
|
</Text>
|
|
</View>
|
|
{detailPayload.pipelineJobs.length === 0 ? (
|
|
<Text style={styles.detailMuted}>No pipeline runs for this MR.</Text>
|
|
) : (
|
|
detailPayload.pipelineJobs.map((job) => {
|
|
const duration = formatDurationSeconds(job.duration)
|
|
return (
|
|
<Pressable
|
|
key={`${job.id ?? job.stage}:${job.name}`}
|
|
style={styles.fileCard}
|
|
disabled={!job.webUrl}
|
|
onPress={() => {
|
|
if (job.webUrl) {
|
|
void Linking.openURL(job.webUrl)
|
|
}
|
|
}}
|
|
>
|
|
<View style={styles.fileActionRow}>
|
|
<Text style={styles.detailLine} numberOfLines={2}>
|
|
{job.name}
|
|
</Text>
|
|
<View
|
|
style={[
|
|
styles.pipelineStatusChip,
|
|
getGitLabPipelineStatusStyle(job.status)
|
|
]}
|
|
>
|
|
<Text style={styles.pipelineStatusText}>{job.status}</Text>
|
|
</View>
|
|
</View>
|
|
<Text style={styles.detailMuted}>
|
|
{[job.stage, duration].filter(Boolean).join(' · ')}
|
|
</Text>
|
|
</Pressable>
|
|
)
|
|
})
|
|
)}
|
|
</View>
|
|
) : null}
|
|
|
|
{SHOW_MOBILE_LINEAR_DETAIL_TOOLS &&
|
|
detailPayload.provider === 'linear' &&
|
|
actionItem.provider === 'linear' ? (
|
|
<View style={styles.detailSection}>
|
|
<View style={styles.detailSectionHeader}>
|
|
<Text style={styles.detailSectionTitle}>Sub-issues</Text>
|
|
<Text style={styles.detailSectionMeta}>
|
|
{detailPayload.children.length || 'None'}
|
|
</Text>
|
|
</View>
|
|
{detailPayload.children.length === 0 ? (
|
|
<Text style={styles.detailMuted}>No sub-issues.</Text>
|
|
) : (
|
|
detailPayload.children.map((child) => (
|
|
<Pressable
|
|
key={child.id}
|
|
style={styles.fileActionRow}
|
|
disabled={mutatingStatus}
|
|
onPress={() =>
|
|
void openLinearSubIssue(child, actionItem.source.workspaceId)
|
|
}
|
|
>
|
|
<Text style={styles.detailLine}>
|
|
{child.identifier} · {child.title}
|
|
</Text>
|
|
<Text style={styles.detailSectionMeta}>Open</Text>
|
|
</Pressable>
|
|
))
|
|
)}
|
|
<TextInput
|
|
style={[styles.input, styles.stackedInput]}
|
|
value={linearSubIssueTitle}
|
|
onChangeText={setLinearSubIssueTitle}
|
|
placeholder="Sub-issue title"
|
|
placeholderTextColor={colors.textMuted}
|
|
/>
|
|
<Pressable
|
|
style={styles.inlineSaveButton}
|
|
disabled={mutatingStatus || linearSubIssueTitle.trim().length === 0}
|
|
onPress={() => void createLinearSubIssue(actionItem)}
|
|
>
|
|
<Text style={styles.inlineSaveText}>Add sub-issue</Text>
|
|
</Pressable>
|
|
</View>
|
|
) : null}
|
|
|
|
<View style={styles.detailSection}>
|
|
<View style={styles.detailSectionHeader}>
|
|
<Text style={styles.detailSectionTitle}>Discussion</Text>
|
|
<Text style={styles.detailSectionMeta}>
|
|
{discussionSummary(detailPayload.comments.length)}
|
|
</Text>
|
|
</View>
|
|
{detailPayload.comments.length === 0 ? (
|
|
<Text style={styles.detailMuted}>No comments.</Text>
|
|
) : (
|
|
detailCommentGroups.map(renderDetailCommentGroup)
|
|
)}
|
|
{(detailPayload.provider === 'github' && actionItem.provider === 'github') ||
|
|
(detailPayload.provider === 'gitlab' && actionItem.provider === 'gitlab')
|
|
? renderCommentComposer({
|
|
value: itemCommentDraft,
|
|
onChangeText: setItemCommentDraft,
|
|
disabled: mutatingStatus,
|
|
onSubmit: () => void addHostedItemComment(actionItem)
|
|
})
|
|
: null}
|
|
{detailPayload.provider === 'linear' && actionItem.provider === 'linear'
|
|
? renderCommentComposer({
|
|
value: linearCommentDraft,
|
|
onChangeText: setLinearCommentDraft,
|
|
disabled: mutatingStatus,
|
|
onSubmit: () => void addLinearComment(actionItem)
|
|
})
|
|
: null}
|
|
</View>
|
|
</>
|
|
) : null}
|
|
</View>
|
|
|
|
<View style={styles.actionGroup}>
|
|
<Pressable
|
|
style={styles.actionRow}
|
|
disabled={creatingKey === actionItem.key}
|
|
onPress={() => {
|
|
if (actionItem.provider === 'linear' && workspaceRepos.length > 1) {
|
|
setWorkspaceRepoPickerItem(actionItem)
|
|
return
|
|
}
|
|
openWorkspaceCreate(actionItem)
|
|
}}
|
|
>
|
|
<Plus size={16} color={colors.textPrimary} />
|
|
<Text style={styles.actionText}>
|
|
{creatingKey === actionItem.key ? 'Creating...' : 'Create Workspace'}
|
|
</Text>
|
|
</Pressable>
|
|
|
|
<View style={styles.actionSeparator} />
|
|
<Pressable
|
|
style={styles.actionRow}
|
|
onPress={() => void Linking.openURL(actionItem.source.url)}
|
|
>
|
|
<ExternalLink size={16} color={colors.textPrimary} />
|
|
<Text style={styles.actionText}>{taskExternalOpenLabel(actionItem)}</Text>
|
|
</Pressable>
|
|
|
|
{actionItem.provider === 'linear' ? (
|
|
<>
|
|
<View style={styles.actionSeparator} />
|
|
<Pressable
|
|
style={styles.actionRow}
|
|
onPress={() =>
|
|
void copyTextToClipboard(
|
|
`linear-url:${actionItem.key}`,
|
|
actionItem.source.url
|
|
)
|
|
}
|
|
>
|
|
<Copy size={16} color={colors.textPrimary} />
|
|
<Text style={styles.actionText}>
|
|
{copiedLinkKey === `linear-url:${actionItem.key}`
|
|
? 'Copied'
|
|
: 'Copy Linear link'}
|
|
</Text>
|
|
</Pressable>
|
|
</>
|
|
) : null}
|
|
|
|
{actionItem.provider === 'github' ? (
|
|
<>
|
|
<View style={styles.actionSeparator} />
|
|
<Pressable
|
|
style={styles.actionRow}
|
|
onPress={() =>
|
|
void copyTaskLink(`task:${actionItem.key}`, actionItem.source.url)
|
|
}
|
|
>
|
|
<Copy size={16} color={colors.textPrimary} />
|
|
<Text style={styles.actionText}>
|
|
{copiedLinkKey === `task:${actionItem.key}` ? 'Copied' : 'Copy GitHub link'}
|
|
</Text>
|
|
</Pressable>
|
|
</>
|
|
) : null}
|
|
|
|
{actionItem.provider === 'github' && actionItem.source.state !== 'merged' ? (
|
|
<>
|
|
<View style={styles.actionSeparator} />
|
|
<Pressable
|
|
style={styles.actionRow}
|
|
disabled={mutatingStatus}
|
|
onPress={() => {
|
|
const githubItem = actionItem as Extract<TaskItem, { provider: 'github' }>
|
|
setPendingHostedStateChange({
|
|
source: 'task',
|
|
item: githubItem,
|
|
nextState: githubItem.source.state === 'closed' ? 'open' : 'closed'
|
|
})
|
|
}}
|
|
>
|
|
{actionItem.source.state === 'closed' ? (
|
|
<RefreshCw size={16} color={colors.textPrimary} />
|
|
) : (
|
|
<X size={16} color={colors.textPrimary} />
|
|
)}
|
|
<Text style={styles.actionText}>{taskStatusActionLabel(actionItem)}</Text>
|
|
</Pressable>
|
|
</>
|
|
) : null}
|
|
|
|
{actionItem.provider === 'github' &&
|
|
actionItem.source.type === 'pr' &&
|
|
actionItem.source.state === 'open' ? (
|
|
<>
|
|
<View style={styles.actionSeparator} />
|
|
<Pressable
|
|
style={styles.actionRow}
|
|
disabled={mutatingStatus || isGitHubPrMergeBlocked(actionItem)}
|
|
onPress={() =>
|
|
setMergeMethodTaskItem(
|
|
actionItem as Extract<TaskItem, { provider: 'github' }>
|
|
)
|
|
}
|
|
>
|
|
<GitBranch size={16} color={colors.textPrimary} />
|
|
<Text style={styles.actionText}>Merge pull request</Text>
|
|
</Pressable>
|
|
{isGitHubPrMergeBlocked(actionItem) ? (
|
|
<Text style={styles.emptyInlineText}>GitHub reports merge conflicts.</Text>
|
|
) : null}
|
|
</>
|
|
) : null}
|
|
|
|
{actionItem.provider === 'gitlab' &&
|
|
actionItem.source.state !== 'merged' &&
|
|
actionItem.source.state !== 'locked' ? (
|
|
<>
|
|
<View style={styles.actionSeparator} />
|
|
<Pressable
|
|
style={styles.actionRow}
|
|
disabled={mutatingStatus}
|
|
onPress={() => {
|
|
const gitlabItem = actionItem as Extract<TaskItem, { provider: 'gitlab' }>
|
|
setPendingHostedStateChange({
|
|
source: 'task',
|
|
item: gitlabItem,
|
|
nextState: gitlabItem.source.state === 'closed' ? 'opened' : 'closed'
|
|
})
|
|
}}
|
|
>
|
|
{actionItem.source.state === 'closed' ? (
|
|
<RefreshCw size={16} color={colors.textPrimary} />
|
|
) : (
|
|
<X size={16} color={colors.textPrimary} />
|
|
)}
|
|
<Text style={styles.actionText}>{taskStatusActionLabel(actionItem)}</Text>
|
|
</Pressable>
|
|
</>
|
|
) : null}
|
|
|
|
{actionItem.provider === 'gitlab' &&
|
|
actionItem.source.type === 'mr' &&
|
|
actionItem.source.state === 'opened' ? (
|
|
<>
|
|
<View style={styles.actionSeparator} />
|
|
<Pressable
|
|
style={styles.actionRow}
|
|
disabled={mutatingStatus}
|
|
onPress={() =>
|
|
setMergeMethodTaskItem(
|
|
actionItem as Extract<TaskItem, { provider: 'gitlab' }>
|
|
)
|
|
}
|
|
>
|
|
<GitBranch size={16} color={colors.textPrimary} />
|
|
<Text style={styles.actionText}>Merge merge request</Text>
|
|
</Pressable>
|
|
</>
|
|
) : null}
|
|
|
|
{actionItem.provider === 'linear' ? (
|
|
<>
|
|
<View style={styles.actionSeparator} />
|
|
<Pressable
|
|
style={styles.actionRow}
|
|
disabled={mutatingStatus}
|
|
onPress={() => {
|
|
setLinearStatusPickerItem(
|
|
actionItem as Extract<TaskItem, { provider: 'linear' }>
|
|
)
|
|
}}
|
|
>
|
|
<GitBranch size={16} color={colors.textPrimary} />
|
|
<Text style={styles.actionText}>Change status</Text>
|
|
</Pressable>
|
|
</>
|
|
) : null}
|
|
</View>
|
|
</View>
|
|
) : null}
|
|
</BottomDrawer>
|
|
|
|
<ActionSheetModal
|
|
visible={taskUiReady && mergeMethodProjectRow != null}
|
|
title="Merge method"
|
|
message="Choose how this pull request should be merged."
|
|
actions={
|
|
mergeMethodProjectRow
|
|
? (['squash', 'merge', 'rebase'] as const).map((method) => ({
|
|
label: getHostedReviewMergeMethodLabel(method),
|
|
icon: GitBranch,
|
|
onPress: () => {
|
|
setPendingProjectGitHubMerge({ row: mergeMethodProjectRow, method })
|
|
}
|
|
}))
|
|
: []
|
|
}
|
|
onClose={() => setMergeMethodProjectRow(null)}
|
|
/>
|
|
<ActionSheetModal
|
|
visible={taskUiReady && mergeMethodTaskItem != null}
|
|
title="Merge method"
|
|
message={
|
|
mergeMethodTaskItem?.provider === 'gitlab'
|
|
? 'Choose how this merge request should be merged.'
|
|
: 'Choose how this pull request should be merged.'
|
|
}
|
|
actions={
|
|
mergeMethodTaskItem
|
|
? (mergeMethodTaskItem.provider === 'gitlab'
|
|
? (['merge', 'squash', 'rebase'] as const)
|
|
: (['squash', 'merge', 'rebase'] as const)
|
|
).map((method) => ({
|
|
label:
|
|
mergeMethodTaskItem.provider === 'gitlab' && method === 'merge'
|
|
? 'Merge'
|
|
: getHostedReviewMergeMethodLabel(method),
|
|
icon: GitBranch,
|
|
onPress: () => {
|
|
const item = mergeMethodTaskItem
|
|
setPendingHostedMerge({ item, method })
|
|
}
|
|
}))
|
|
: []
|
|
}
|
|
onClose={() => setMergeMethodTaskItem(null)}
|
|
/>
|
|
<ConfirmModal
|
|
visible={taskUiReady && pendingHostedMerge != null}
|
|
title={
|
|
pendingHostedMerge?.item.provider === 'gitlab' ? 'Merge Request' : 'Merge Pull Request'
|
|
}
|
|
message={pendingHostedMerge ? getHostedMergeConfirmMessage(pendingHostedMerge) : undefined}
|
|
confirmLabel={
|
|
pendingHostedMerge ? getHostedReviewMergeMethodLabel(pendingHostedMerge.method) : 'Merge'
|
|
}
|
|
onConfirm={() => {
|
|
if (!taskUiReady || !pendingHostedMerge) {
|
|
return
|
|
}
|
|
void mergeHostedReview(pendingHostedMerge.item, pendingHostedMerge.method)
|
|
}}
|
|
onCancel={() => setPendingHostedMerge(null)}
|
|
/>
|
|
<ConfirmModal
|
|
visible={taskUiReady && pendingProjectGitHubMerge != null}
|
|
title="Merge Pull Request"
|
|
message={
|
|
pendingProjectGitHubMerge
|
|
? getProjectGitHubMergeConfirmMessage(pendingProjectGitHubMerge)
|
|
: undefined
|
|
}
|
|
confirmLabel={
|
|
pendingProjectGitHubMerge
|
|
? getHostedReviewMergeMethodLabel(pendingProjectGitHubMerge.method)
|
|
: 'Merge'
|
|
}
|
|
onConfirm={() => {
|
|
if (!taskUiReady || !pendingProjectGitHubMerge) {
|
|
return
|
|
}
|
|
void mergeProjectGitHubPullRequest(
|
|
pendingProjectGitHubMerge.row,
|
|
pendingProjectGitHubMerge.method
|
|
)
|
|
}}
|
|
onCancel={() => setPendingProjectGitHubMerge(null)}
|
|
/>
|
|
<ConfirmModal
|
|
visible={taskUiReady && pendingHostedStateChange != null}
|
|
title={
|
|
pendingHostedStateChange
|
|
? getHostedStateConfirmTitle(pendingHostedStateChange)
|
|
: 'Update Item'
|
|
}
|
|
message={
|
|
pendingHostedStateChange
|
|
? getHostedStateConfirmMessage(pendingHostedStateChange)
|
|
: undefined
|
|
}
|
|
confirmLabel={
|
|
pendingHostedStateChange
|
|
? getHostedStateConfirmLabel(pendingHostedStateChange)
|
|
: 'Confirm'
|
|
}
|
|
destructive={pendingHostedStateChange?.nextState === 'closed'}
|
|
onConfirm={() => {
|
|
if (!taskUiReady || !pendingHostedStateChange) {
|
|
return
|
|
}
|
|
if (pendingHostedStateChange.source === 'task') {
|
|
if (pendingHostedStateChange.item.provider === 'gitlab') {
|
|
void toggleGitLabStatus(pendingHostedStateChange.item)
|
|
return
|
|
}
|
|
void toggleGitHubStatus(pendingHostedStateChange.item)
|
|
return
|
|
}
|
|
void mutateProjectRowIssueOrPr(pendingHostedStateChange.row, {
|
|
state: pendingHostedStateChange.nextState
|
|
})
|
|
}}
|
|
onCancel={() => setPendingHostedStateChange(null)}
|
|
/>
|
|
</SafeAreaView>
|
|
)
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
container: {
|
|
flex: 1,
|
|
backgroundColor: colors.bgBase
|
|
},
|
|
topChrome: {
|
|
backgroundColor: colors.bgPanel,
|
|
borderBottomWidth: 1,
|
|
borderBottomColor: colors.borderSubtle
|
|
},
|
|
statusBar: {
|
|
minHeight: 38,
|
|
paddingTop: spacing.xs,
|
|
paddingHorizontal: spacing.lg,
|
|
flexDirection: 'row',
|
|
alignItems: 'center'
|
|
},
|
|
backButton: {
|
|
width: 32,
|
|
height: 32,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
marginRight: spacing.xs
|
|
},
|
|
titleWrap: {
|
|
flex: 1,
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
minWidth: 0
|
|
},
|
|
title: {
|
|
fontSize: 16,
|
|
fontWeight: '700',
|
|
color: colors.textPrimary
|
|
},
|
|
iconButton: {
|
|
width: 32,
|
|
height: 32,
|
|
alignItems: 'center',
|
|
justifyContent: 'center'
|
|
},
|
|
toolbar: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: spacing.sm,
|
|
paddingHorizontal: spacing.md,
|
|
paddingVertical: spacing.xs + 2
|
|
},
|
|
toolbarScroll: {
|
|
borderTopWidth: 1,
|
|
borderTopColor: colors.borderSubtle
|
|
},
|
|
segmentButton: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: spacing.xs,
|
|
borderWidth: 1,
|
|
borderColor: colors.borderSubtle,
|
|
borderRadius: radii.button,
|
|
paddingHorizontal: spacing.sm + 2,
|
|
paddingVertical: spacing.xs
|
|
},
|
|
segmentIconButton: {
|
|
width: 32,
|
|
height: 30,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
borderWidth: 1,
|
|
borderColor: colors.borderSubtle,
|
|
borderRadius: radii.button
|
|
},
|
|
segmentCountPill: {
|
|
minWidth: 32,
|
|
height: 30,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
borderWidth: 1,
|
|
borderColor: colors.borderSubtle,
|
|
borderRadius: radii.button,
|
|
paddingHorizontal: spacing.sm
|
|
},
|
|
segmentRepoDot: {
|
|
width: 8,
|
|
height: 8,
|
|
borderRadius: 4
|
|
},
|
|
segmentButtonText: {
|
|
fontSize: 12,
|
|
fontWeight: '600',
|
|
color: colors.textPrimary
|
|
},
|
|
segmentSecondaryText: {
|
|
fontSize: 12,
|
|
color: colors.textSecondary
|
|
},
|
|
searchBar: {
|
|
paddingHorizontal: spacing.md,
|
|
paddingVertical: spacing.sm,
|
|
borderTopWidth: StyleSheet.hairlineWidth,
|
|
borderTopColor: colors.borderSubtle
|
|
},
|
|
errorBanner: {
|
|
paddingHorizontal: spacing.lg,
|
|
paddingVertical: spacing.sm,
|
|
backgroundColor: colors.bgPanel,
|
|
borderBottomWidth: 1,
|
|
borderBottomColor: colors.borderSubtle
|
|
},
|
|
errorText: {
|
|
color: colors.statusRed,
|
|
fontSize: 13
|
|
},
|
|
sourceErrorBanner: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: spacing.sm,
|
|
paddingHorizontal: spacing.lg,
|
|
paddingVertical: spacing.sm,
|
|
backgroundColor: colors.bgPanel,
|
|
borderBottomWidth: 1,
|
|
borderBottomColor: colors.borderSubtle
|
|
},
|
|
sourceErrorCopy: {
|
|
flex: 1,
|
|
minWidth: 0
|
|
},
|
|
sourceErrorText: {
|
|
color: colors.statusAmber,
|
|
fontSize: 13,
|
|
fontWeight: '600'
|
|
},
|
|
sourceErrorSlug: {
|
|
fontFamily: typography.monoFamily,
|
|
color: colors.textPrimary
|
|
},
|
|
sourceErrorMessage: {
|
|
marginTop: 2,
|
|
color: colors.textSecondary,
|
|
fontSize: 12
|
|
},
|
|
sourceErrorRetry: {
|
|
borderWidth: 1,
|
|
borderColor: colors.borderSubtle,
|
|
borderRadius: radii.button,
|
|
paddingHorizontal: spacing.sm,
|
|
paddingVertical: spacing.xs
|
|
},
|
|
sourceErrorRetryText: {
|
|
color: colors.textPrimary,
|
|
fontSize: 12,
|
|
fontWeight: '600'
|
|
},
|
|
sourceNoticeBanner: {
|
|
paddingHorizontal: spacing.lg,
|
|
paddingVertical: spacing.sm,
|
|
backgroundColor: colors.bgPanel,
|
|
borderBottomWidth: 1,
|
|
borderBottomColor: colors.borderSubtle
|
|
},
|
|
sourceNoticeText: {
|
|
color: colors.statusAmber,
|
|
fontSize: 13
|
|
},
|
|
centered: {
|
|
flex: 1,
|
|
alignItems: 'center',
|
|
justifyContent: 'center'
|
|
},
|
|
emptyText: {
|
|
color: colors.textSecondary,
|
|
fontSize: typography.bodySize
|
|
},
|
|
centeredHint: {
|
|
color: colors.textMuted,
|
|
fontSize: 12,
|
|
lineHeight: 17,
|
|
marginTop: spacing.sm,
|
|
maxWidth: 280,
|
|
textAlign: 'center'
|
|
},
|
|
centerActionButton: {
|
|
marginTop: spacing.md,
|
|
minWidth: 160
|
|
},
|
|
list: {
|
|
paddingTop: spacing.xs
|
|
},
|
|
repoSectionHeader: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: spacing.xs,
|
|
paddingHorizontal: spacing.lg,
|
|
paddingTop: spacing.md,
|
|
paddingBottom: spacing.xs,
|
|
backgroundColor: colors.bgBase
|
|
},
|
|
repoSectionDot: {
|
|
width: 8,
|
|
height: 8,
|
|
borderRadius: 4
|
|
},
|
|
repoSectionTitle: {
|
|
flex: 1,
|
|
minWidth: 0,
|
|
fontSize: 11,
|
|
fontWeight: '600',
|
|
color: colors.textMuted,
|
|
textTransform: 'uppercase',
|
|
letterSpacing: 0.5
|
|
},
|
|
separator: {
|
|
height: 1,
|
|
backgroundColor: colors.borderSubtle,
|
|
marginLeft: spacing.lg + 26,
|
|
marginRight: spacing.lg
|
|
},
|
|
taskRow: {
|
|
flexDirection: 'row',
|
|
alignItems: 'flex-start',
|
|
paddingHorizontal: spacing.lg,
|
|
paddingVertical: spacing.sm + 2
|
|
},
|
|
taskRowPressed: {
|
|
backgroundColor: colors.bgRaised
|
|
},
|
|
taskIcon: {
|
|
width: 20,
|
|
paddingTop: 3,
|
|
marginRight: spacing.sm,
|
|
alignItems: 'center'
|
|
},
|
|
taskMain: {
|
|
flex: 1,
|
|
minWidth: 0,
|
|
marginRight: spacing.sm
|
|
},
|
|
taskTitleRow: {
|
|
flexDirection: 'row',
|
|
gap: spacing.sm,
|
|
alignItems: 'flex-start'
|
|
},
|
|
taskTitle: {
|
|
flex: 1,
|
|
fontSize: 14,
|
|
fontWeight: '600',
|
|
color: colors.textPrimary,
|
|
lineHeight: 18
|
|
},
|
|
updatedAt: {
|
|
fontSize: 11,
|
|
color: colors.textMuted,
|
|
paddingTop: 2
|
|
},
|
|
metaRow: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
marginTop: 4,
|
|
gap: spacing.xs
|
|
},
|
|
repoDot: {
|
|
width: 7,
|
|
height: 7,
|
|
borderRadius: 4
|
|
},
|
|
pickerRepoDot: {
|
|
width: 9,
|
|
height: 9,
|
|
borderRadius: 4.5
|
|
},
|
|
subtitle: {
|
|
flex: 1,
|
|
fontSize: 11,
|
|
color: colors.textSecondary
|
|
},
|
|
branchMetaRow: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: spacing.xs,
|
|
marginTop: 3,
|
|
minWidth: 0
|
|
},
|
|
branchMetaText: {
|
|
flexShrink: 1,
|
|
minWidth: 0,
|
|
maxWidth: 180,
|
|
fontSize: 11,
|
|
color: colors.textPrimary
|
|
},
|
|
branchMetaBase: {
|
|
flexShrink: 1,
|
|
minWidth: 0,
|
|
fontSize: 10,
|
|
color: colors.textMuted
|
|
},
|
|
prSignalRow: {
|
|
flexDirection: 'row',
|
|
flexWrap: 'wrap',
|
|
gap: spacing.xs,
|
|
marginTop: spacing.xs + 1
|
|
},
|
|
prSignalChip: {
|
|
alignSelf: 'flex-start',
|
|
borderWidth: 1,
|
|
borderColor: colors.borderSubtle,
|
|
borderRadius: radii.button,
|
|
backgroundColor: colors.bgPanel,
|
|
paddingHorizontal: spacing.xs + 2,
|
|
paddingVertical: 2
|
|
},
|
|
prSignalSuccess: {
|
|
borderColor: colors.statusGreen
|
|
},
|
|
prSignalWarning: {
|
|
borderColor: colors.statusAmber
|
|
},
|
|
prSignalDanger: {
|
|
borderColor: colors.statusRed
|
|
},
|
|
prSignalText: {
|
|
fontSize: 10,
|
|
color: colors.textSecondary,
|
|
fontWeight: '600'
|
|
},
|
|
statusPill: {
|
|
maxWidth: 112,
|
|
backgroundColor: colors.bgRaised,
|
|
borderRadius: 999,
|
|
paddingHorizontal: spacing.sm,
|
|
paddingVertical: 3,
|
|
borderWidth: 1,
|
|
borderColor: colors.borderSubtle
|
|
},
|
|
statusPillSelf: {
|
|
alignSelf: 'flex-start',
|
|
backgroundColor: colors.bgRaised,
|
|
borderRadius: 999,
|
|
paddingHorizontal: spacing.sm,
|
|
paddingVertical: 3,
|
|
borderWidth: 1,
|
|
borderColor: colors.borderSubtle,
|
|
marginTop: spacing.sm
|
|
},
|
|
linearStatePill: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: spacing.xs
|
|
},
|
|
linearStateDot: {
|
|
width: 7,
|
|
height: 7,
|
|
borderRadius: 4
|
|
},
|
|
linearListTrailing: {
|
|
alignItems: 'flex-end',
|
|
gap: spacing.xs
|
|
},
|
|
taskRowTrailing: {
|
|
alignItems: 'flex-end',
|
|
gap: spacing.xs
|
|
},
|
|
statusText: {
|
|
fontSize: 11,
|
|
color: colors.textSecondary
|
|
},
|
|
statusTextFlex: {
|
|
flex: 1,
|
|
minWidth: 0
|
|
},
|
|
paginationFooter: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
gap: spacing.sm,
|
|
paddingHorizontal: spacing.lg,
|
|
paddingTop: spacing.md,
|
|
paddingBottom: spacing.sm
|
|
},
|
|
paginationButton: {
|
|
width: 44,
|
|
minHeight: 38,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
borderWidth: 1,
|
|
borderColor: colors.borderSubtle,
|
|
borderRadius: radii.button,
|
|
backgroundColor: colors.bgRaised,
|
|
paddingVertical: spacing.sm
|
|
},
|
|
paginationButtonDisabled: {
|
|
opacity: 0.45
|
|
},
|
|
paginationLabel: {
|
|
color: colors.textSecondary,
|
|
fontSize: 12,
|
|
textAlign: 'center'
|
|
},
|
|
paginationLabelButton: {
|
|
flex: 1,
|
|
alignItems: 'center',
|
|
borderRadius: radii.button,
|
|
paddingHorizontal: spacing.xs,
|
|
paddingVertical: spacing.sm
|
|
},
|
|
boardContainer: {
|
|
gap: spacing.md,
|
|
padding: spacing.md
|
|
},
|
|
boardColumn: {
|
|
width: 280,
|
|
maxHeight: '100%',
|
|
borderWidth: 1,
|
|
borderColor: colors.borderSubtle,
|
|
borderRadius: radii.card,
|
|
backgroundColor: colors.bgPanel,
|
|
overflow: 'hidden'
|
|
},
|
|
boardHeader: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: spacing.xs,
|
|
paddingHorizontal: spacing.md,
|
|
paddingVertical: spacing.sm,
|
|
borderBottomWidth: StyleSheet.hairlineWidth,
|
|
borderBottomColor: colors.borderSubtle
|
|
},
|
|
boardTitle: {
|
|
flex: 1,
|
|
color: colors.textPrimary,
|
|
fontSize: 13,
|
|
fontWeight: '600'
|
|
},
|
|
boardCount: {
|
|
color: colors.textMuted,
|
|
fontSize: 11
|
|
},
|
|
boardCard: {
|
|
margin: spacing.sm,
|
|
marginBottom: 0,
|
|
borderRadius: radii.card,
|
|
borderWidth: 1,
|
|
borderColor: colors.borderSubtle,
|
|
backgroundColor: colors.bgBase,
|
|
padding: spacing.md
|
|
},
|
|
repoPickerGroup: {
|
|
backgroundColor: colors.bgPanel,
|
|
borderRadius: 12,
|
|
overflow: 'hidden'
|
|
},
|
|
pagePickerList: {
|
|
maxHeight: 420,
|
|
backgroundColor: colors.bgPanel,
|
|
borderRadius: 12
|
|
},
|
|
projectPickerControls: {
|
|
gap: spacing.sm,
|
|
marginBottom: spacing.md
|
|
},
|
|
projectWarningBanner: {
|
|
flexDirection: 'row',
|
|
alignItems: 'flex-start',
|
|
gap: spacing.sm,
|
|
paddingHorizontal: spacing.md,
|
|
paddingVertical: spacing.sm,
|
|
marginBottom: spacing.md,
|
|
borderWidth: 1,
|
|
borderColor: colors.statusAmber,
|
|
borderRadius: radii.card,
|
|
backgroundColor: colors.bgPanel
|
|
},
|
|
projectWarningTextWrap: {
|
|
flex: 1,
|
|
minWidth: 0
|
|
},
|
|
projectWarningTitle: {
|
|
color: colors.textPrimary,
|
|
fontSize: 12,
|
|
fontWeight: '600'
|
|
},
|
|
projectWarningText: {
|
|
color: colors.textSecondary,
|
|
fontSize: 11,
|
|
marginTop: 2
|
|
},
|
|
projectDataNotice: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: spacing.sm,
|
|
paddingHorizontal: spacing.lg,
|
|
paddingVertical: spacing.sm,
|
|
backgroundColor: colors.bgPanel,
|
|
borderBottomWidth: 1,
|
|
borderBottomColor: colors.borderSubtle
|
|
},
|
|
projectDataNoticeText: {
|
|
flex: 1,
|
|
color: colors.statusAmber,
|
|
fontSize: 13
|
|
},
|
|
projectGroupHeader: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: spacing.xs,
|
|
paddingHorizontal: spacing.lg,
|
|
paddingVertical: spacing.sm,
|
|
backgroundColor: colors.bgPanel
|
|
},
|
|
projectGroupChevronCollapsed: {
|
|
transform: [{ rotate: '-90deg' }]
|
|
},
|
|
projectGroupTitle: {
|
|
flex: 1,
|
|
minWidth: 0,
|
|
color: colors.textPrimary,
|
|
fontSize: 12,
|
|
fontWeight: '600'
|
|
},
|
|
projectGroupMeta: {
|
|
color: colors.textMuted,
|
|
fontSize: 11
|
|
},
|
|
projectFieldPillRow: {
|
|
flexDirection: 'row',
|
|
flexWrap: 'wrap',
|
|
gap: spacing.xs,
|
|
marginTop: spacing.xs
|
|
},
|
|
projectFieldPill: {
|
|
maxWidth: '100%',
|
|
borderWidth: 1,
|
|
borderColor: colors.borderSubtle,
|
|
borderRadius: 999,
|
|
backgroundColor: colors.bgPanel,
|
|
paddingHorizontal: spacing.sm,
|
|
paddingVertical: 2
|
|
},
|
|
projectFieldPillText: {
|
|
color: colors.textSecondary,
|
|
fontSize: 11
|
|
},
|
|
projectFieldPillEmptyText: {
|
|
color: colors.textMuted
|
|
},
|
|
projectPasteRow: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: spacing.sm
|
|
},
|
|
projectPasteInput: {
|
|
flex: 1
|
|
},
|
|
projectPasteButton: {
|
|
minHeight: 40,
|
|
alignItems: 'center',
|
|
justifyContent: 'center'
|
|
},
|
|
pickerRow: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: spacing.sm,
|
|
paddingHorizontal: spacing.md + 2,
|
|
paddingVertical: spacing.md,
|
|
borderBottomWidth: 1,
|
|
borderBottomColor: colors.borderSubtle
|
|
},
|
|
pickerRowSelected: {
|
|
backgroundColor: colors.bgRaised
|
|
},
|
|
pickerRowContent: {
|
|
flex: 1,
|
|
minWidth: 0
|
|
},
|
|
pickerRowLabel: {
|
|
fontSize: typography.bodySize,
|
|
color: colors.textPrimary
|
|
},
|
|
pickerRowSubtitle: {
|
|
fontSize: 11,
|
|
color: colors.textMuted,
|
|
marginTop: 1
|
|
},
|
|
pickerRowWithAction: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center'
|
|
},
|
|
pickerRowMain: {
|
|
flex: 1,
|
|
minWidth: 0,
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: spacing.sm,
|
|
paddingHorizontal: spacing.md + 2,
|
|
paddingVertical: spacing.md
|
|
},
|
|
pickerCheck: {
|
|
width: 18,
|
|
alignItems: 'center'
|
|
},
|
|
pickerContent: {
|
|
flex: 1,
|
|
minWidth: 0
|
|
},
|
|
pickerLabel: {
|
|
fontSize: typography.bodySize,
|
|
color: colors.textPrimary
|
|
},
|
|
monoText: {
|
|
fontFamily: Platform.select({ ios: 'Menlo', android: 'monospace', default: 'monospace' })
|
|
},
|
|
pickerSubtitle: {
|
|
fontSize: 11,
|
|
color: colors.textMuted,
|
|
marginTop: 1
|
|
},
|
|
iconActionButton: {
|
|
width: 44,
|
|
height: 44,
|
|
alignItems: 'center',
|
|
justifyContent: 'center'
|
|
},
|
|
groupSeparator: {
|
|
height: StyleSheet.hairlineWidth,
|
|
backgroundColor: colors.borderSubtle,
|
|
marginHorizontal: spacing.md
|
|
},
|
|
repoPickerRow: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: spacing.sm,
|
|
paddingHorizontal: spacing.md + 2,
|
|
paddingVertical: spacing.md
|
|
},
|
|
repoPickerTextWrap: {
|
|
flex: 1,
|
|
minWidth: 0
|
|
},
|
|
repoPickerTitle: {
|
|
fontSize: typography.bodySize,
|
|
color: colors.textPrimary
|
|
},
|
|
repoPickerSubtitle: {
|
|
fontSize: 11,
|
|
color: colors.textMuted,
|
|
marginTop: 1
|
|
},
|
|
sheetHeader: {
|
|
paddingHorizontal: spacing.xs,
|
|
marginBottom: spacing.md
|
|
},
|
|
sheetTitleRow: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: spacing.sm
|
|
},
|
|
sheetTitle: {
|
|
flex: 1,
|
|
minWidth: 0,
|
|
fontSize: 15,
|
|
fontWeight: '700',
|
|
color: colors.textPrimary,
|
|
lineHeight: 20
|
|
},
|
|
sheetSubtitle: {
|
|
fontSize: 12,
|
|
color: colors.textMuted,
|
|
marginTop: 2
|
|
},
|
|
actionGroup: {
|
|
backgroundColor: colors.bgPanel,
|
|
borderRadius: 12,
|
|
overflow: 'hidden'
|
|
},
|
|
detailGroup: {
|
|
backgroundColor: colors.bgPanel,
|
|
borderRadius: 12,
|
|
padding: spacing.md,
|
|
marginBottom: spacing.md,
|
|
gap: spacing.md
|
|
},
|
|
detailLoading: {
|
|
paddingVertical: spacing.lg,
|
|
alignItems: 'center'
|
|
},
|
|
detailLoadingInline: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: spacing.sm
|
|
},
|
|
detailError: {
|
|
color: colors.statusRed,
|
|
fontSize: 13
|
|
},
|
|
detailMetaGrid: {
|
|
flexDirection: 'row',
|
|
flexWrap: 'wrap',
|
|
gap: spacing.sm
|
|
},
|
|
detailMetaItem: {
|
|
minWidth: 96,
|
|
flexGrow: 1
|
|
},
|
|
detailMetaLabel: {
|
|
fontSize: 11,
|
|
color: colors.textMuted,
|
|
marginBottom: 2
|
|
},
|
|
detailMetaValue: {
|
|
fontSize: 13,
|
|
color: colors.textPrimary,
|
|
fontWeight: '600'
|
|
},
|
|
chipRow: {
|
|
flexDirection: 'row',
|
|
flexWrap: 'wrap',
|
|
gap: spacing.xs
|
|
},
|
|
detailChip: {
|
|
borderWidth: 1,
|
|
borderColor: colors.borderSubtle,
|
|
backgroundColor: colors.bgRaised,
|
|
borderRadius: 999,
|
|
paddingHorizontal: spacing.sm,
|
|
paddingVertical: 2
|
|
},
|
|
detailChipSelected: {
|
|
borderColor: colors.accentBlue,
|
|
backgroundColor: colors.bgRaised
|
|
},
|
|
detailChipText: {
|
|
fontSize: 11,
|
|
color: colors.textSecondary
|
|
},
|
|
issueTypeChipContent: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: spacing.xs
|
|
},
|
|
issueTypeDot: {
|
|
width: 7,
|
|
height: 7,
|
|
borderRadius: 999
|
|
},
|
|
detailSection: {
|
|
gap: spacing.xs
|
|
},
|
|
detailSectionHeader: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
gap: spacing.sm
|
|
},
|
|
detailSectionTitle: {
|
|
fontSize: 11,
|
|
fontWeight: '600',
|
|
color: colors.textMuted,
|
|
textTransform: 'uppercase',
|
|
letterSpacing: 0.5
|
|
},
|
|
detailSectionMeta: {
|
|
flexShrink: 0,
|
|
fontSize: 11,
|
|
color: colors.textMuted
|
|
},
|
|
fieldButton: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: spacing.sm,
|
|
backgroundColor: colors.bgRaised,
|
|
borderRadius: radii.input,
|
|
paddingHorizontal: spacing.md,
|
|
paddingVertical: spacing.sm,
|
|
borderWidth: 1,
|
|
borderColor: colors.borderSubtle
|
|
},
|
|
fieldButtonDisabled: {
|
|
opacity: 0.55
|
|
},
|
|
fieldButtonPlaceholder: {
|
|
color: colors.textMuted
|
|
},
|
|
fieldButtonText: {
|
|
flex: 1,
|
|
fontSize: typography.bodySize,
|
|
color: colors.textPrimary
|
|
},
|
|
workspaceCreateForm: {
|
|
gap: 0
|
|
},
|
|
workspaceCreateField: {
|
|
marginBottom: spacing.md
|
|
},
|
|
workspaceCreateLabel: {
|
|
fontSize: 13,
|
|
fontWeight: '500',
|
|
color: colors.textSecondary,
|
|
marginBottom: spacing.xs
|
|
},
|
|
workspaceCreateLabelHint: {
|
|
fontWeight: '400',
|
|
color: colors.textMuted
|
|
},
|
|
workspaceAdvancedToggle: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: spacing.xs,
|
|
paddingVertical: spacing.sm,
|
|
marginBottom: spacing.xs
|
|
},
|
|
workspaceAdvancedText: {
|
|
fontSize: typography.bodySize,
|
|
fontWeight: '500',
|
|
color: colors.textSecondary
|
|
},
|
|
workspaceCreateActions: {
|
|
flexDirection: 'row',
|
|
justifyContent: 'flex-end',
|
|
marginTop: spacing.sm
|
|
},
|
|
workspaceCreateButton: {
|
|
minWidth: 160,
|
|
paddingHorizontal: spacing.lg
|
|
},
|
|
sshConnectCard: {
|
|
backgroundColor: colors.bgRaised,
|
|
borderRadius: radii.input,
|
|
borderWidth: 1,
|
|
borderColor: colors.borderSubtle,
|
|
paddingHorizontal: spacing.md,
|
|
paddingVertical: spacing.sm,
|
|
gap: spacing.xs
|
|
},
|
|
sshStatusRow: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: spacing.sm
|
|
},
|
|
sshStatusDot: {
|
|
width: 8,
|
|
height: 8,
|
|
borderRadius: 999
|
|
},
|
|
sshStatusDotConnected: {
|
|
backgroundColor: colors.statusGreen
|
|
},
|
|
sshStatusDotProgress: {
|
|
backgroundColor: colors.statusAmber
|
|
},
|
|
sshStatusDotDisconnected: {
|
|
backgroundColor: colors.statusRed
|
|
},
|
|
sshStatusCopy: {
|
|
flex: 1,
|
|
minWidth: 0
|
|
},
|
|
sshStatusTitle: {
|
|
fontSize: typography.bodySize,
|
|
color: colors.textPrimary,
|
|
fontWeight: '600'
|
|
},
|
|
reviewerRow: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: spacing.sm,
|
|
borderWidth: 1,
|
|
borderColor: colors.borderSubtle,
|
|
borderRadius: radii.card,
|
|
paddingHorizontal: spacing.sm,
|
|
paddingVertical: spacing.xs
|
|
},
|
|
reviewerAvatar: {
|
|
width: 24,
|
|
height: 24,
|
|
borderRadius: 12,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
backgroundColor: colors.bgRaised
|
|
},
|
|
reviewerAvatarText: {
|
|
fontSize: 11,
|
|
fontWeight: '700',
|
|
color: colors.textSecondary
|
|
},
|
|
reviewerInfo: {
|
|
flex: 1,
|
|
minWidth: 0
|
|
},
|
|
reviewerName: {
|
|
fontSize: 12,
|
|
fontWeight: '600',
|
|
color: colors.textPrimary
|
|
},
|
|
reviewerMeta: {
|
|
fontSize: 11,
|
|
color: colors.textMuted
|
|
},
|
|
reviewerState: {
|
|
flexShrink: 0,
|
|
fontSize: 11,
|
|
color: colors.textSecondary
|
|
},
|
|
projectFieldCard: {
|
|
borderWidth: 1,
|
|
borderColor: colors.borderSubtle,
|
|
borderRadius: radii.card,
|
|
padding: spacing.sm,
|
|
gap: spacing.xs
|
|
},
|
|
projectFieldName: {
|
|
flex: 1,
|
|
minWidth: 0,
|
|
fontSize: 12,
|
|
fontWeight: '600',
|
|
color: colors.textPrimary
|
|
},
|
|
projectFieldValue: {
|
|
maxWidth: 140,
|
|
fontSize: 12,
|
|
color: colors.textMuted
|
|
},
|
|
projectIterationList: {
|
|
gap: spacing.xs
|
|
},
|
|
projectIterationCopy: {
|
|
flex: 1,
|
|
minWidth: 0,
|
|
gap: 2
|
|
},
|
|
detailLine: {
|
|
fontSize: 12,
|
|
lineHeight: 17,
|
|
color: colors.textSecondary
|
|
},
|
|
fileActionRow: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
gap: spacing.sm,
|
|
paddingVertical: 2
|
|
},
|
|
fileCard: {
|
|
borderWidth: 1,
|
|
borderColor: colors.borderSubtle,
|
|
borderRadius: radii.card,
|
|
padding: spacing.sm,
|
|
gap: spacing.xs
|
|
},
|
|
pipelineStatusChip: {
|
|
flexShrink: 0,
|
|
borderWidth: 1,
|
|
borderColor: colors.borderSubtle,
|
|
borderRadius: 999,
|
|
backgroundColor: colors.bgRaised,
|
|
paddingHorizontal: spacing.sm,
|
|
paddingVertical: 2
|
|
},
|
|
pipelineStatusSuccess: {
|
|
borderColor: colors.statusGreen
|
|
},
|
|
pipelineStatusWarning: {
|
|
borderColor: colors.statusAmber
|
|
},
|
|
pipelineStatusDanger: {
|
|
borderColor: colors.statusRed
|
|
},
|
|
pipelineStatusActive: {
|
|
borderColor: colors.accentBlue
|
|
},
|
|
pipelineStatusText: {
|
|
fontSize: 10,
|
|
color: colors.textSecondary,
|
|
fontWeight: '600',
|
|
textTransform: 'uppercase'
|
|
},
|
|
filePreview: {
|
|
gap: spacing.xs,
|
|
marginTop: spacing.xs
|
|
},
|
|
fileDiff: {
|
|
borderWidth: 1,
|
|
borderColor: colors.borderSubtle,
|
|
borderRadius: radii.row,
|
|
overflow: 'hidden'
|
|
},
|
|
diffLineBlock: {
|
|
borderTopWidth: StyleSheet.hairlineWidth,
|
|
borderTopColor: colors.borderSubtle,
|
|
borderLeftWidth: 2,
|
|
borderLeftColor: colors.borderSubtle,
|
|
paddingHorizontal: spacing.xs,
|
|
paddingVertical: spacing.xs,
|
|
gap: spacing.xs
|
|
},
|
|
diffLineAdded: {
|
|
borderLeftColor: colors.statusGreen
|
|
},
|
|
diffLineRemoved: {
|
|
borderLeftColor: colors.statusRed
|
|
},
|
|
diffCodeRow: {
|
|
flexDirection: 'row',
|
|
gap: spacing.xs,
|
|
alignItems: 'flex-start'
|
|
},
|
|
diffLineNumbers: {
|
|
width: 76,
|
|
flexShrink: 0,
|
|
fontFamily: typography.monoFamily,
|
|
fontSize: 10,
|
|
lineHeight: 16,
|
|
color: colors.textMuted
|
|
},
|
|
codeLine: {
|
|
flex: 1,
|
|
fontFamily: typography.monoFamily,
|
|
fontSize: 11,
|
|
lineHeight: 16,
|
|
color: colors.textSecondary
|
|
},
|
|
diffCodeAdded: {
|
|
color: colors.statusGreen
|
|
},
|
|
diffCodeRemoved: {
|
|
color: colors.statusRed
|
|
},
|
|
detailMuted: {
|
|
fontSize: 12,
|
|
color: colors.textSecondary
|
|
},
|
|
commentBlock: {
|
|
borderTopWidth: 1,
|
|
borderTopColor: colors.borderSubtle,
|
|
paddingTop: spacing.sm
|
|
},
|
|
commentThreadGroup: {
|
|
gap: spacing.xs
|
|
},
|
|
commentReplyBlock: {
|
|
marginLeft: spacing.md,
|
|
paddingLeft: spacing.sm,
|
|
borderLeftWidth: 1,
|
|
borderLeftColor: colors.borderSubtle
|
|
},
|
|
commentResolvedBlock: {
|
|
opacity: 0.6
|
|
},
|
|
resolvedCommentSummary: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: spacing.sm,
|
|
borderWidth: 1,
|
|
borderColor: colors.borderSubtle,
|
|
borderRadius: radii.card,
|
|
backgroundColor: colors.bgPanel,
|
|
paddingHorizontal: spacing.sm,
|
|
paddingVertical: spacing.sm
|
|
},
|
|
resolvedCommentTitle: {
|
|
flex: 1,
|
|
minWidth: 0,
|
|
fontSize: 12,
|
|
color: colors.textSecondary
|
|
},
|
|
commentSource: {
|
|
fontSize: 11,
|
|
lineHeight: 15,
|
|
color: colors.textSecondary,
|
|
marginBottom: 2
|
|
},
|
|
commentMeta: {
|
|
fontSize: 11,
|
|
color: colors.textMuted,
|
|
marginBottom: 2
|
|
},
|
|
commentControls: {
|
|
gap: spacing.xs,
|
|
marginTop: spacing.sm
|
|
},
|
|
reactionRow: {
|
|
flexDirection: 'row',
|
|
flexWrap: 'wrap',
|
|
gap: spacing.xs,
|
|
marginTop: spacing.xs
|
|
},
|
|
reactionChip: {
|
|
borderWidth: 1,
|
|
borderColor: colors.borderSubtle,
|
|
backgroundColor: colors.bgRaised,
|
|
borderRadius: 999,
|
|
paddingHorizontal: spacing.sm,
|
|
paddingVertical: 2
|
|
},
|
|
reactionText: {
|
|
fontSize: 11,
|
|
color: colors.textSecondary
|
|
},
|
|
inlineActionRow: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
flexWrap: 'wrap',
|
|
gap: spacing.xs
|
|
},
|
|
actionRow: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: spacing.sm,
|
|
paddingHorizontal: spacing.md + 2,
|
|
paddingVertical: spacing.md
|
|
},
|
|
actionText: {
|
|
flex: 1,
|
|
fontSize: typography.bodySize,
|
|
color: colors.textPrimary
|
|
},
|
|
actionSeparator: {
|
|
height: StyleSheet.hairlineWidth,
|
|
backgroundColor: colors.borderSubtle,
|
|
marginHorizontal: spacing.md
|
|
},
|
|
setupPromptBox: {
|
|
backgroundColor: colors.bgPanel,
|
|
borderRadius: 12,
|
|
borderWidth: 1,
|
|
borderColor: colors.borderSubtle,
|
|
padding: spacing.md,
|
|
marginBottom: spacing.md,
|
|
gap: spacing.xs
|
|
},
|
|
setupPromptCommand: {
|
|
fontFamily: typography.monoFamily,
|
|
fontSize: 12,
|
|
lineHeight: 17,
|
|
color: colors.textPrimary
|
|
},
|
|
linearStatesBlock: {
|
|
paddingTop: spacing.sm
|
|
},
|
|
linearStatesTitle: {
|
|
fontSize: 11,
|
|
fontWeight: '600',
|
|
color: colors.textMuted,
|
|
textTransform: 'uppercase',
|
|
letterSpacing: 0.5,
|
|
paddingHorizontal: spacing.md + 2,
|
|
paddingBottom: spacing.xs
|
|
},
|
|
emptyInlineText: {
|
|
color: colors.textSecondary,
|
|
fontSize: 13,
|
|
paddingHorizontal: spacing.md + 2,
|
|
paddingBottom: spacing.md
|
|
},
|
|
createForm: {
|
|
gap: spacing.sm
|
|
},
|
|
fieldLabel: {
|
|
fontSize: 12,
|
|
fontWeight: '600',
|
|
color: colors.textSecondary
|
|
},
|
|
inlineTextLink: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: spacing.xs,
|
|
alignSelf: 'flex-start',
|
|
paddingVertical: spacing.xs
|
|
},
|
|
inlineTextLinkText: {
|
|
color: colors.textSecondary,
|
|
fontSize: 12,
|
|
textDecorationLine: 'underline'
|
|
},
|
|
securityHintRow: {
|
|
flexDirection: 'row',
|
|
alignItems: 'flex-start',
|
|
gap: spacing.xs
|
|
},
|
|
securityHintText: {
|
|
flex: 1,
|
|
color: colors.textMuted,
|
|
fontSize: 11,
|
|
lineHeight: 16
|
|
},
|
|
targetButton: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: spacing.sm,
|
|
borderWidth: 1,
|
|
borderColor: colors.borderSubtle,
|
|
backgroundColor: colors.bgPanel,
|
|
borderRadius: radii.input,
|
|
paddingHorizontal: spacing.md,
|
|
paddingVertical: spacing.sm + 2
|
|
},
|
|
targetButtonText: {
|
|
flex: 1,
|
|
color: colors.textPrimary,
|
|
fontSize: typography.bodySize
|
|
},
|
|
issueSourceBox: {
|
|
borderWidth: 1,
|
|
borderColor: colors.borderSubtle,
|
|
borderRadius: radii.card,
|
|
backgroundColor: colors.bgPanel,
|
|
padding: spacing.sm,
|
|
gap: spacing.xs
|
|
},
|
|
issueSourceHint: {
|
|
fontSize: 12,
|
|
color: colors.textSecondary
|
|
},
|
|
issueSourceSegment: {
|
|
flexDirection: 'row',
|
|
borderWidth: 1,
|
|
borderColor: colors.borderSubtle,
|
|
borderRadius: radii.input,
|
|
backgroundColor: colors.bgBase,
|
|
padding: 2,
|
|
gap: 2
|
|
},
|
|
issueSourceSegmentButton: {
|
|
flex: 1,
|
|
borderRadius: radii.input - 2,
|
|
paddingHorizontal: spacing.sm,
|
|
paddingVertical: spacing.xs
|
|
},
|
|
issueSourceSegmentButtonActive: {
|
|
backgroundColor: colors.bgRaised
|
|
},
|
|
issueSourceSegmentText: {
|
|
fontSize: 12,
|
|
fontWeight: '700',
|
|
color: colors.textMuted
|
|
},
|
|
issueSourceSegmentTextActive: {
|
|
color: colors.textPrimary
|
|
},
|
|
issueSourceSlug: {
|
|
marginTop: 1,
|
|
fontSize: 10,
|
|
color: colors.textMuted
|
|
},
|
|
drawerLoadingRow: {
|
|
paddingVertical: spacing.lg,
|
|
alignItems: 'center'
|
|
},
|
|
input: {
|
|
borderWidth: 1,
|
|
borderColor: colors.borderSubtle,
|
|
backgroundColor: colors.bgPanel,
|
|
borderRadius: radii.input,
|
|
paddingHorizontal: spacing.md,
|
|
paddingVertical: spacing.sm,
|
|
color: colors.textPrimary,
|
|
fontSize: typography.bodySize
|
|
},
|
|
bodyInput: {
|
|
minHeight: 88
|
|
},
|
|
monoInput: {
|
|
fontFamily: Platform.select({ ios: 'Menlo', android: 'monospace', default: 'monospace' })
|
|
},
|
|
commentInput: {
|
|
minHeight: 72,
|
|
marginTop: spacing.sm
|
|
},
|
|
commentComposer: {
|
|
position: 'relative',
|
|
marginTop: spacing.sm
|
|
},
|
|
commentComposerInput: {
|
|
minHeight: 40,
|
|
maxHeight: 120,
|
|
marginTop: 0,
|
|
paddingRight: 44
|
|
},
|
|
commentComposerSend: {
|
|
position: 'absolute',
|
|
right: spacing.xs,
|
|
bottom: spacing.xs,
|
|
width: 32,
|
|
height: 32,
|
|
borderRadius: radii.button,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
backgroundColor: colors.bgRaised
|
|
},
|
|
commentComposerSendPressed: {
|
|
opacity: 0.75
|
|
},
|
|
commentComposerSendDisabled: {
|
|
opacity: 0.5
|
|
},
|
|
replyInput: {
|
|
minHeight: 48,
|
|
marginTop: spacing.xs
|
|
},
|
|
stackedInput: {
|
|
marginTop: spacing.sm
|
|
},
|
|
inlineSaveButton: {
|
|
alignSelf: 'flex-start',
|
|
marginTop: spacing.sm,
|
|
borderRadius: radii.button,
|
|
borderWidth: 1,
|
|
borderColor: colors.borderSubtle,
|
|
paddingHorizontal: spacing.md,
|
|
paddingVertical: spacing.xs
|
|
},
|
|
inlineSaveButtonCompact: {
|
|
alignSelf: 'flex-start',
|
|
borderRadius: radii.button,
|
|
borderWidth: 1,
|
|
borderColor: colors.borderSubtle,
|
|
paddingHorizontal: spacing.sm,
|
|
paddingVertical: spacing.xs
|
|
},
|
|
inlineSaveText: {
|
|
color: colors.textPrimary,
|
|
fontSize: 12,
|
|
fontWeight: '600'
|
|
},
|
|
inlineButtonRow: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
flexWrap: 'wrap',
|
|
gap: spacing.xs,
|
|
marginTop: spacing.xs
|
|
},
|
|
drawerActionRow: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: spacing.sm
|
|
},
|
|
secondaryActionButton: {
|
|
flex: 1,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
borderRadius: radii.button,
|
|
borderWidth: 1,
|
|
borderColor: colors.borderSubtle,
|
|
paddingVertical: spacing.sm
|
|
},
|
|
secondaryActionText: {
|
|
color: colors.textPrimary,
|
|
fontSize: typography.bodySize,
|
|
fontWeight: '600'
|
|
},
|
|
primaryActionButton: {
|
|
flex: 1,
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
gap: spacing.xs,
|
|
borderRadius: radii.button,
|
|
backgroundColor: colors.textPrimary,
|
|
paddingVertical: spacing.sm
|
|
},
|
|
primaryActionText: {
|
|
color: colors.bgBase,
|
|
fontSize: typography.bodySize,
|
|
fontWeight: '700'
|
|
},
|
|
inlineDeleteText: {
|
|
color: colors.statusRed,
|
|
fontSize: 12,
|
|
fontWeight: '600'
|
|
},
|
|
createButton: {
|
|
marginTop: spacing.sm,
|
|
backgroundColor: colors.textPrimary,
|
|
borderRadius: radii.button,
|
|
paddingVertical: spacing.sm + 2,
|
|
alignItems: 'center'
|
|
},
|
|
createButtonDisabled: {
|
|
opacity: 0.5
|
|
},
|
|
createButtonText: {
|
|
color: colors.bgBase,
|
|
fontSize: typography.bodySize,
|
|
fontWeight: '700'
|
|
}
|
|
})
|
|
|
|
function getPrSignalToneStyle(tone: 'neutral' | 'success' | 'warning' | 'danger') {
|
|
if (tone === 'success') {
|
|
return styles.prSignalSuccess
|
|
}
|
|
if (tone === 'warning') {
|
|
return styles.prSignalWarning
|
|
}
|
|
if (tone === 'danger') {
|
|
return styles.prSignalDanger
|
|
}
|
|
return null
|
|
}
|
|
|
|
function getGitLabPipelineStatusStyle(status: string) {
|
|
switch (status) {
|
|
case 'success':
|
|
return styles.pipelineStatusSuccess
|
|
case 'failed':
|
|
return styles.pipelineStatusDanger
|
|
case 'manual':
|
|
return styles.pipelineStatusWarning
|
|
case 'running':
|
|
case 'pending':
|
|
case 'created':
|
|
case 'preparing':
|
|
case 'waiting_for_resource':
|
|
case 'scheduled':
|
|
return styles.pipelineStatusActive
|
|
case 'canceled':
|
|
case 'skipped':
|
|
default:
|
|
return null
|
|
}
|
|
}
|