diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index 43809ae8dd7..807785aa670 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -30,7 +30,6 @@ inline src/main/ssh/ssh-relay-session.ts inline src/main/updater.ts inline src/preload/index.ts inline src/relay/pty-handler.ts -inline src/renderer/src/components/LinearItemDrawer.tsx inline src/renderer/src/components/TaskPage.tsx inline src/renderer/src/components/Terminal.tsx inline src/renderer/src/components/WorktreeJumpPalette.tsx diff --git a/src/renderer/src/components/LinearItemDrawer.tsx b/src/renderer/src/components/LinearItemDrawer.tsx index d544b58eebe..6cb74ef71b1 100644 --- a/src/renderer/src/components/LinearItemDrawer.tsx +++ b/src/renderer/src/components/LinearItemDrawer.tsx @@ -1,1237 +1,27 @@ -/* eslint-disable max-lines -- Why: the Linear drawer co-locates read-only preview, edit controls, and comment input so the full issue surface stays in one file. */ /* oxlint-disable react-doctor/no-adjust-state-on-prop-change -- Why: Linear drawer state hydrates full issue details and comments from provider IPC for the selected issue. */ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { - ArrowRight, - ChevronDown, - ExternalLink, - FolderOpen, - Gauge, - LoaderCircle, - Plus, - Send, - Tag, - UserRound, - X -} from 'lucide-react' -import { toast } from 'sonner' -import { Button } from '@/components/ui/button' -import { ButtonGroup } from '@/components/ui/button-group' -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger -} from '@/components/ui/dropdown-menu' -import { Input } from '@/components/ui/input' -import { LinearIssueTextEditor } from '@/components/LinearIssueTextEditor' -import { Sheet, SheetContent, SheetDescription, SheetTitle } from '@/components/ui/sheet' -import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' -import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' -import { VisuallyHidden } from 'radix-ui' -import CommentMarkdown from '@/components/sidebar/CommentMarkdown' -import { cn } from '@/lib/utils' -import { - getCommentBodySubmitState, - hasBoundedCommentBodyText -} from '@/lib/comment-body-submit-state' import { findLinearIssueWorkspaceAttachment } from '@/lib/linear-issue-workspace-attachment' import { openLinearIssueWorkspaceOrStart } from '@/lib/linear-issue-workspace-open' import { getWorktreeAttachmentLabel } from '@/lib/worktree-attachment-label' import { folderWorkspaceToWorktree } from '../../../shared/folder-workspace-worktree' import { useAppStore } from '@/store' import { useAllWorktrees } from '@/store/selectors' -import { getScreenSubmitShortcutLabel, isScreenSubmitShortcut } from '@/lib/screen-submit-shortcut' -import { createBrowserUuid } from '@/lib/browser-uuid' -import { - useTeamStates, - useTeamLabels, - useTeamMembers, - useImmediateMutation -} from '@/hooks/useIssueMetadata' -import { - getLinearStateMarkerStyle, - getLinearStatePillStyle -} from '@/components/linear-state-pill-style' -import { LinearPriorityIcon } from '@/components/linear-priority-icon' import type { LinearComment, LinearIssue } from '../../../shared/linear/issue-types' -import type { TaskSourceContext } from '../../../shared/task-source-context' +import { linearGetIssue, linearIssueComments } from '@/runtime/runtime-linear-issue-mutations' import { - linearAddIssueComment, - linearGetIssue, - linearIssueComments, - linearUpdateIssue -} from '@/runtime/runtime-linear-issue-mutations' -import { translate } from '@/i18n/i18n' -import { formatUiRelativeTimeFromDate } from '@/i18n/relative-time-format' + initLinearIssueEditState, + type LinearEditState, + type LinearItemDrawerProps, + type LinearLocalComment +} from '@/components/linear-item-drawer-types' +import { renderLinearItemDrawerSheet } from '@/components/linear-item-drawer-sheet' -function LinearIcon({ className }: { className?: string }): React.JSX.Element { - return ( - - - - ) -} - -const PRIORITY_LABELS: Record = { - 0: 'No priority', - 1: 'Urgent', - 2: 'High', - 3: 'Medium', - 4: 'Low' -} - -const LINEAR_EDIT_CHIP_CLASS = - 'inline-flex h-6 min-w-0 max-w-[14rem] cursor-pointer items-center gap-1.5 rounded-full border border-border/70 bg-background/70 px-2.5 text-[11px] font-medium leading-none text-muted-foreground shadow-xs transition-[background-color,border-color,color,box-shadow] hover:border-border hover:bg-accent hover:text-accent-foreground hover:[--linear-state-pill-current-background:var(--linear-state-pill-hover-background)] hover:[--linear-state-pill-current-border:var(--linear-state-pill-hover-border)] hover:[--linear-state-pill-current-foreground:var(--linear-state-pill-hover-foreground)] focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-80' - -const LINEAR_EDIT_MENU_ITEM_CLASS = - 'flex w-full cursor-pointer items-center rounded-sm px-2 py-1.5 text-[12px] hover:bg-accent' - -const LINEAR_EDIT_MENU_ITEM_WITH_ICON_CLASS = - 'flex w-full cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-[12px] hover:bg-accent' - -const LINEAR_ESTIMATE_PRESETS = [1, 2, 3, 5, 8] as const - -export function formatLinearEstimateLabel(estimate: number | null | undefined): string { - return estimate === null || estimate === undefined ? 'Set estimate' : `Estimate ${estimate}` -} - -function formatLinearEstimateInput(estimate: number | null | undefined): string { - return estimate === null || estimate === undefined ? '' : String(estimate) -} - -function LinearEditChipAdornment({ - loading, - pending -}: { - loading?: boolean - pending?: boolean -}): React.JSX.Element { - if (loading || pending) { - return - } - - return -} - -function formatRelativeTime(input: string): string { - return formatUiRelativeTimeFromDate(input) -} - -type LinearItemDrawerProps = { - issue: LinearIssue | null - onUse: (issue: LinearIssue) => void - onClose: () => void - sourceContext?: TaskSourceContext | null -} - -export type LinearEditState = { - state: LinearIssue['state'] - priority: number - estimate: number | null | undefined - assignee: LinearIssue['assignee'] - labelIds: string[] - labels: string[] -} - -type EditSectionProps = { - issue: LinearIssue - editState: LinearEditState - onEditStateChange: (patch: Partial) => void - layout?: 'chips' | 'properties' - sourceContext?: TaskSourceContext | null -} - -export function LinearIssueEditSection({ - issue, - editState, - onEditStateChange, - layout = 'chips', - sourceContext -}: EditSectionProps): React.JSX.Element { - const [labelPopoverOpen, setLabelPopoverOpen] = useState(false) - const [estimatePopoverOpen, setEstimatePopoverOpen] = useState(false) - const patchLinearIssue = useAppStore((s) => s.patchLinearIssue) - const settings = useAppStore((s) => s.settings) - const providerSettings = sourceContext ?? settings - const { isPending, run } = useImmediateMutation() - - const { - state: localState, - priority: localPriority, - estimate: localEstimate, - assignee: localAssignee, - labelIds: localLabelIds, - labels: localLabels - } = editState - const [estimateInput, setEstimateInput] = useState(() => formatLinearEstimateInput(localEstimate)) - - const teamId = issue.team?.id || null - const states = useTeamStates(teamId, providerSettings, issue.workspaceId) - const labels = useTeamLabels(teamId, providerSettings, issue.workspaceId) - const members = useTeamMembers(teamId, providerSettings, issue.workspaceId) - - const handleEstimatePopoverOpenChange = useCallback( - (open: boolean) => { - setEstimatePopoverOpen(open) - if (open) { - setEstimateInput(formatLinearEstimateInput(localEstimate)) - } - }, - [localEstimate] - ) - - const handleStateChange = useCallback( - (stateId: string) => { - const newState = states.data.find((s) => s.id === stateId) - if (!newState) { - return - } - - const prevState = localState - const stateValue = { name: newState.name, type: newState.type, color: newState.color } - - run('state', { - mutate: () => linearUpdateIssue(providerSettings, issue.id, { stateId }, issue.workspaceId), - onOptimistic: () => { - onEditStateChange({ state: stateValue }) - patchLinearIssue(issue.id, { state: stateValue }, { sourceContext }) - }, - onRevert: () => { - onEditStateChange({ state: prevState }) - patchLinearIssue(issue.id, { state: prevState }, { sourceContext }) - }, - onSuccess: () => { - useAppStore.getState().invalidateLinearIssueLists({ sourceContext }) - useAppStore.getState().recordFeatureInteraction('linear-tasks') - }, - onError: (err) => toast.error(err) - }) - }, - [ - issue.id, - issue.workspaceId, - localState, - providerSettings, - states.data, - patchLinearIssue, - run, - onEditStateChange, - sourceContext - ] - ) - - const handlePriorityChange = useCallback( - (value: string) => { - const priority = Number.parseInt(value, 10) - const prevPriority = localPriority - run('priority', { - mutate: () => - linearUpdateIssue(providerSettings, issue.id, { priority }, issue.workspaceId), - onOptimistic: () => { - onEditStateChange({ priority }) - patchLinearIssue(issue.id, { priority }, { sourceContext }) - }, - onRevert: () => { - onEditStateChange({ priority: prevPriority }) - patchLinearIssue(issue.id, { priority: prevPriority }, { sourceContext }) - }, - onSuccess: () => { - useAppStore.getState().invalidateLinearIssueLists({ sourceContext }) - useAppStore.getState().recordFeatureInteraction('linear-tasks') - }, - onError: (err) => toast.error(err) - }) - }, - [ - issue.id, - issue.workspaceId, - localPriority, - providerSettings, - patchLinearIssue, - run, - onEditStateChange, - sourceContext - ] - ) - - const handleEstimateChange = useCallback( - (estimate: number | null) => { - const prevEstimate = localEstimate - run('estimate', { - mutate: () => - linearUpdateIssue(providerSettings, issue.id, { estimate }, issue.workspaceId), - onOptimistic: () => { - onEditStateChange({ estimate }) - patchLinearIssue(issue.id, { estimate }, { sourceContext }) - setEstimatePopoverOpen(false) - }, - onRevert: () => { - onEditStateChange({ estimate: prevEstimate }) - patchLinearIssue(issue.id, { estimate: prevEstimate }, { sourceContext }) - }, - onSuccess: () => { - useAppStore.getState().recordFeatureInteraction('linear-tasks') - }, - onError: (err) => toast.error(err) - }) - }, - [ - issue.id, - issue.workspaceId, - localEstimate, - providerSettings, - patchLinearIssue, - run, - onEditStateChange, - sourceContext - ] - ) - - const handleEstimateSubmit = useCallback(() => { - const trimmed = estimateInput.trim() - if (!trimmed) { - handleEstimateChange(null) - return - } - - const estimate = Number(trimmed) - if (!Number.isInteger(estimate) || estimate < 0) { - toast.error( - translate( - 'auto.components.LinearItemDrawer.0be31fef8e', - 'Estimate must be a non-negative integer' - ) - ) - return - } - - handleEstimateChange(estimate) - }, [estimateInput, handleEstimateChange]) - - const handleAssigneeChange = useCallback( - (memberId: string) => { - const assigneeId = memberId === '__unassign__' ? null : memberId - const member = members.data.find((m) => m.id === memberId) - const prevAssignee = localAssignee - const newAssignee = member - ? { id: member.id, displayName: member.displayName, avatarUrl: member.avatarUrl } - : undefined - run('assignee', { - mutate: () => - linearUpdateIssue(providerSettings, issue.id, { assigneeId }, issue.workspaceId), - onOptimistic: () => { - onEditStateChange({ assignee: newAssignee }) - patchLinearIssue(issue.id, { assignee: newAssignee }, { sourceContext }) - }, - onRevert: () => { - onEditStateChange({ assignee: prevAssignee }) - patchLinearIssue(issue.id, { assignee: prevAssignee }, { sourceContext }) - }, - onSuccess: () => { - useAppStore.getState().invalidateLinearIssueLists({ sourceContext }) - useAppStore.getState().recordFeatureInteraction('linear-tasks') - }, - onError: (err) => toast.error(err) - }) - }, - [ - issue.id, - issue.workspaceId, - localAssignee, - providerSettings, - members.data, - patchLinearIssue, - run, - onEditStateChange, - sourceContext - ] - ) - - const handleLabelToggle = useCallback( - (labelId: string) => { - const prevLabelIds = localLabelIds - const prevLabels = localLabels - const isRemoving = prevLabelIds.includes(labelId) - const newLabelIds = isRemoving - ? prevLabelIds.filter((id) => id !== labelId) - : [...prevLabelIds, labelId] - const newLabels = newLabelIds - .map((id) => labels.data.find((l) => l.id === id)?.name) - .filter((n): n is string => !!n) - - run('labels', { - mutate: () => - linearUpdateIssue( - providerSettings, - issue.id, - { labelIds: newLabelIds }, - issue.workspaceId - ), - onOptimistic: () => { - onEditStateChange({ labelIds: newLabelIds, labels: newLabels }) - patchLinearIssue( - issue.id, - { labelIds: newLabelIds, labels: newLabels }, - { sourceContext } - ) - }, - onRevert: () => { - onEditStateChange({ labelIds: prevLabelIds, labels: prevLabels }) - patchLinearIssue( - issue.id, - { labelIds: prevLabelIds, labels: prevLabels }, - { sourceContext } - ) - }, - onSuccess: () => { - useAppStore.getState().invalidateLinearIssueLists({ sourceContext }) - useAppStore.getState().recordFeatureInteraction('linear-tasks') - }, - onError: (err) => toast.error(err) - }) - }, - [ - issue.id, - issue.workspaceId, - localLabelIds, - localLabels, - providerSettings, - labels.data, - patchLinearIssue, - run, - onEditStateChange, - sourceContext - ] - ) - - const currentStateId = states.data.find( - (s) => s.name === localState.name && s.type === localState.type - )?.id - const statePending = isPending('state') - const priorityPending = isPending('priority') - const estimatePending = isPending('estimate') - const assigneePending = isPending('assignee') - const labelsPending = isPending('labels') - const labelSummary = - localLabels.length === 0 - ? '+ Label' - : localLabels.length === 1 - ? localLabels[0] - : `${localLabels[0]} +${localLabels.length - 1}` - - const checkIcon = ( - - - - ) - - if (layout === 'properties') { - const propertyRowClass = - 'flex min-h-9 w-full cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm text-foreground transition hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-80' - const propertyIconClass = 'size-4 shrink-0 text-muted-foreground' - - return ( -
-
-
- {translate('auto.components.LinearItemDrawer.dd304de85a', 'Properties')} - -
-
- - - - - - {states.error ? ( -
- {states.error} -
- ) : states.loading ? ( -
- - {translate('auto.components.LinearItemDrawer.59b6cd3706', 'Loading states')} -
- ) : states.data.length > 0 ? ( -
- {states.data.map((s) => ( - - ))} -
- ) : ( -
- {translate('auto.components.LinearItemDrawer.780ea6ed89', 'No states found')} -
- )} -
-
- - - - - - - {[0, 1, 2, 3, 4].map((p) => ( - - ))} - - - - - - - - -
- - {members.error ? ( -
- {members.error} -
- ) : members.loading ? ( -
- - {translate('auto.components.LinearItemDrawer.b2376d0179', 'Loading members')} -
- ) : ( - members.data.map((m) => ( - - )) - )} -
-
-
- - - - - - -
-
- {LINEAR_ESTIMATE_PRESETS.map((estimate) => ( - - ))} -
- setEstimateInput(event.target.value)} - onKeyDown={(event) => { - if (event.key === 'Enter') { - event.preventDefault() - handleEstimateSubmit() - } - }} - inputMode="numeric" - placeholder={translate( - 'auto.components.LinearItemDrawer.fbb90300e2', - 'Custom estimate' - )} - className="h-8 text-sm" - /> -
- - -
-
-
-
-
-
- -
-
- {translate('auto.components.LinearItemDrawer.64bfffc4dd', 'Labels')} - -
-
- - - - - - {labels.error ? ( -
- {labels.error} -
- ) : labels.loading ? ( -
- - {translate('auto.components.LinearItemDrawer.cddd9b04a7', 'Loading labels')} -
- ) : labels.data.length > 0 ? ( -
- {labels.data.map((label) => ( - - ))} -
- ) : ( -
- {translate('auto.components.LinearItemDrawer.367f828482', 'No labels found')} -
- )} -
-
-
-
-
- ) - } - - return ( -
- {/* Status */} - - - - - - {states.error ? ( -
{states.error}
- ) : states.loading ? ( -
- - {translate('auto.components.LinearItemDrawer.59b6cd3706', 'Loading states')} -
- ) : states.data.length > 0 ? ( -
- {states.data.map((s) => ( - - ))} -
- ) : ( -
- {translate('auto.components.LinearItemDrawer.780ea6ed89', 'No states found')} -
- )} -
-
- - {/* Priority */} - - - - - - {[0, 1, 2, 3, 4].map((p) => ( - - ))} - - - - {/* Estimate */} - - - - - -
-
- {LINEAR_ESTIMATE_PRESETS.map((estimate) => ( - - ))} -
- setEstimateInput(event.target.value)} - onKeyDown={(event) => { - if (event.key === 'Enter') { - event.preventDefault() - handleEstimateSubmit() - } - }} - inputMode="numeric" - placeholder={translate( - 'auto.components.LinearItemDrawer.fbb90300e2', - 'Custom estimate' - )} - className="h-8 text-sm" - /> -
- - -
-
-
-
- - {/* Assignee */} - - - - - -
- - {members.error ? ( -
- {members.error} -
- ) : members.loading ? ( -
- - {translate('auto.components.LinearItemDrawer.b2376d0179', 'Loading members')} -
- ) : ( - members.data.map((m) => ( - - )) - )} -
-
-
- - {/* Labels */} - - - - - - {labels.error ? ( -
{labels.error}
- ) : labels.loading ? ( -
- - {translate('auto.components.LinearItemDrawer.cddd9b04a7', 'Loading labels')} -
- ) : labels.data.length > 0 ? ( -
- {labels.data.map((label) => ( - - ))} -
- ) : ( -
- {translate('auto.components.LinearItemDrawer.367f828482', 'No labels found')} -
- )} -
-
-
- ) -} - -export type LinearLocalComment = { id: string; body: string; createdAt: string } - -export function LinearIssueCommentFooter({ - issueId, - workspaceId, - onCommentAdded, - variant = 'compact', - sourceContext -}: { - issueId: string - workspaceId?: string | null - onCommentAdded: (comment: LinearLocalComment) => void - variant?: 'compact' | 'linear-page' - sourceContext?: TaskSourceContext | null -}): React.JSX.Element { - const settings = useAppStore((s) => s.settings) - const providerSettings = sourceContext ?? settings - const submitShortcutLabel = getScreenSubmitShortcutLabel() - const [body, setBody] = useState('') - const [submitting, setSubmitting] = useState(false) - const textareaRef = useRef(null) - const mountedRef = useRef(true) - - const handleFooterRef = useCallback((node: HTMLDivElement | null): void => { - // Why: comment submission can resolve after the footer unmounts; the root - // ref keeps that completion from writing stale local state without an Effect. - mountedRef.current = node !== null - }, []) - - const autoGrow = useCallback(() => { - const el = textareaRef.current - if (!el) { - return - } - el.style.height = 'auto' - el.style.height = `${Math.min(el.scrollHeight, 96)}px` - }, []) - - const handleSubmit = useCallback(async () => { - const bodyState = getCommentBodySubmitState(body) - if (bodyState.status === 'empty') { - return - } - if (bodyState.status === 'too-large-leading-whitespace') { - toast.error( - translate( - 'auto.components.LinearItemDrawer.commentTooLarge', - 'Comment is too large to submit safely.' - ) - ) - return - } - setSubmitting(true) - try { - const result = await linearAddIssueComment( - providerSettings, - issueId, - bodyState.body, - workspaceId - ) - const typed = result as { ok: boolean; id?: string; error?: string } - if (!mountedRef.current) { - return - } - if (typed.ok) { - setBody('') - useAppStore.getState().recordFeatureInteraction('linear-tasks') - onCommentAdded({ - id: typed.id ?? createBrowserUuid(), - body: bodyState.body, - createdAt: new Date().toISOString() - }) - } else { - toast.error( - typed.error ?? - translate('auto.components.LinearItemDrawer.6ab35eafd5', 'Failed to add comment') - ) - } - } catch (err) { - if (mountedRef.current) { - toast.error( - err instanceof Error - ? err.message - : translate('auto.components.LinearItemDrawer.6ab35eafd5', 'Failed to add comment') - ) - } - } finally { - if (mountedRef.current) { - setSubmitting(false) - } - } - }, [body, issueId, onCommentAdded, providerSettings, workspaceId]) - const canSubmitComment = hasBoundedCommentBodyText(body) - - const handleKeyDown = useCallback( - (e: React.KeyboardEvent) => { - if (isScreenSubmitShortcut(e)) { - e.preventDefault() - handleSubmit() - } - }, - [handleSubmit] - ) - - if (variant === 'linear-page') { - return ( -
-