mirror of
https://github.com/stablyai/orca.git
synced 2026-10-23 16:03:49 +00:00
fix(merge): remove unrelated stale snapshot changes
This commit is contained in:
@@ -40,10 +40,9 @@ jobs:
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
|
||||
# Why: the build's plain-Node daemon smoke load resolves node-pty.
|
||||
# Why no native-runtime: this job only produces JS bundles. Native modules
|
||||
# are prepared in the consumer shards that actually launch Electron.
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
with:
|
||||
native-runtime: node
|
||||
|
||||
# Why: building here avoids parallel builds inside Playwright globalSetup;
|
||||
# paired-browser specs also need the standalone web bundle.
|
||||
|
||||
@@ -42,6 +42,7 @@ inline src/relay/git-handler.ts
|
||||
inline src/relay/pty-handler.ts
|
||||
inline src/relay/workspace-space-scan.ts
|
||||
inline src/renderer/src/components/GitLabItemDialog.tsx
|
||||
inline src/renderer/src/components/JiraIssueWorkspace.tsx
|
||||
inline src/renderer/src/components/LinearItemDrawer.tsx
|
||||
inline src/renderer/src/components/TaskPage.tsx
|
||||
inline src/renderer/src/components/Terminal.tsx
|
||||
|
||||
@@ -165,7 +165,7 @@ describe('PR E2E gate contract', () => {
|
||||
(step) => step.uses === './.github/actions/install-node-dependencies'
|
||||
)
|
||||
|
||||
expect(installFor('build').with['native-runtime']).toBe('node')
|
||||
expect(installFor('build').with).toBeUndefined()
|
||||
for (const jobName of ['e2e', 'changed-e2e', 'ssh-docker-watcher-isolation']) {
|
||||
expect(installFor(jobName).with['native-runtime'], jobName).toBe('electron')
|
||||
}
|
||||
|
||||
@@ -1,13 +1,33 @@
|
||||
/* eslint-disable max-lines -- Why: the Jira drawer co-locates preview,
|
||||
metadata edits, and comments so the task page has one full issue surface. */
|
||||
/* oxlint-disable react-doctor/no-adjust-state-on-prop-change -- Why: Jira issue hydration, comments, transitions, priorities, and user options are loaded from provider IPC for the selected issue. */
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
ArrowRight,
|
||||
Clipboard,
|
||||
ExternalLink,
|
||||
GitBranch,
|
||||
LoaderCircle,
|
||||
RefreshCw,
|
||||
Save,
|
||||
Send,
|
||||
X
|
||||
} from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { VisuallyHidden } from 'radix-ui'
|
||||
|
||||
import CommentMarkdown from '@/components/sidebar/CommentMarkdown'
|
||||
import { JiraIcon } from '@/components/icons/JiraIcon'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { Sheet, SheetContent, SheetDescription, SheetTitle } from '@/components/ui/sheet'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
getCommentBodySubmitState,
|
||||
hasBoundedCommentBodyText
|
||||
} from '@/lib/comment-body-submit-state'
|
||||
|
||||
import { createBrowserUuid } from '@/lib/browser-uuid'
|
||||
import { useAppStore } from '@/store'
|
||||
import {
|
||||
@@ -28,9 +48,7 @@ import type {
|
||||
} from '../../../shared/jira-types'
|
||||
import type { TaskSourceContext } from '../../../shared/task-source-context'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { JiraIssueMetadataBar, JiraIssueWorkspaceHeader } from './jira-issue-workspace-chrome'
|
||||
import { JiraIssueCommentComposer, JiraIssueWorkspaceContent } from './jira-issue-workspace-content'
|
||||
import { getJiraIssueWorkspaceActions } from './jira-issue-workspace-actions'
|
||||
import { formatUiRelativeTimeFromDate } from '@/i18n/relative-time-format'
|
||||
|
||||
type JiraIssueWorkspaceProps = {
|
||||
issue: JiraIssue | null
|
||||
@@ -39,6 +57,50 @@ type JiraIssueWorkspaceProps = {
|
||||
sourceContext?: TaskSourceContext | null
|
||||
}
|
||||
|
||||
function formatRelativeTime(input: string): string {
|
||||
return formatUiRelativeTimeFromDate(input)
|
||||
}
|
||||
|
||||
function buildJiraBranchName(issue: JiraIssue): string {
|
||||
const slug = issue.title
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 52)
|
||||
return `${issue.key.toLowerCase()}${slug ? `-${slug}` : ''}`
|
||||
}
|
||||
|
||||
function buildJiraPrompt(issue: JiraIssue): string {
|
||||
return `Complete Jira issue ${issue.key}: ${issue.title}\n\n${issue.url}`
|
||||
}
|
||||
|
||||
function jiraStatusClass(categoryKey: string): string {
|
||||
if (categoryKey === 'done') {
|
||||
return 'border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-200'
|
||||
}
|
||||
if (categoryKey === 'indeterminate') {
|
||||
return 'border-sky-500/30 bg-sky-500/10 text-sky-700 dark:text-sky-200'
|
||||
}
|
||||
return 'border-border/50 bg-muted/40 text-muted-foreground'
|
||||
}
|
||||
|
||||
async function copyTextToClipboard(text: string, label: string): Promise<void> {
|
||||
try {
|
||||
await window.api.ui.writeClipboardText(text)
|
||||
toast.success(
|
||||
translate('auto.components.JiraIssueWorkspace.2ff69a3545', '{{value0}} copied', {
|
||||
value0: label
|
||||
})
|
||||
)
|
||||
} catch {
|
||||
toast.error(
|
||||
translate('auto.components.JiraIssueWorkspace.6c41a9bcea', 'Failed to copy {{value0}}', {
|
||||
value0: label.toLowerCase()
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default function JiraIssueWorkspace({
|
||||
issue,
|
||||
onUse,
|
||||
@@ -281,10 +343,41 @@ export default function JiraIssueWorkspace({
|
||||
}, [commentDraft, commentSubmitting, displayed, providerSettings])
|
||||
const canSubmitComment = hasBoundedCommentBodyText(commentDraft)
|
||||
|
||||
const actionItems = useMemo(
|
||||
() => (displayed ? getJiraIssueWorkspaceActions(displayed) : []),
|
||||
[displayed]
|
||||
)
|
||||
const actionItems = useMemo(() => {
|
||||
if (!displayed) {
|
||||
return []
|
||||
}
|
||||
return [
|
||||
{
|
||||
label: translate('auto.components.JiraIssueWorkspace.69da9a208c', 'Open in Jira'),
|
||||
icon: ExternalLink,
|
||||
action: () => window.api.shell.openUrl(displayed.url)
|
||||
},
|
||||
{
|
||||
label: translate('auto.components.JiraIssueWorkspace.779bb91ee0', 'Copy URL'),
|
||||
icon: Clipboard,
|
||||
action: () => void copyTextToClipboard(displayed.url, 'URL')
|
||||
},
|
||||
{
|
||||
label: translate('auto.components.JiraIssueWorkspace.38839801e8', 'Copy key'),
|
||||
icon: Clipboard,
|
||||
action: () => void copyTextToClipboard(displayed.key, 'Key')
|
||||
},
|
||||
{
|
||||
label: translate(
|
||||
'auto.components.JiraIssueWorkspace.80efa101c5',
|
||||
'Copy suggested branch name'
|
||||
),
|
||||
icon: GitBranch,
|
||||
action: () => void copyTextToClipboard(buildJiraBranchName(displayed), 'Branch name')
|
||||
},
|
||||
{
|
||||
label: translate('auto.components.JiraIssueWorkspace.0cc62bd690', 'Copy prompt'),
|
||||
icon: Clipboard,
|
||||
action: () => void copyTextToClipboard(buildJiraPrompt(displayed), 'Prompt')
|
||||
}
|
||||
]
|
||||
}, [displayed])
|
||||
|
||||
return (
|
||||
<Sheet open={issue !== null} onOpenChange={(open) => !open && onClose()}>
|
||||
@@ -311,46 +404,419 @@ export default function JiraIssueWorkspace({
|
||||
|
||||
{displayed ? (
|
||||
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-background">
|
||||
<JiraIssueWorkspaceHeader
|
||||
displayed={displayed}
|
||||
issueLoading={issueLoading}
|
||||
onUse={onUse}
|
||||
onClose={onClose}
|
||||
/>
|
||||
<div className="flex-none border-b border-border/50 bg-muted/30 px-4 py-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-[11px] text-muted-foreground">
|
||||
<span className="font-mono">{displayed.key}</span>
|
||||
{displayed.siteName ? <span>{displayed.siteName}</span> : null}
|
||||
<span>{displayed.project.key}</span>
|
||||
<span>{formatRelativeTime(displayed.updatedAt)}</span>
|
||||
{issueLoading ? <LoaderCircle className="size-3 animate-spin" /> : null}
|
||||
</div>
|
||||
<h2 className="mt-1 text-[20px] font-semibold leading-tight text-foreground">
|
||||
{displayed.title}
|
||||
</h2>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => onUse(displayed)}
|
||||
className="hidden shrink-0 gap-2 sm:inline-flex"
|
||||
size="sm"
|
||||
>
|
||||
{translate('auto.components.JiraIssueWorkspace.2441be6f9f', 'Start workspace')}
|
||||
<ArrowRight className="size-4" />
|
||||
</Button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="shrink-0"
|
||||
onClick={onClose}
|
||||
aria-label={translate(
|
||||
'auto.components.JiraIssueWorkspace.76513c7898',
|
||||
'Close Jira issue preview'
|
||||
)}
|
||||
>
|
||||
<X className="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={6}>
|
||||
{translate('auto.components.JiraIssueWorkspace.7a96985ca0', 'Close')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<JiraIssueMetadataBar
|
||||
displayed={displayed}
|
||||
pendingField={pendingField}
|
||||
transitions={transitions}
|
||||
priorities={priorities}
|
||||
users={users}
|
||||
mutateIssue={mutateIssue}
|
||||
/>
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-2 border-b border-border/60 px-4 py-2.5">
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
disabled={pendingField === 'transition' || transitions.length === 0}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 text-[11px] font-medium transition hover:opacity-80 disabled:opacity-50',
|
||||
jiraStatusClass(displayed.status.categoryKey)
|
||||
)}
|
||||
>
|
||||
{displayed.status.name}
|
||||
{pendingField === 'transition' ? (
|
||||
<LoaderCircle className="size-3 animate-spin" />
|
||||
) : null}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="popover-scroll-content scrollbar-sleek w-52 p-1"
|
||||
align="start"
|
||||
>
|
||||
{transitions.map((transition) => (
|
||||
<button
|
||||
key={transition.id}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
void mutateIssue(
|
||||
'transition',
|
||||
{ transitionId: transition.id },
|
||||
{ status: transition.to }
|
||||
)
|
||||
}
|
||||
className="flex w-full items-center rounded-sm px-2 py-1.5 text-left text-[12px] hover:bg-accent"
|
||||
>
|
||||
{transition.name}
|
||||
</button>
|
||||
))}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<JiraIssueWorkspaceContent
|
||||
displayed={displayed}
|
||||
titleDraft={titleDraft}
|
||||
setTitleDraft={setTitleDraft}
|
||||
labelsDraft={labelsDraft}
|
||||
setLabelsDraft={setLabelsDraft}
|
||||
handleSaveTitle={handleSaveTitle}
|
||||
handleSaveLabels={handleSaveLabels}
|
||||
pendingField={pendingField}
|
||||
comments={comments}
|
||||
commentsError={commentsError}
|
||||
commentsLoading={commentsLoading}
|
||||
retryComments={() => void loadComments(displayed, requestIdRef.current)}
|
||||
onUse={onUse}
|
||||
actionItems={actionItems}
|
||||
/>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
disabled={pendingField === 'priority'}
|
||||
className="rounded-md px-1.5 py-0.5 text-[11px] text-muted-foreground transition hover:bg-muted/40 disabled:opacity-50"
|
||||
>
|
||||
{displayed.priority?.name ??
|
||||
translate('auto.components.JiraIssueWorkspace.51bed73f88', 'No priority')}
|
||||
{pendingField === 'priority' ? (
|
||||
<LoaderCircle className="ml-1 inline size-3 animate-spin" />
|
||||
) : null}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="popover-scroll-content scrollbar-sleek w-48 p-1"
|
||||
align="start"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
void mutateIssue('priority', { priorityId: null }, { priority: undefined })
|
||||
}
|
||||
className="flex w-full items-center rounded-sm px-2 py-1.5 text-left text-[12px] hover:bg-accent"
|
||||
>
|
||||
{translate('auto.components.JiraIssueWorkspace.51bed73f88', 'No priority')}
|
||||
</button>
|
||||
{priorities.map((priority) => (
|
||||
<button
|
||||
key={priority.id}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
void mutateIssue('priority', { priorityId: priority.id }, { priority })
|
||||
}
|
||||
className="flex w-full items-center rounded-sm px-2 py-1.5 text-left text-[12px] hover:bg-accent"
|
||||
>
|
||||
{priority.name}
|
||||
</button>
|
||||
))}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<JiraIssueCommentComposer
|
||||
commentDraft={commentDraft}
|
||||
setCommentDraft={setCommentDraft}
|
||||
commentSubmitting={commentSubmitting}
|
||||
canSubmitComment={canSubmitComment}
|
||||
handleSubmitComment={() => void handleSubmitComment()}
|
||||
/>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
disabled={pendingField === 'assignee'}
|
||||
className="flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[11px] text-muted-foreground transition hover:bg-muted/40 disabled:opacity-50"
|
||||
>
|
||||
{displayed.assignee?.displayName ??
|
||||
translate('auto.components.JiraIssueWorkspace.54649eaeab', '+ Assignee')}
|
||||
{pendingField === 'assignee' ? (
|
||||
<LoaderCircle className="size-3 animate-spin" />
|
||||
) : null}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="popover-scroll-content scrollbar-sleek w-56 p-1"
|
||||
align="start"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
void mutateIssue(
|
||||
'assignee',
|
||||
{ assigneeAccountId: null },
|
||||
{ assignee: undefined }
|
||||
)
|
||||
}
|
||||
className="flex w-full items-center rounded-sm px-2 py-1.5 text-left text-[12px] hover:bg-accent"
|
||||
>
|
||||
{translate('auto.components.JiraIssueWorkspace.0b6b5646ed', 'Unassigned')}
|
||||
</button>
|
||||
{users.map((user) => (
|
||||
<button
|
||||
key={user.accountId}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
void mutateIssue(
|
||||
'assignee',
|
||||
{ assigneeAccountId: user.accountId },
|
||||
{ assignee: user }
|
||||
)
|
||||
}
|
||||
className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left text-[12px] hover:bg-accent"
|
||||
>
|
||||
{user.avatarUrl ? (
|
||||
<img src={user.avatarUrl} alt="" className="size-5 rounded-full" />
|
||||
) : null}
|
||||
<span className="truncate">{user.displayName}</span>
|
||||
</button>
|
||||
))}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<div className="grid min-h-0 flex-1 grid-cols-1 xl:grid-cols-[minmax(0,1fr)_228px]">
|
||||
<div className="min-h-0 overflow-y-auto scrollbar-sleek">
|
||||
<section className="border-b border-border/40 px-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<label className="text-[11px] font-medium text-muted-foreground">
|
||||
{translate('auto.components.JiraIssueWorkspace.444865b4a8', 'Title')}
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={titleDraft}
|
||||
onChange={(event) => setTitleDraft(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' && !event.nativeEvent.isComposing) {
|
||||
event.preventDefault()
|
||||
handleSaveTitle()
|
||||
}
|
||||
}}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleSaveTitle}
|
||||
disabled={pendingField === 'title'}
|
||||
>
|
||||
{pendingField === 'title' ? (
|
||||
<LoaderCircle className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Save className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<label className="mt-2 text-[11px] font-medium text-muted-foreground">
|
||||
{translate('auto.components.JiraIssueWorkspace.aee97b6913', 'Labels')}
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={labelsDraft}
|
||||
onChange={(event) => setLabelsDraft(event.target.value)}
|
||||
placeholder={translate(
|
||||
'auto.components.JiraIssueWorkspace.0f3c07a901',
|
||||
'backend, bug'
|
||||
)}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleSaveLabels}
|
||||
disabled={pendingField === 'labels'}
|
||||
>
|
||||
{pendingField === 'labels' ? (
|
||||
<LoaderCircle className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Save className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="border-b border-border/40 px-4 py-4">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<JiraIcon className="size-3 text-muted-foreground" />
|
||||
<span className="text-xs font-medium text-foreground">
|
||||
{displayed.issueType.name}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{displayed.project.key} ·{' '}
|
||||
{displayed.assignee?.displayName ??
|
||||
translate('auto.components.JiraIssueWorkspace.0b6b5646ed', 'Unassigned')}
|
||||
</span>
|
||||
</div>
|
||||
{displayed.description?.trim() ? (
|
||||
<CommentMarkdown
|
||||
content={displayed.description}
|
||||
variant="document"
|
||||
className="text-[14px] leading-relaxed"
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm italic text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.JiraIssueWorkspace.c4889a47e4',
|
||||
'No description provided.'
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="px-4 py-4">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[13px] font-medium text-foreground">
|
||||
{translate('auto.components.JiraIssueWorkspace.9a980b06b9', 'Comments')}
|
||||
</span>
|
||||
{comments.length > 0 ? (
|
||||
<span className="text-[12px] text-muted-foreground">{comments.length}</span>
|
||||
) : null}
|
||||
</div>
|
||||
{commentsError ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
onClick={() => void loadComments(displayed, requestIdRef.current)}
|
||||
disabled={commentsLoading}
|
||||
className="gap-1"
|
||||
>
|
||||
{commentsLoading ? (
|
||||
<LoaderCircle className="size-3 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="size-3" />
|
||||
)}
|
||||
{translate('auto.components.JiraIssueWorkspace.5cd09beaf9', 'Retry')}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
{commentsError ? (
|
||||
<div className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{commentsError}
|
||||
</div>
|
||||
) : commentsLoading && comments.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<LoaderCircle className="size-4 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : comments.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.JiraIssueWorkspace.9178090e26',
|
||||
'No comments yet.'
|
||||
)}
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
{comments.map((comment) => (
|
||||
<div
|
||||
key={comment.id}
|
||||
className="rounded-md border border-border/50 bg-muted/20"
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2 border-b border-border/40 px-3 py-2">
|
||||
{comment.user?.avatarUrl ? (
|
||||
<img
|
||||
src={comment.user.avatarUrl}
|
||||
alt=""
|
||||
className="size-5 shrink-0 rounded-full"
|
||||
/>
|
||||
) : null}
|
||||
<span className="truncate text-[13px] font-semibold text-foreground">
|
||||
{comment.user?.displayName ??
|
||||
translate(
|
||||
'auto.components.JiraIssueWorkspace.666cfdd835',
|
||||
'Unknown'
|
||||
)}
|
||||
</span>
|
||||
<span className="shrink-0 text-[12px] text-muted-foreground">
|
||||
{formatRelativeTime(comment.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="px-3 py-2">
|
||||
{/* Why: Jira comment screenshots need the same preview
|
||||
affordance without changing compact comment typography. */}
|
||||
<CommentMarkdown
|
||||
content={comment.body}
|
||||
expandImages
|
||||
className="text-[13px] leading-relaxed"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<aside className="border-t border-border/50 bg-muted/20 px-3 py-3 xl:border-l xl:border-t-0">
|
||||
<Button
|
||||
onClick={() => onUse(displayed)}
|
||||
className="mb-3 w-full justify-center gap-2 sm:hidden"
|
||||
>
|
||||
{translate('auto.components.JiraIssueWorkspace.2441be6f9f', 'Start workspace')}
|
||||
<ArrowRight className="size-4" />
|
||||
</Button>
|
||||
<div className="grid gap-1">
|
||||
{actionItems.map((item) => {
|
||||
const Icon = item.icon
|
||||
return (
|
||||
<Tooltip key={item.label}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={item.action}
|
||||
className="flex min-w-0 items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs text-muted-foreground transition hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
<Icon className="size-3.5 shrink-0" />
|
||||
<span className="truncate">{item.label}</span>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left" sideOffset={6}>
|
||||
{item.label}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<div className="flex-none border-t border-border/50 bg-background px-3 py-3">
|
||||
<div className="flex gap-2">
|
||||
<textarea
|
||||
value={commentDraft}
|
||||
onChange={(event) => setCommentDraft(event.target.value)}
|
||||
placeholder={translate(
|
||||
'auto.components.JiraIssueWorkspace.a585fd204e',
|
||||
'Add a Jira comment...'
|
||||
)}
|
||||
rows={2}
|
||||
disabled={commentSubmitting}
|
||||
className="min-h-10 flex-1 resize-none rounded-md border border-input bg-transparent px-3 py-2 text-sm outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50"
|
||||
/>
|
||||
<Button
|
||||
onClick={() => void handleSubmitComment()}
|
||||
disabled={!canSubmitComment || commentSubmitting}
|
||||
className="self-end gap-2"
|
||||
>
|
||||
{commentSubmitting ? (
|
||||
<LoaderCircle className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Send className="size-4" />
|
||||
)}
|
||||
{translate('auto.components.JiraIssueWorkspace.b0b92666c9', 'Comment')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</SheetContent>
|
||||
|
||||
@@ -7,8 +7,6 @@ import {
|
||||
getOpenableAnnotationLine,
|
||||
openAnnotationLocation
|
||||
} from './check-annotation-open'
|
||||
import { formatAnnotationsForClipboard } from './check-run-clipboard-text'
|
||||
import { CheckRunCopyButton } from './CheckRunCopyButton'
|
||||
|
||||
export function CheckRunAnnotations({
|
||||
annotations,
|
||||
@@ -36,29 +34,18 @@ export function CheckRunAnnotations({
|
||||
[worktreeId]
|
||||
)
|
||||
|
||||
const annotationFallback = translate(
|
||||
'auto.components.editor.CheckRunDetailsPanel.cdbfda4dec',
|
||||
'Annotation'
|
||||
)
|
||||
const clipboardText = formatAnnotationsForClipboard(annotations, annotationFallback)
|
||||
|
||||
return (
|
||||
<section className="rounded-md border border-border bg-background">
|
||||
<div className="flex items-center justify-between border-b border-border px-3 py-2">
|
||||
<div className="text-sm font-medium">
|
||||
{translate('auto.components.editor.CheckRunDetailsPanel.f2fe8a4e8f', 'Annotations')}
|
||||
</div>
|
||||
<CheckRunCopyButton
|
||||
text={clipboardText}
|
||||
label={translate('auto.components.editor.CheckRunAnnotations.copy', 'Copy annotations')}
|
||||
/>
|
||||
<div className="border-b border-border px-3 py-2 text-sm font-medium">
|
||||
{translate('auto.components.editor.CheckRunDetailsPanel.f2fe8a4e8f', 'Annotations')}
|
||||
</div>
|
||||
<div className="divide-y divide-border/50">
|
||||
{annotations.map((annotation, index) => {
|
||||
const openable = worktreeId ? getOpenableAnnotationLine(annotation) : null
|
||||
const locationLabel = `${annotation.path ?? annotationFallback}${
|
||||
annotation.startLine ? `:${annotation.startLine}` : ''
|
||||
}`
|
||||
const locationLabel = `${
|
||||
annotation.path ??
|
||||
translate('auto.components.editor.CheckRunDetailsPanel.cdbfda4dec', 'Annotation')
|
||||
}${annotation.startLine ? `:${annotation.startLine}` : ''}`
|
||||
return (
|
||||
<div key={`${annotation.path ?? 'annotation'}-${index}`} className="px-3 py-3">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
import { CheckRunCopyButton } from './CheckRunCopyButton'
|
||||
|
||||
const writeClipboardText = vi.fn<(text: string) => Promise<void>>()
|
||||
|
||||
vi.mock('@/i18n/i18n', () => ({
|
||||
translate: (_key: string, fallback: string) => fallback
|
||||
}))
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
beforeEach(() => {
|
||||
writeClipboardText.mockReset().mockResolvedValue(undefined)
|
||||
Object.assign(window, { api: { ui: { writeClipboardText } } })
|
||||
})
|
||||
|
||||
function renderCopyButton(text: string): void {
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<CheckRunCopyButton text={text} label="Copy annotations" />
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
describe('CheckRunCopyButton', () => {
|
||||
it('copies the card text and shows copied feedback', async () => {
|
||||
renderCopyButton('src/main.ts:10\nBuild failed')
|
||||
|
||||
const copyButton = screen.getByRole('button', { name: 'Copy annotations' })
|
||||
fireEvent.click(copyButton)
|
||||
|
||||
await waitFor(() =>
|
||||
expect(writeClipboardText).toHaveBeenCalledWith('src/main.ts:10\nBuild failed')
|
||||
)
|
||||
expect(await screen.findByRole('button', { name: 'Copied' })).toBeDefined()
|
||||
})
|
||||
|
||||
it('disables the action when there is no card text', () => {
|
||||
renderCopyButton('')
|
||||
|
||||
const copyButton = screen.getByRole('button', { name: 'Nothing to copy' })
|
||||
expect((copyButton as HTMLButtonElement).disabled).toBe(true)
|
||||
fireEvent.click(copyButton)
|
||||
expect(writeClipboardText).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,50 +0,0 @@
|
||||
import React from 'react'
|
||||
import { Check, Copy } from 'lucide-react'
|
||||
import { useClipboardTextCopyFeedback } from '@/hooks/use-clipboard-text-copy-feedback'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
|
||||
export function CheckRunCopyButton({
|
||||
text,
|
||||
label
|
||||
}: {
|
||||
text: string
|
||||
label: string
|
||||
}): React.JSX.Element {
|
||||
const { canCopy, copyText, status } = useClipboardTextCopyFeedback(text)
|
||||
const accessibleLabel =
|
||||
status === 'copied'
|
||||
? translate('auto.components.editor.CheckRunCopyButton.copied', 'Copied')
|
||||
: status === 'failed'
|
||||
? translate('auto.components.editor.CheckRunCopyButton.failed', "Couldn't copy")
|
||||
: canCopy
|
||||
? label
|
||||
: translate('auto.components.editor.CheckRunCopyButton.empty', 'Nothing to copy')
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
aria-label={accessibleLabel}
|
||||
disabled={!canCopy}
|
||||
onClick={() => void copyText()}
|
||||
className={cn(
|
||||
'text-muted-foreground',
|
||||
status === 'copied' && 'text-status-success',
|
||||
status === 'failed' && 'text-destructive'
|
||||
)}
|
||||
>
|
||||
{status === 'copied' ? <Check /> : <Copy />}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={4}>
|
||||
{accessibleLabel}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
@@ -7,10 +7,8 @@ import type { PRCheckDetail, PRCheckRunDetails } from '../../../../shared/github
|
||||
import { SourceControlFixSplitButton } from '@/components/right-sidebar/source-control-fix-split-button'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { useCheckRunDetailsFixWithAI } from './check-run-details-fix-with-ai'
|
||||
import { formatCheckRunOutputForClipboard } from './check-run-clipboard-text'
|
||||
import { CheckRunAnnotations } from './CheckRunAnnotations'
|
||||
import { CheckRunJobs } from './CheckRunJobs'
|
||||
import { CheckRunCopyButton } from './CheckRunCopyButton'
|
||||
|
||||
function formatCheckTimestamp(value: string | null | undefined): string | null {
|
||||
if (!value) {
|
||||
@@ -126,7 +124,6 @@ export function CheckRunDetailsPanel({
|
||||
const hasOutput = Boolean(details?.title || details?.summary || details?.text)
|
||||
const hasAnnotations = (details?.annotations.length ?? 0) > 0
|
||||
const hasJobs = jobs.length > 0
|
||||
const outputClipboardText = details ? formatCheckRunOutputForClipboard(details) : ''
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col bg-editor-surface">
|
||||
@@ -271,17 +268,8 @@ export function CheckRunDetailsPanel({
|
||||
|
||||
{hasOutput && (
|
||||
<section className="rounded-md border border-border bg-background">
|
||||
<div className="flex items-center justify-between border-b border-border px-3 py-2">
|
||||
<div className="text-sm font-medium">
|
||||
{translate('auto.components.editor.CheckRunDetailsPanel.d098e5529a', 'Output')}
|
||||
</div>
|
||||
<CheckRunCopyButton
|
||||
text={outputClipboardText}
|
||||
label={translate(
|
||||
'auto.components.editor.CheckRunDetailsPanel.copyOutput',
|
||||
'Copy output'
|
||||
)}
|
||||
/>
|
||||
<div className="border-b border-border px-3 py-2 text-sm font-medium">
|
||||
{translate('auto.components.editor.CheckRunDetailsPanel.d098e5529a', 'Output')}
|
||||
</div>
|
||||
<div className="px-3 py-3">
|
||||
{details?.title && (
|
||||
|
||||
@@ -5,8 +5,6 @@ import { translate } from '@/i18n/i18n'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { PRCheckJob, PRCheckStep } from '../../../../shared/github/check-types'
|
||||
import { resolveStepOutcome, summarizeJobSteps, type StepOutcome } from './check-job-step-status'
|
||||
import { formatJobsForClipboard } from './check-run-clipboard-text'
|
||||
import { CheckRunCopyButton } from './CheckRunCopyButton'
|
||||
|
||||
function StepOutcomeIcon({ outcome }: { outcome: StepOutcome }): React.JSX.Element {
|
||||
switch (outcome) {
|
||||
@@ -151,23 +149,12 @@ export function CheckRunJobs({
|
||||
jobs: PRCheckJob[]
|
||||
hasFailedJobs: boolean
|
||||
}): React.JSX.Element {
|
||||
const clipboardText = formatJobsForClipboard(
|
||||
jobs,
|
||||
translate('auto.components.editor.CheckRunDetailsPanel.ee07b33924', 'unknown')
|
||||
)
|
||||
|
||||
return (
|
||||
<section className="rounded-md border border-border bg-background">
|
||||
<div className="flex items-center justify-between border-b border-border px-3 py-2">
|
||||
<div className="text-sm font-medium">
|
||||
{hasFailedJobs
|
||||
? translate('auto.components.editor.CheckRunDetailsPanel.066fedd446', 'Failed jobs')
|
||||
: translate('auto.components.editor.CheckRunDetailsPanel.49731703ea', 'Jobs')}
|
||||
</div>
|
||||
<CheckRunCopyButton
|
||||
text={clipboardText}
|
||||
label={translate('auto.components.editor.CheckRunJobs.copy', 'Copy jobs')}
|
||||
/>
|
||||
<div className="border-b border-border px-3 py-2 text-sm font-medium">
|
||||
{hasFailedJobs
|
||||
? translate('auto.components.editor.CheckRunDetailsPanel.066fedd446', 'Failed jobs')
|
||||
: translate('auto.components.editor.CheckRunDetailsPanel.49731703ea', 'Jobs')}
|
||||
</div>
|
||||
<div className="divide-y divide-border/50">
|
||||
{jobs.map((job, index) => (
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { PRCheckAnnotation, PRCheckJob } from '../../../../shared/github/check-types'
|
||||
import {
|
||||
formatAnnotationsForClipboard,
|
||||
formatCheckRunOutputForClipboard,
|
||||
formatJobsForClipboard
|
||||
} from './check-run-clipboard-text'
|
||||
|
||||
describe('check run clipboard text', () => {
|
||||
it('formats all output sections in display order', () => {
|
||||
expect(
|
||||
formatCheckRunOutputForClipboard({
|
||||
title: 'Build failed',
|
||||
summary: 'One test failed.',
|
||||
text: 'Run the suite locally.'
|
||||
})
|
||||
).toBe('Build failed\n\nOne test failed.\n\nRun the suite locally.')
|
||||
|
||||
expect(
|
||||
formatCheckRunOutputForClipboard({ title: null, summary: 'Summary only', text: null })
|
||||
).toBe('Summary only')
|
||||
})
|
||||
|
||||
it('formats annotations with localized missing-path labels and separators', () => {
|
||||
const annotations: PRCheckAnnotation[] = [
|
||||
{
|
||||
path: 'src/main.ts',
|
||||
startLine: 10,
|
||||
endLine: 10,
|
||||
annotationLevel: 'failure',
|
||||
title: 'Type error',
|
||||
message: 'Expected string',
|
||||
rawDetails: 'Actual: number'
|
||||
},
|
||||
{
|
||||
path: null,
|
||||
startLine: null,
|
||||
endLine: null,
|
||||
annotationLevel: null,
|
||||
title: null,
|
||||
message: 'Workflow failed',
|
||||
rawDetails: null
|
||||
}
|
||||
]
|
||||
|
||||
expect(formatAnnotationsForClipboard(annotations, 'Anotacion')).toBe(
|
||||
'src/main.ts:10\nfailure\nType error\nExpected string\nActual: number\n\nAnotacion\nWorkflow failed'
|
||||
)
|
||||
})
|
||||
|
||||
it('formats jobs with localized unknown states and log excerpts', () => {
|
||||
const jobs: PRCheckJob[] = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'unit',
|
||||
status: null,
|
||||
conclusion: 'failure',
|
||||
startedAt: null,
|
||||
completedAt: null,
|
||||
url: null,
|
||||
logTail: 'AssertionError: expected true',
|
||||
steps: [
|
||||
{
|
||||
name: 'test',
|
||||
status: null,
|
||||
conclusion: null,
|
||||
startedAt: null,
|
||||
completedAt: null
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'deploy',
|
||||
status: null,
|
||||
conclusion: null,
|
||||
startedAt: null,
|
||||
completedAt: null,
|
||||
url: null,
|
||||
logTail: null,
|
||||
steps: []
|
||||
}
|
||||
]
|
||||
|
||||
expect(formatJobsForClipboard(jobs, 'desconocido')).toBe(
|
||||
'unit: failure\ntest: desconocido\nAssertionError: expected true\n\ndeploy: desconocido'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,48 +0,0 @@
|
||||
import type {
|
||||
PRCheckAnnotation,
|
||||
PRCheckJob,
|
||||
PRCheckRunDetails
|
||||
} from '../../../../shared/github/check-types'
|
||||
|
||||
export function formatCheckRunOutputForClipboard(
|
||||
details: Pick<PRCheckRunDetails, 'title' | 'summary' | 'text'>
|
||||
): string {
|
||||
return [details.title, details.summary, details.text]
|
||||
.filter((value): value is string => Boolean(value))
|
||||
.join('\n\n')
|
||||
}
|
||||
|
||||
export function formatAnnotationsForClipboard(
|
||||
annotations: PRCheckAnnotation[],
|
||||
annotationFallback: string
|
||||
): string {
|
||||
return annotations
|
||||
.map((annotation) => {
|
||||
const location = `${annotation.path ?? annotationFallback}${
|
||||
annotation.startLine ? `:${annotation.startLine}` : ''
|
||||
}`
|
||||
return [
|
||||
location,
|
||||
annotation.annotationLevel,
|
||||
annotation.title,
|
||||
annotation.message,
|
||||
annotation.rawDetails
|
||||
]
|
||||
.filter((value): value is string => Boolean(value))
|
||||
.join('\n')
|
||||
})
|
||||
.join('\n\n')
|
||||
}
|
||||
|
||||
export function formatJobsForClipboard(jobs: PRCheckJob[], unknownLabel: string): string {
|
||||
return jobs
|
||||
.map((job) => {
|
||||
const steps = job.steps.map(
|
||||
(step) => `${step.name}: ${step.conclusion ?? step.status ?? unknownLabel}`
|
||||
)
|
||||
return [`${job.name}: ${job.conclusion ?? job.status ?? unknownLabel}`, ...steps, job.logTail]
|
||||
.filter((value): value is string => Boolean(value))
|
||||
.join('\n')
|
||||
})
|
||||
.join('\n\n')
|
||||
}
|
||||
@@ -2,25 +2,11 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
extractDetailsSummaryHtml,
|
||||
isEditableDetailsHtmlBlock,
|
||||
matchDetailsHtmlBlock,
|
||||
parseDetailsAttributes,
|
||||
parseToggleHeadingVariant,
|
||||
type DetailsHtmlBlock
|
||||
} from './details-markdown-html'
|
||||
|
||||
function nestedToggles(depth: number): string {
|
||||
let html = '<details class="orca-details" open>\n<summary>leaf</summary>\n\nBody\n\n</details>'
|
||||
for (let level = depth - 1; level > 0; level -= 1) {
|
||||
html = `<details class="orca-details" open>\n<summary>level ${level}</summary>\n\n${html}\n\n</details>`
|
||||
}
|
||||
return html
|
||||
}
|
||||
|
||||
function isEditableHtml(html: string): boolean {
|
||||
const block = matchDetailsHtmlBlock(html, 0)
|
||||
return block !== null && isEditableDetailsHtmlBlock(block)
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
@@ -48,7 +34,8 @@ describe('details markdown html', () => {
|
||||
const block: DetailsHtmlBlock = {
|
||||
raw: '',
|
||||
openingAttributes: '',
|
||||
inner: `<summary>${'Heading line\n'.repeat(1_000)}</summary><p>Body</p>`
|
||||
inner: `<summary>${'Heading line\n'.repeat(1_000)}</summary><p>Body</p>`,
|
||||
hasNestedDetails: false
|
||||
}
|
||||
|
||||
expect(isEditableDetailsHtmlBlock(block)).toBe(true)
|
||||
@@ -74,67 +61,17 @@ describe('details markdown html', () => {
|
||||
const editableHeading5: DetailsHtmlBlock = {
|
||||
raw: '',
|
||||
openingAttributes: ' data-orca-toggle="heading-5"',
|
||||
inner: '<summary>Toggle</summary><p>Body</p>'
|
||||
inner: '<summary>Toggle</summary><p>Body</p>',
|
||||
hasNestedDetails: false
|
||||
}
|
||||
const unsupportedHeading6: DetailsHtmlBlock = {
|
||||
raw: '',
|
||||
openingAttributes: ' data-orca-toggle="heading-6"',
|
||||
inner: '<summary>Toggle</summary><p>Body</p>'
|
||||
inner: '<summary>Toggle</summary><p>Body</p>',
|
||||
hasNestedDetails: false
|
||||
}
|
||||
|
||||
expect(isEditableDetailsHtmlBlock(editableHeading5)).toBe(true)
|
||||
expect(isEditableDetailsHtmlBlock(unsupportedHeading6)).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts nested toggles whose descendants are all editable', () => {
|
||||
expect(isEditableHtml(nestedToggles(2))).toBe(true)
|
||||
expect(isEditableHtml(nestedToggles(16))).toBe(true)
|
||||
})
|
||||
|
||||
it('bounds each of many sibling nested toggles independently', () => {
|
||||
const siblings = (count: number, extra = ''): string =>
|
||||
Array.from(
|
||||
{ length: count },
|
||||
(_, index) =>
|
||||
`<details class="orca-details"${extra}>\n<summary>sibling ${index}</summary>\n\nBody\n\n</details>`
|
||||
).join('\n\n')
|
||||
const wrap = (body: string): string =>
|
||||
`<details class="orca-details">\n<summary>Outer</summary>\n\n${body}\n\n</details>`
|
||||
|
||||
expect(isEditableHtml(wrap(siblings(40)))).toBe(true)
|
||||
// A single non-editable sibling must still reject, so sharing fence ranges
|
||||
// cannot make later siblings inherit an earlier sibling's boundaries.
|
||||
expect(isEditableHtml(wrap(`${siblings(20)}\n\n${siblings(1, ' id="x"')}`))).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects a toggle whose nested toggle is not itself editable', () => {
|
||||
const block: DetailsHtmlBlock = {
|
||||
raw: '',
|
||||
openingAttributes: ' class="orca-details"',
|
||||
inner: '<summary>Outer</summary><details id="x"><summary>Inner</summary><p>Body</p></details>'
|
||||
}
|
||||
|
||||
expect(isEditableDetailsHtmlBlock(block)).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects nesting past the recursion guard instead of recursing without bound', () => {
|
||||
expect(isEditableHtml(nestedToggles(17))).toBe(false)
|
||||
expect(isEditableHtml(nestedToggles(400))).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects unbalanced and non-toggle tags that start with details', () => {
|
||||
const unbalanced: DetailsHtmlBlock = {
|
||||
raw: '',
|
||||
openingAttributes: '',
|
||||
inner: '<summary>Outer</summary><details><summary>Inner</summary>'
|
||||
}
|
||||
const lookalike: DetailsHtmlBlock = {
|
||||
raw: '',
|
||||
openingAttributes: '',
|
||||
inner: '<summary>Outer</summary><detailsish>Body</detailsish>'
|
||||
}
|
||||
|
||||
expect(isEditableDetailsHtmlBlock(unbalanced)).toBe(false)
|
||||
expect(isEditableDetailsHtmlBlock(lookalike)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -34,12 +34,9 @@ export type DetailsHtmlBlock = {
|
||||
raw: string
|
||||
openingAttributes: string
|
||||
inner: string
|
||||
hasNestedDetails: boolean
|
||||
}
|
||||
|
||||
// Fence ranges depend only on the scanned string, so callers scanning one body
|
||||
// repeatedly compute them once and share them across sibling matches.
|
||||
export type MarkdownFenceRanges = readonly (readonly [number, number])[]
|
||||
|
||||
export type DetailsSummaryHtml = {
|
||||
attributes: string
|
||||
content: string
|
||||
@@ -91,7 +88,7 @@ export function renderDetailsAttributes(attrs: Record<string, unknown> | undefin
|
||||
return attributes.join(' ')
|
||||
}
|
||||
|
||||
function markdownFenceRanges(content: string): MarkdownFenceRanges {
|
||||
function markdownFenceRanges(content: string): [number, number][] {
|
||||
const ranges: [number, number][] = []
|
||||
let offset = 0
|
||||
let openFence: { marker: '`' | '~'; length: number; start: number } | null = null
|
||||
@@ -133,15 +130,11 @@ function markdownFenceRanges(content: string): MarkdownFenceRanges {
|
||||
return ranges
|
||||
}
|
||||
|
||||
function isInsideRange(index: number, ranges: MarkdownFenceRanges): boolean {
|
||||
function isInsideRange(index: number, ranges: [number, number][]): boolean {
|
||||
return ranges.some(([start, end]) => index >= start && index < end)
|
||||
}
|
||||
|
||||
export function matchDetailsHtmlBlock(
|
||||
content: string,
|
||||
start: number,
|
||||
precomputedFenceRanges?: MarkdownFenceRanges
|
||||
): DetailsHtmlBlock | null {
|
||||
export function matchDetailsHtmlBlock(content: string, start: number): DetailsHtmlBlock | null {
|
||||
const openingMatch = content.slice(start).match(/^<details\b[^>]*>/i)
|
||||
if (!openingMatch) {
|
||||
return null
|
||||
@@ -149,9 +142,10 @@ export function matchDetailsHtmlBlock(
|
||||
|
||||
const detailsTagPattern = /<\/?details\b[^>]*>/gi
|
||||
detailsTagPattern.lastIndex = start
|
||||
const fenceRanges = precomputedFenceRanges ?? markdownFenceRanges(content)
|
||||
const fenceRanges = markdownFenceRanges(content)
|
||||
|
||||
let depth = 0
|
||||
let hasNestedDetails = false
|
||||
|
||||
for (;;) {
|
||||
const tagMatch = detailsTagPattern.exec(content)
|
||||
@@ -173,10 +167,14 @@ export function matchDetailsHtmlBlock(
|
||||
return {
|
||||
raw: content.slice(start, closingEnd),
|
||||
openingAttributes: openingMatch[0].replace(/^<details\b/i, '').replace(/>$/u, ''),
|
||||
inner: content.slice(start + openingMatch[0].length, tagMatch.index)
|
||||
inner: content.slice(start + openingMatch[0].length, tagMatch.index),
|
||||
hasNestedDetails
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (depth > 0) {
|
||||
hasNestedDetails = true
|
||||
}
|
||||
depth += 1
|
||||
}
|
||||
}
|
||||
@@ -269,40 +267,11 @@ function isHtmlTagNamePart(code: number): boolean {
|
||||
)
|
||||
}
|
||||
|
||||
// Why: nested toggles validate recursively and each level rescans its own body,
|
||||
// so a pathological file would otherwise blow the stack or go quadratic.
|
||||
const MAX_DETAILS_NESTING_LEVELS = 16
|
||||
|
||||
// Removes nested toggles that are themselves editable, so the caller's raw-tag
|
||||
// scan sees only the body's own markup. Null when a nested toggle can't be one.
|
||||
function stripEditableNestedDetails(bodyHtml: string, nestingLevel: number): string | null {
|
||||
let result = ''
|
||||
let index = 0
|
||||
// Why: without sharing this, N sibling toggles rescan the whole body N times.
|
||||
let fenceRanges: MarkdownFenceRanges | null = null
|
||||
|
||||
for (;;) {
|
||||
const nestedStart = indexOfAsciiIgnoreCase(bodyHtml, '<details', index)
|
||||
if (nestedStart === -1) {
|
||||
return result + bodyHtml.slice(index)
|
||||
}
|
||||
|
||||
if (nestingLevel >= MAX_DETAILS_NESTING_LEVELS) {
|
||||
return null
|
||||
}
|
||||
|
||||
fenceRanges ??= markdownFenceRanges(bodyHtml)
|
||||
const nested = matchDetailsHtmlBlock(bodyHtml, nestedStart, fenceRanges)
|
||||
if (!nested || !isEditableDetailsHtmlBlock(nested, nestingLevel + 1)) {
|
||||
return null
|
||||
}
|
||||
|
||||
result += bodyHtml.slice(index, nestedStart)
|
||||
index = nestedStart + nested.raw.length
|
||||
export function isEditableDetailsHtmlBlock(block: DetailsHtmlBlock): boolean {
|
||||
if (block.hasNestedDetails) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function isEditableDetailsHtmlBlock(block: DetailsHtmlBlock, nestingLevel = 1): boolean {
|
||||
if (!hasOnlySupportedDetailsAttributes(block.openingAttributes)) {
|
||||
return false
|
||||
}
|
||||
@@ -320,11 +289,7 @@ export function isEditableDetailsHtmlBlock(block: DetailsHtmlBlock, nestingLevel
|
||||
return false
|
||||
}
|
||||
|
||||
const bodyHtml = stripEditableNestedDetails(block.inner.slice(summary.rawLength), nestingLevel)
|
||||
if (bodyHtml === null) {
|
||||
return false
|
||||
}
|
||||
|
||||
const bodyHtml = block.inner.slice(summary.rawLength)
|
||||
if (!hasOnlyPlainParagraphAndBreakTags(bodyHtml)) {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -186,74 +186,9 @@ describe('rich markdown round trip', () => {
|
||||
expect(roundTripMarkdown(input)).toBe(input.trimEnd())
|
||||
})
|
||||
|
||||
it('reopens nested toggles as editable details blocks', () => {
|
||||
expect(
|
||||
roundTripMarkdown(
|
||||
'<details><summary>Outer</summary><details><summary>Inner</summary><p>Body</p></details></details>\n'
|
||||
)
|
||||
).toBe(
|
||||
[
|
||||
'<details class="orca-details">',
|
||||
'<summary>Outer</summary>',
|
||||
'',
|
||||
'<details class="orca-details">',
|
||||
'<summary>Inner</summary>',
|
||||
'',
|
||||
'Body',
|
||||
'',
|
||||
'</details>',
|
||||
'',
|
||||
'</details>'
|
||||
].join('\n')
|
||||
)
|
||||
})
|
||||
|
||||
it('round-trips an orca-authored nested toggle unchanged', () => {
|
||||
const input = [
|
||||
'<details class="orca-details" data-orca-toggle="heading-3" open>',
|
||||
'<summary>08/26/2026</summary>',
|
||||
'',
|
||||
'<details class="orca-details" open>',
|
||||
'<summary>goals</summary>',
|
||||
'',
|
||||
'- Read X post',
|
||||
' - Collab',
|
||||
'',
|
||||
'</details>',
|
||||
'',
|
||||
'- after inner',
|
||||
'',
|
||||
'</details>',
|
||||
''
|
||||
].join('\n')
|
||||
expect(roundTripMarkdown(input)).toBe(input.trimEnd())
|
||||
})
|
||||
|
||||
it('keeps nested toggle bodies editable rather than inert raw html', () => {
|
||||
const markdown = markdownAfterTextReplace(
|
||||
[
|
||||
'<details class="orca-details" open>',
|
||||
'<summary>Outer</summary>',
|
||||
'',
|
||||
'<details class="orca-details" open>',
|
||||
'<summary>Inner</summary>',
|
||||
'',
|
||||
'Body',
|
||||
'',
|
||||
'</details>',
|
||||
'',
|
||||
'</details>',
|
||||
''
|
||||
].join('\n'),
|
||||
'Body',
|
||||
'Edited body'
|
||||
)
|
||||
expect(markdown).toContain('Edited body')
|
||||
})
|
||||
|
||||
it('preserves a nested toggle that is not itself editable as passthrough html', () => {
|
||||
it('preserves nested details blocks as passthrough html', () => {
|
||||
const input =
|
||||
'<details><summary>Outer</summary><details id="x"><summary>Inner</summary><p>Body</p></details></details>\n'
|
||||
'<details><summary>Outer</summary><details><summary>Inner</summary><p>Body</p></details></details>\n'
|
||||
expect(roundTripMarkdown(input)).toBe(input.trimEnd())
|
||||
})
|
||||
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
import { Clipboard, ExternalLink, GitBranch } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import type { JiraIssue } from '../../../shared/jira-types'
|
||||
import type { JiraIssueWorkspaceAction } from './jira-issue-workspace-content'
|
||||
|
||||
function buildJiraBranchName(issue: JiraIssue): string {
|
||||
const slug = issue.title
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 52)
|
||||
return `${issue.key.toLowerCase()}${slug ? `-${slug}` : ''}`
|
||||
}
|
||||
|
||||
async function copyTextToClipboard(text: string, label: string): Promise<void> {
|
||||
try {
|
||||
await window.api.ui.writeClipboardText(text)
|
||||
toast.success(
|
||||
translate('auto.components.JiraIssueWorkspace.2ff69a3545', '{{value0}} copied', {
|
||||
value0: label
|
||||
})
|
||||
)
|
||||
} catch {
|
||||
toast.error(
|
||||
translate('auto.components.JiraIssueWorkspace.6c41a9bcea', 'Failed to copy {{value0}}', {
|
||||
value0: label.toLowerCase()
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export function getJiraIssueWorkspaceActions(issue: JiraIssue): JiraIssueWorkspaceAction[] {
|
||||
return [
|
||||
{
|
||||
label: translate('auto.components.JiraIssueWorkspace.69da9a208c', 'Open in Jira'),
|
||||
icon: ExternalLink,
|
||||
action: () => window.api.shell.openUrl(issue.url)
|
||||
},
|
||||
{
|
||||
label: translate('auto.components.JiraIssueWorkspace.779bb91ee0', 'Copy URL'),
|
||||
icon: Clipboard,
|
||||
action: () => void copyTextToClipboard(issue.url, 'URL')
|
||||
},
|
||||
{
|
||||
label: translate('auto.components.JiraIssueWorkspace.38839801e8', 'Copy key'),
|
||||
icon: Clipboard,
|
||||
action: () => void copyTextToClipboard(issue.key, 'Key')
|
||||
},
|
||||
{
|
||||
label: translate(
|
||||
'auto.components.JiraIssueWorkspace.80efa101c5',
|
||||
'Copy suggested branch name'
|
||||
),
|
||||
icon: GitBranch,
|
||||
action: () => void copyTextToClipboard(buildJiraBranchName(issue), 'Branch name')
|
||||
},
|
||||
{
|
||||
label: translate('auto.components.JiraIssueWorkspace.0cc62bd690', 'Copy prompt'),
|
||||
icon: Clipboard,
|
||||
action: () =>
|
||||
void copyTextToClipboard(
|
||||
`Complete Jira issue ${issue.key}: ${issue.title}\n\n${issue.url}`,
|
||||
'Prompt'
|
||||
)
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,225 +0,0 @@
|
||||
import { ArrowRight, LoaderCircle, X } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { formatUiRelativeTimeFromDate } from '@/i18n/relative-time-format'
|
||||
import type {
|
||||
JiraIssue,
|
||||
JiraIssueUpdate,
|
||||
JiraPriority,
|
||||
JiraTransition,
|
||||
JiraUser
|
||||
} from '../../../shared/jira-types'
|
||||
|
||||
function jiraStatusClass(categoryKey: string): string {
|
||||
if (categoryKey === 'done') {
|
||||
return 'border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-200'
|
||||
}
|
||||
if (categoryKey === 'indeterminate') {
|
||||
return 'border-sky-500/30 bg-sky-500/10 text-sky-700 dark:text-sky-200'
|
||||
}
|
||||
return 'border-border/50 bg-muted/40 text-muted-foreground'
|
||||
}
|
||||
|
||||
export function JiraIssueWorkspaceHeader({
|
||||
displayed,
|
||||
issueLoading,
|
||||
onUse,
|
||||
onClose
|
||||
}: {
|
||||
displayed: JiraIssue
|
||||
issueLoading: boolean
|
||||
onUse: (issue: JiraIssue) => void
|
||||
onClose: () => void
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<div className="flex-none border-b border-border/50 bg-muted/30 px-4 py-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-[11px] text-muted-foreground">
|
||||
<span className="font-mono">{displayed.key}</span>
|
||||
{displayed.siteName ? <span>{displayed.siteName}</span> : null}
|
||||
<span>{displayed.project.key}</span>
|
||||
<span>{formatUiRelativeTimeFromDate(displayed.updatedAt)}</span>
|
||||
{issueLoading ? <LoaderCircle className="size-3 animate-spin" /> : null}
|
||||
</div>
|
||||
<h2 className="mt-1 text-[20px] font-semibold leading-tight text-foreground">
|
||||
{displayed.title}
|
||||
</h2>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => onUse(displayed)}
|
||||
className="hidden shrink-0 gap-2 sm:inline-flex"
|
||||
size="sm"
|
||||
>
|
||||
{translate('auto.components.JiraIssueWorkspace.2441be6f9f', 'Start workspace')}
|
||||
<ArrowRight className="size-4" />
|
||||
</Button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="shrink-0"
|
||||
onClick={onClose}
|
||||
aria-label={translate(
|
||||
'auto.components.JiraIssueWorkspace.76513c7898',
|
||||
'Close Jira issue preview'
|
||||
)}
|
||||
>
|
||||
<X className="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={6}>
|
||||
{translate('auto.components.JiraIssueWorkspace.7a96985ca0', 'Close')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function JiraIssueMetadataBar({
|
||||
displayed,
|
||||
pendingField,
|
||||
transitions,
|
||||
priorities,
|
||||
users,
|
||||
mutateIssue
|
||||
}: {
|
||||
displayed: JiraIssue
|
||||
pendingField: string | null
|
||||
transitions: JiraTransition[]
|
||||
priorities: JiraPriority[]
|
||||
users: JiraUser[]
|
||||
mutateIssue: (
|
||||
field: string,
|
||||
updates: JiraIssueUpdate,
|
||||
optimistic?: Partial<JiraIssue>
|
||||
) => Promise<void>
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-2 border-b border-border/60 px-4 py-2.5">
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
disabled={pendingField === 'transition' || transitions.length === 0}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 text-[11px] font-medium transition hover:opacity-80 disabled:opacity-50',
|
||||
jiraStatusClass(displayed.status.categoryKey)
|
||||
)}
|
||||
>
|
||||
{displayed.status.name}
|
||||
{pendingField === 'transition' ? (
|
||||
<LoaderCircle className="size-3 animate-spin" />
|
||||
) : null}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="popover-scroll-content scrollbar-sleek w-52 p-1" align="start">
|
||||
{transitions.map((transition) => (
|
||||
<button
|
||||
key={transition.id}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
void mutateIssue(
|
||||
'transition',
|
||||
{ transitionId: transition.id },
|
||||
{ status: transition.to }
|
||||
)
|
||||
}
|
||||
className="flex w-full items-center rounded-sm px-2 py-1.5 text-left text-[12px] hover:bg-accent"
|
||||
>
|
||||
{transition.name}
|
||||
</button>
|
||||
))}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
disabled={pendingField === 'priority'}
|
||||
className="rounded-md px-1.5 py-0.5 text-[11px] text-muted-foreground transition hover:bg-muted/40 disabled:opacity-50"
|
||||
>
|
||||
{displayed.priority?.name ??
|
||||
translate('auto.components.JiraIssueWorkspace.51bed73f88', 'No priority')}
|
||||
{pendingField === 'priority' ? (
|
||||
<LoaderCircle className="ml-1 inline size-3 animate-spin" />
|
||||
) : null}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="popover-scroll-content scrollbar-sleek w-48 p-1" align="start">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
void mutateIssue('priority', { priorityId: null }, { priority: undefined })
|
||||
}
|
||||
className="flex w-full items-center rounded-sm px-2 py-1.5 text-left text-[12px] hover:bg-accent"
|
||||
>
|
||||
{translate('auto.components.JiraIssueWorkspace.51bed73f88', 'No priority')}
|
||||
</button>
|
||||
{priorities.map((priority) => (
|
||||
<button
|
||||
key={priority.id}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
void mutateIssue('priority', { priorityId: priority.id }, { priority })
|
||||
}
|
||||
className="flex w-full items-center rounded-sm px-2 py-1.5 text-left text-[12px] hover:bg-accent"
|
||||
>
|
||||
{priority.name}
|
||||
</button>
|
||||
))}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
disabled={pendingField === 'assignee'}
|
||||
className="flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[11px] text-muted-foreground transition hover:bg-muted/40 disabled:opacity-50"
|
||||
>
|
||||
{displayed.assignee?.displayName ??
|
||||
translate('auto.components.JiraIssueWorkspace.54649eaeab', '+ Assignee')}
|
||||
{pendingField === 'assignee' ? <LoaderCircle className="size-3 animate-spin" /> : null}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="popover-scroll-content scrollbar-sleek w-56 p-1" align="start">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
void mutateIssue('assignee', { assigneeAccountId: null }, { assignee: undefined })
|
||||
}
|
||||
className="flex w-full items-center rounded-sm px-2 py-1.5 text-left text-[12px] hover:bg-accent"
|
||||
>
|
||||
{translate('auto.components.JiraIssueWorkspace.0b6b5646ed', 'Unassigned')}
|
||||
</button>
|
||||
{users.map((user) => (
|
||||
<button
|
||||
key={user.accountId}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
void mutateIssue(
|
||||
'assignee',
|
||||
{ assigneeAccountId: user.accountId },
|
||||
{ assignee: user }
|
||||
)
|
||||
}
|
||||
className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left text-[12px] hover:bg-accent"
|
||||
>
|
||||
{user.avatarUrl ? (
|
||||
<img src={user.avatarUrl} alt="" className="size-5 rounded-full" />
|
||||
) : null}
|
||||
<span className="truncate">{user.displayName}</span>
|
||||
</button>
|
||||
))}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,289 +0,0 @@
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import { ArrowRight, LoaderCircle, RefreshCw, Save, Send } from 'lucide-react'
|
||||
import CommentMarkdown from '@/components/sidebar/CommentMarkdown'
|
||||
import { JiraIcon } from '@/components/icons/JiraIcon'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { formatUiRelativeTimeFromDate } from '@/i18n/relative-time-format'
|
||||
import type { JiraComment, JiraIssue } from '../../../shared/jira-types'
|
||||
|
||||
export type JiraIssueWorkspaceAction = {
|
||||
label: string
|
||||
icon: LucideIcon
|
||||
action: () => void
|
||||
}
|
||||
|
||||
export function JiraIssueWorkspaceContent({
|
||||
displayed,
|
||||
titleDraft,
|
||||
setTitleDraft,
|
||||
labelsDraft,
|
||||
setLabelsDraft,
|
||||
handleSaveTitle,
|
||||
handleSaveLabels,
|
||||
pendingField,
|
||||
comments,
|
||||
commentsError,
|
||||
commentsLoading,
|
||||
retryComments,
|
||||
onUse,
|
||||
actionItems
|
||||
}: {
|
||||
displayed: JiraIssue
|
||||
titleDraft: string
|
||||
setTitleDraft: (value: string) => void
|
||||
labelsDraft: string
|
||||
setLabelsDraft: (value: string) => void
|
||||
handleSaveTitle: () => void
|
||||
handleSaveLabels: () => void
|
||||
pendingField: string | null
|
||||
comments: JiraComment[]
|
||||
commentsError: string | null
|
||||
commentsLoading: boolean
|
||||
retryComments: () => void
|
||||
onUse: (issue: JiraIssue) => void
|
||||
actionItems: JiraIssueWorkspaceAction[]
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<div className="grid min-h-0 flex-1 grid-cols-1 xl:grid-cols-[minmax(0,1fr)_228px]">
|
||||
<div className="min-h-0 overflow-y-auto scrollbar-sleek">
|
||||
<section className="border-b border-border/40 px-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<label className="text-[11px] font-medium text-muted-foreground">
|
||||
{translate('auto.components.JiraIssueWorkspace.444865b4a8', 'Title')}
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={titleDraft}
|
||||
onChange={(event) => setTitleDraft(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' && !event.nativeEvent.isComposing) {
|
||||
event.preventDefault()
|
||||
handleSaveTitle()
|
||||
}
|
||||
}}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleSaveTitle}
|
||||
disabled={pendingField === 'title'}
|
||||
>
|
||||
{pendingField === 'title' ? (
|
||||
<LoaderCircle className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Save className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<label className="mt-2 text-[11px] font-medium text-muted-foreground">
|
||||
{translate('auto.components.JiraIssueWorkspace.aee97b6913', 'Labels')}
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={labelsDraft}
|
||||
onChange={(event) => setLabelsDraft(event.target.value)}
|
||||
placeholder={translate(
|
||||
'auto.components.JiraIssueWorkspace.0f3c07a901',
|
||||
'backend, bug'
|
||||
)}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleSaveLabels}
|
||||
disabled={pendingField === 'labels'}
|
||||
>
|
||||
{pendingField === 'labels' ? (
|
||||
<LoaderCircle className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Save className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="border-b border-border/40 px-4 py-4">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<JiraIcon className="size-3 text-muted-foreground" />
|
||||
<span className="text-xs font-medium text-foreground">{displayed.issueType.name}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{displayed.project.key} ·{' '}
|
||||
{displayed.assignee?.displayName ??
|
||||
translate('auto.components.JiraIssueWorkspace.0b6b5646ed', 'Unassigned')}
|
||||
</span>
|
||||
</div>
|
||||
{displayed.description?.trim() ? (
|
||||
<CommentMarkdown
|
||||
content={displayed.description}
|
||||
variant="document"
|
||||
className="text-[14px] leading-relaxed"
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm italic text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.JiraIssueWorkspace.c4889a47e4',
|
||||
'No description provided.'
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="px-4 py-4">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[13px] font-medium text-foreground">
|
||||
{translate('auto.components.JiraIssueWorkspace.9a980b06b9', 'Comments')}
|
||||
</span>
|
||||
{comments.length > 0 ? (
|
||||
<span className="text-[12px] text-muted-foreground">{comments.length}</span>
|
||||
) : null}
|
||||
</div>
|
||||
{commentsError ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
onClick={retryComments}
|
||||
disabled={commentsLoading}
|
||||
className="gap-1"
|
||||
>
|
||||
{commentsLoading ? (
|
||||
<LoaderCircle className="size-3 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="size-3" />
|
||||
)}
|
||||
{translate('auto.components.JiraIssueWorkspace.5cd09beaf9', 'Retry')}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
{commentsError ? (
|
||||
<div className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{commentsError}
|
||||
</div>
|
||||
) : commentsLoading && comments.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<LoaderCircle className="size-4 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : comments.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{translate('auto.components.JiraIssueWorkspace.9178090e26', 'No comments yet.')}
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
{comments.map((comment) => (
|
||||
<div key={comment.id} className="rounded-md border border-border/50 bg-muted/20">
|
||||
<div className="flex min-w-0 items-center gap-2 border-b border-border/40 px-3 py-2">
|
||||
{comment.user?.avatarUrl ? (
|
||||
<img
|
||||
src={comment.user.avatarUrl}
|
||||
alt=""
|
||||
className="size-5 shrink-0 rounded-full"
|
||||
/>
|
||||
) : null}
|
||||
<span className="truncate text-[13px] font-semibold text-foreground">
|
||||
{comment.user?.displayName ??
|
||||
translate('auto.components.JiraIssueWorkspace.666cfdd835', 'Unknown')}
|
||||
</span>
|
||||
<span className="shrink-0 text-[12px] text-muted-foreground">
|
||||
{formatUiRelativeTimeFromDate(comment.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="px-3 py-2">
|
||||
{/* Why: Jira comment screenshots need the same preview
|
||||
affordance without changing compact comment typography. */}
|
||||
<CommentMarkdown
|
||||
content={comment.body}
|
||||
expandImages
|
||||
className="text-[13px] leading-relaxed"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<aside className="border-t border-border/50 bg-muted/20 px-3 py-3 xl:border-l xl:border-t-0">
|
||||
<Button
|
||||
onClick={() => onUse(displayed)}
|
||||
className="mb-3 w-full justify-center gap-2 sm:hidden"
|
||||
>
|
||||
{translate('auto.components.JiraIssueWorkspace.2441be6f9f', 'Start workspace')}
|
||||
<ArrowRight className="size-4" />
|
||||
</Button>
|
||||
<div className="grid gap-1">
|
||||
{actionItems.map((item) => {
|
||||
const Icon = item.icon
|
||||
return (
|
||||
<Tooltip key={item.label}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={item.action}
|
||||
className="flex min-w-0 items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs text-muted-foreground transition hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
<Icon className="size-3.5 shrink-0" />
|
||||
<span className="truncate">{item.label}</span>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left" sideOffset={6}>
|
||||
{item.label}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function JiraIssueCommentComposer({
|
||||
commentDraft,
|
||||
setCommentDraft,
|
||||
commentSubmitting,
|
||||
canSubmitComment,
|
||||
handleSubmitComment
|
||||
}: {
|
||||
commentDraft: string
|
||||
setCommentDraft: (value: string) => void
|
||||
commentSubmitting: boolean
|
||||
canSubmitComment: boolean
|
||||
handleSubmitComment: () => void
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<div className="flex-none border-t border-border/50 bg-background px-3 py-3">
|
||||
<div className="flex gap-2">
|
||||
<textarea
|
||||
value={commentDraft}
|
||||
onChange={(event) => setCommentDraft(event.target.value)}
|
||||
placeholder={translate(
|
||||
'auto.components.JiraIssueWorkspace.a585fd204e',
|
||||
'Add a Jira comment...'
|
||||
)}
|
||||
rows={2}
|
||||
disabled={commentSubmitting}
|
||||
className="min-h-10 flex-1 resize-none rounded-md border border-input bg-transparent px-3 py-2 text-sm outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50"
|
||||
/>
|
||||
<Button
|
||||
onClick={handleSubmitComment}
|
||||
disabled={!canSubmitComment || commentSubmitting}
|
||||
className="self-end gap-2"
|
||||
>
|
||||
{commentSubmitting ? (
|
||||
<LoaderCircle className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Send className="size-4" />
|
||||
)}
|
||||
{translate('auto.components.JiraIssueWorkspace.b0b92666c9', 'Comment')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -14678,16 +14678,14 @@
|
||||
"d5a8c2f1b9": "Start the default AI agent to fix this check",
|
||||
"e2b4d7c8a1": "Choose an agent for this check",
|
||||
"f1c9e3a6d4": "Choose agent to fix check",
|
||||
"actionRequired": "Action required",
|
||||
"copyOutput": "Copy output"
|
||||
"actionRequired": "Action required"
|
||||
},
|
||||
"CheckRunJobs": {
|
||||
"1c0a4d7e02": "succeeded",
|
||||
"2d3b8f1a55": "skipped",
|
||||
"3e6c9a2b71": "pending",
|
||||
"4f7d0c3e88": "steps failed",
|
||||
"5a8e1d4f23": " · ",
|
||||
"copy": "Copy jobs"
|
||||
"5a8e1d4f23": " · "
|
||||
},
|
||||
"check": {
|
||||
"run": {
|
||||
@@ -14767,14 +14765,6 @@
|
||||
"insertColumnRight": "Insert column right",
|
||||
"deleteRow": "Delete row",
|
||||
"deleteColumn": "Delete column"
|
||||
},
|
||||
"CheckRunAnnotations": {
|
||||
"copy": "Copy annotations"
|
||||
},
|
||||
"CheckRunCopyButton": {
|
||||
"copied": "Copied",
|
||||
"failed": "Couldn't copy",
|
||||
"empty": "Nothing to copy"
|
||||
}
|
||||
},
|
||||
"diff": {
|
||||
|
||||
@@ -1,139 +0,0 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { mkdir, rm, writeFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import type { Locator, Page } from '@stablyai/playwright-test'
|
||||
import { expect } from '@stablyai/playwright-test'
|
||||
|
||||
const MARKDOWN_HYDRATION_TIMEOUT_MS = 25_000
|
||||
|
||||
export type ActiveWorktreeContext = {
|
||||
worktreeId: string
|
||||
rootPath: string
|
||||
}
|
||||
|
||||
export type ActiveEditorFile = {
|
||||
filePath: string
|
||||
}
|
||||
|
||||
export async function getActiveWorktreeContext(page: Page): Promise<ActiveWorktreeContext> {
|
||||
return page.evaluate(() => {
|
||||
const store = window.__store
|
||||
if (!store) {
|
||||
throw new Error('window.__store is not available')
|
||||
}
|
||||
|
||||
const state = store.getState()
|
||||
const worktreeId = state.activeWorktreeId
|
||||
if (!worktreeId) {
|
||||
throw new Error('No active worktree is selected')
|
||||
}
|
||||
|
||||
const worktree = Object.values(state.worktreesByRepo)
|
||||
.flat()
|
||||
.find((entry) => entry.id === worktreeId)
|
||||
if (!worktree) {
|
||||
throw new Error(`Active worktree was not found in store: ${worktreeId}`)
|
||||
}
|
||||
|
||||
return { worktreeId, rootPath: worktree.path }
|
||||
})
|
||||
}
|
||||
|
||||
export async function createMarkdownFixture(
|
||||
context: ActiveWorktreeContext,
|
||||
directoryName: string,
|
||||
slug: string,
|
||||
workerIndex: number,
|
||||
initialMarkdown: string
|
||||
): Promise<string> {
|
||||
const directory = path.join(context.rootPath, directoryName)
|
||||
await mkdir(directory, { recursive: true })
|
||||
|
||||
const filePath = path.join(directory, `${slug}-${workerIndex}-${Date.now()}-${randomUUID()}.md`)
|
||||
await writeFile(filePath, initialMarkdown, 'utf8')
|
||||
|
||||
return filePath
|
||||
}
|
||||
|
||||
export async function cleanupMarkdownFixture(filePath: string | null): Promise<void> {
|
||||
if (!filePath) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await rm(filePath, { force: true })
|
||||
} catch {
|
||||
// Best-effort cleanup must not hide the editor regression assertion.
|
||||
}
|
||||
}
|
||||
|
||||
export async function openMarkdownFixture(
|
||||
page: Page,
|
||||
context: ActiveWorktreeContext,
|
||||
filePath: string
|
||||
): Promise<ActiveEditorFile> {
|
||||
const relativePath = path.relative(context.rootPath, filePath)
|
||||
|
||||
await page.evaluate(
|
||||
({ filePath, relativePath, worktreeId }) => {
|
||||
const store = window.__store
|
||||
if (!store) {
|
||||
throw new Error('window.__store is not available')
|
||||
}
|
||||
|
||||
store.getState().openFile({
|
||||
filePath,
|
||||
relativePath,
|
||||
worktreeId,
|
||||
language: 'markdown',
|
||||
mode: 'edit'
|
||||
})
|
||||
},
|
||||
{ filePath, relativePath, worktreeId: context.worktreeId }
|
||||
)
|
||||
|
||||
let activeFile: ActiveEditorFile | null = null
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
activeFile = await page.evaluate(() => {
|
||||
const store = window.__store
|
||||
if (!store) {
|
||||
return null
|
||||
}
|
||||
|
||||
const state = store.getState()
|
||||
const file = state.openFiles.find((entry) => entry.id === state.activeFileId)
|
||||
return file ? { filePath: file.filePath } : null
|
||||
})
|
||||
return activeFile?.filePath ?? null
|
||||
},
|
||||
{
|
||||
timeout: 5_000,
|
||||
message: `Active editor file did not become ${filePath}`
|
||||
}
|
||||
)
|
||||
.toBe(filePath)
|
||||
|
||||
if (!activeFile) {
|
||||
throw new Error(`Active editor file was not available after opening ${filePath}`)
|
||||
}
|
||||
|
||||
return activeFile
|
||||
}
|
||||
|
||||
export async function waitForRichMarkdownEditor(page: Page): Promise<Locator> {
|
||||
const editor = page.locator('.rich-markdown-editor')
|
||||
await expect(editor).toBeVisible({ timeout: MARKDOWN_HYDRATION_TIMEOUT_MS })
|
||||
return editor
|
||||
}
|
||||
|
||||
export async function closeActiveEditorTab(page: Page, filePath: string): Promise<void> {
|
||||
const fileName = path.basename(filePath)
|
||||
const tab = page.locator('[data-tab-id]').filter({ hasText: fileName }).last()
|
||||
await tab.getByRole('button', { name: 'Close tab' }).click()
|
||||
|
||||
await expect(page.locator('.editor-header-path').filter({ hasText: fileName })).toHaveCount(0, {
|
||||
timeout: 10_000
|
||||
})
|
||||
}
|
||||
@@ -1,163 +0,0 @@
|
||||
import type { Page } from '@stablyai/playwright-test'
|
||||
import { expect } from '@stablyai/playwright-test'
|
||||
|
||||
const TOGGLE_RENDER_TIMEOUT_MS = 10_000
|
||||
|
||||
export const NESTED_TOGGLE_FIXTURE_DIRECTORY = '.orca-e2e-markdown-nested-toggle'
|
||||
|
||||
export const NESTED_TOGGLE_BODY_TEXT = 'Get back to ppl'
|
||||
|
||||
export const NESTED_TOGGLE_MARKDOWN = [
|
||||
'# 08/27/2026',
|
||||
'',
|
||||
'<details class="orca-details" data-orca-toggle="heading-3" open>',
|
||||
'<summary>08/26/2026</summary>',
|
||||
'',
|
||||
'<details class="orca-details" open>',
|
||||
'<summary>goals</summary>',
|
||||
'',
|
||||
'- Get back to ppl',
|
||||
'',
|
||||
'</details>',
|
||||
'',
|
||||
'- after inner toggle',
|
||||
'',
|
||||
'</details>',
|
||||
''
|
||||
].join('\n')
|
||||
|
||||
// An inner toggle carrying unsupported attributes cannot become an editable
|
||||
// node, so the whole block must still fall back to byte-preserving passthrough.
|
||||
export const UNSUPPORTED_NESTED_TOGGLE_MARKDOWN = [
|
||||
'<details class="orca-details" open>',
|
||||
'<summary>outer</summary>',
|
||||
'',
|
||||
'<details id="not-a-toggle">',
|
||||
'<summary>inner</summary>',
|
||||
'',
|
||||
'Body',
|
||||
'',
|
||||
'</details>',
|
||||
'',
|
||||
'</details>',
|
||||
''
|
||||
].join('\n')
|
||||
|
||||
export type RenderedToggles = {
|
||||
toggleCount: number
|
||||
nestedToggleCount: number
|
||||
summaries: string[]
|
||||
variants: (string | null)[]
|
||||
passthroughBlockCount: number
|
||||
}
|
||||
|
||||
export async function readRenderedToggles(page: Page): Promise<RenderedToggles> {
|
||||
return page.evaluate(() => {
|
||||
const editor = document.querySelector('.rich-markdown-editor')
|
||||
const toggles = Array.from(editor?.querySelectorAll('[data-type="details"]') ?? [])
|
||||
|
||||
return {
|
||||
toggleCount: toggles.length,
|
||||
nestedToggleCount:
|
||||
editor?.querySelectorAll('[data-type="details"] [data-type="details"]').length ?? 0,
|
||||
summaries: toggles.map(
|
||||
(toggle) => toggle.querySelector('summary')?.textContent?.trim() ?? ''
|
||||
),
|
||||
variants: toggles.map((toggle) => toggle.getAttribute('data-orca-toggle')),
|
||||
passthroughBlockCount: editor?.querySelectorAll('[data-raw-markdown-html-block]').length ?? 0
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export async function expectEditableNestedToggles(page: Page): Promise<void> {
|
||||
await expect
|
||||
.poll(async () => readRenderedToggles(page), {
|
||||
timeout: TOGGLE_RENDER_TIMEOUT_MS,
|
||||
message: 'Nested toggles did not render as editable toggle nodes'
|
||||
})
|
||||
.toEqual({
|
||||
toggleCount: 2,
|
||||
nestedToggleCount: 1,
|
||||
summaries: ['08/26/2026', 'goals'],
|
||||
variants: ['heading-3', null],
|
||||
passthroughBlockCount: 0
|
||||
})
|
||||
}
|
||||
|
||||
export async function expectPassthroughFallback(page: Page): Promise<void> {
|
||||
const passthrough = page.locator('.rich-markdown-editor [data-raw-markdown-html-block]')
|
||||
await expect(passthrough).toHaveCount(1, { timeout: TOGGLE_RENDER_TIMEOUT_MS })
|
||||
await expect(passthrough).toContainText('<details id="not-a-toggle">')
|
||||
}
|
||||
|
||||
async function selectionIsInsideNestedToggleBody(page: Page): Promise<boolean> {
|
||||
return page.evaluate(() => {
|
||||
const editor = document.querySelector('.rich-markdown-editor') as
|
||||
| (Element & { editor?: { state?: { selection?: { from?: number; empty?: boolean } } } })
|
||||
| null
|
||||
const paragraph = editor?.querySelector(
|
||||
'[data-type="details"] [data-type="details"] [data-type="detailsContent"] p'
|
||||
) as (Element & { pmViewDesc?: { posAtStart?: number; posAtEnd?: number } }) | null
|
||||
const selection = editor?.editor?.state?.selection
|
||||
const from = selection?.from
|
||||
const start = paragraph?.pmViewDesc?.posAtStart
|
||||
const end = paragraph?.pmViewDesc?.posAtEnd
|
||||
|
||||
if (
|
||||
!selection?.empty ||
|
||||
typeof from !== 'number' ||
|
||||
typeof start !== 'number' ||
|
||||
typeof end !== 'number'
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
return from >= start && from <= end
|
||||
})
|
||||
}
|
||||
|
||||
export async function placeCaretInNestedToggleBody(page: Page): Promise<void> {
|
||||
// Real input only: click the body text itself, then extend to end of line.
|
||||
await page
|
||||
.locator('.rich-markdown-editor')
|
||||
.getByText(NESTED_TOGGLE_BODY_TEXT, { exact: true })
|
||||
.click()
|
||||
await page.keyboard.press('End')
|
||||
|
||||
await expect
|
||||
.poll(async () => selectionIsInsideNestedToggleBody(page), {
|
||||
timeout: 3_000,
|
||||
message: 'Clicking the nested toggle body text did not place the caret inside it'
|
||||
})
|
||||
.toBe(true)
|
||||
}
|
||||
|
||||
export async function expectSentinelInsideNestedToggle(
|
||||
page: Page,
|
||||
sentinel: string
|
||||
): Promise<void> {
|
||||
const nestedBody = page.locator(
|
||||
'.rich-markdown-editor [data-type="details"] [data-type="details"] [data-type="detailsContent"]'
|
||||
)
|
||||
await expect(nestedBody).toContainText(sentinel, { timeout: TOGGLE_RENDER_TIMEOUT_MS })
|
||||
}
|
||||
|
||||
export function expectFileKeepsNesting(fileContents: string, sentinel: string): void {
|
||||
const outerOpen = fileContents.indexOf('<details class="orca-details" data-orca-toggle=')
|
||||
const innerOpen = fileContents.indexOf('<details class="orca-details" open>')
|
||||
const firstClose = fileContents.indexOf('</details>')
|
||||
const sentinelAt = fileContents.indexOf(sentinel)
|
||||
|
||||
expect({
|
||||
openTagCount: fileContents.split('<details').length - 1,
|
||||
closeTagCount: fileContents.split('</details>').length - 1,
|
||||
innerIsNestedInOuter: outerOpen !== -1 && innerOpen > outerOpen,
|
||||
// The edited line has to stay inside the inner toggle, before its close.
|
||||
sentinelInsideInner: sentinelAt !== -1 && sentinelAt > innerOpen && sentinelAt < firstClose
|
||||
}).toEqual({
|
||||
openTagCount: 2,
|
||||
closeTagCount: 2,
|
||||
innerIsNestedInOuter: true,
|
||||
sentinelInsideInner: true
|
||||
})
|
||||
}
|
||||
@@ -1,20 +1,16 @@
|
||||
import type { Page } from '@stablyai/playwright-test'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { mkdir, rm, writeFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import type { Locator, Page } from '@stablyai/playwright-test'
|
||||
import { expect } from '@stablyai/playwright-test'
|
||||
import {
|
||||
createMarkdownFixture as createMarkdownFixtureIn,
|
||||
type ActiveWorktreeContext
|
||||
} from './markdown-editor-fixture'
|
||||
|
||||
const MARKDOWN_HYDRATION_TIMEOUT_MS = 25_000
|
||||
const DRAFT_SERIALIZATION_TIMEOUT_MS = 10_000
|
||||
const FIXTURE_DIRECTORY = '.orca-e2e-markdown-ordered-list'
|
||||
|
||||
export {
|
||||
cleanupMarkdownFixture,
|
||||
getActiveWorktreeContext,
|
||||
openMarkdownFixture,
|
||||
waitForRichMarkdownEditor,
|
||||
type ActiveWorktreeContext
|
||||
} from './markdown-editor-fixture'
|
||||
export type ActiveWorktreeContext = {
|
||||
worktreeId: string
|
||||
rootPath: string
|
||||
}
|
||||
|
||||
export type MatrixRow = {
|
||||
name: string
|
||||
@@ -24,13 +20,120 @@ export type MatrixRow = {
|
||||
run: (page: Page, sentinel: string) => Promise<void>
|
||||
}
|
||||
|
||||
type ActiveEditorFile = {
|
||||
filePath: string
|
||||
}
|
||||
|
||||
export async function getActiveWorktreeContext(page: Page): Promise<ActiveWorktreeContext> {
|
||||
return page.evaluate(() => {
|
||||
const store = window.__store
|
||||
if (!store) {
|
||||
throw new Error('window.__store is not available')
|
||||
}
|
||||
|
||||
const state = store.getState()
|
||||
const worktreeId = state.activeWorktreeId
|
||||
if (!worktreeId) {
|
||||
throw new Error('No active worktree is selected')
|
||||
}
|
||||
|
||||
const worktree = Object.values(state.worktreesByRepo)
|
||||
.flat()
|
||||
.find((entry) => entry.id === worktreeId)
|
||||
if (!worktree) {
|
||||
throw new Error(`Active worktree was not found in store: ${worktreeId}`)
|
||||
}
|
||||
|
||||
return { worktreeId, rootPath: worktree.path }
|
||||
})
|
||||
}
|
||||
|
||||
export async function createMarkdownFixture(
|
||||
context: ActiveWorktreeContext,
|
||||
slug: string,
|
||||
workerIndex: number,
|
||||
initialMarkdown: string
|
||||
): Promise<string> {
|
||||
return createMarkdownFixtureIn(context, FIXTURE_DIRECTORY, slug, workerIndex, initialMarkdown)
|
||||
const directory = path.join(context.rootPath, '.orca-e2e-markdown-ordered-list')
|
||||
await mkdir(directory, { recursive: true })
|
||||
|
||||
const filePath = path.join(directory, `${slug}-${workerIndex}-${Date.now()}-${randomUUID()}.md`)
|
||||
await writeFile(filePath, initialMarkdown, 'utf8')
|
||||
|
||||
return filePath
|
||||
}
|
||||
|
||||
export async function cleanupMarkdownFixture(filePath: string | null): Promise<void> {
|
||||
if (!filePath) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await rm(filePath, { force: true })
|
||||
} catch {
|
||||
// Best-effort cleanup must not hide the editor regression assertion.
|
||||
}
|
||||
}
|
||||
|
||||
export async function openMarkdownFixture(
|
||||
page: Page,
|
||||
context: ActiveWorktreeContext,
|
||||
filePath: string
|
||||
): Promise<ActiveEditorFile> {
|
||||
const relativePath = path.relative(context.rootPath, filePath)
|
||||
|
||||
await page.evaluate(
|
||||
({ filePath, relativePath, worktreeId }) => {
|
||||
const store = window.__store
|
||||
if (!store) {
|
||||
throw new Error('window.__store is not available')
|
||||
}
|
||||
|
||||
store.getState().openFile({
|
||||
filePath,
|
||||
relativePath,
|
||||
worktreeId,
|
||||
language: 'markdown',
|
||||
mode: 'edit'
|
||||
})
|
||||
},
|
||||
{ filePath, relativePath, worktreeId: context.worktreeId }
|
||||
)
|
||||
|
||||
let activeFile: ActiveEditorFile | null = null
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
activeFile = await page.evaluate(() => {
|
||||
const store = window.__store
|
||||
if (!store) {
|
||||
return null
|
||||
}
|
||||
|
||||
const state = store.getState()
|
||||
const file = state.openFiles.find((entry) => entry.id === state.activeFileId)
|
||||
return file ? { filePath: file.filePath } : null
|
||||
})
|
||||
return activeFile?.filePath ?? null
|
||||
},
|
||||
{
|
||||
timeout: 5_000,
|
||||
message: `Active editor file did not become ${filePath}`
|
||||
}
|
||||
)
|
||||
.toBe(filePath)
|
||||
|
||||
if (!activeFile) {
|
||||
throw new Error(`Active editor file was not available after opening ${filePath}`)
|
||||
}
|
||||
|
||||
return activeFile
|
||||
}
|
||||
|
||||
export async function waitForRichMarkdownEditor(page: Page): Promise<Locator> {
|
||||
const editor = page.locator('.rich-markdown-editor')
|
||||
await expect(editor).toBeVisible({ timeout: MARKDOWN_HYDRATION_TIMEOUT_MS })
|
||||
return editor
|
||||
}
|
||||
|
||||
export async function expectSentinelParagraphOutsideOrderedList(
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { test, expect } from './helpers/orca-app'
|
||||
import {
|
||||
cleanupMarkdownFixture,
|
||||
closeActiveEditorTab,
|
||||
createMarkdownFixture,
|
||||
getActiveWorktreeContext,
|
||||
openMarkdownFixture,
|
||||
waitForRichMarkdownEditor
|
||||
} from './helpers/markdown-editor-fixture'
|
||||
import {
|
||||
expectEditableNestedToggles,
|
||||
expectFileKeepsNesting,
|
||||
expectPassthroughFallback,
|
||||
expectSentinelInsideNestedToggle,
|
||||
NESTED_TOGGLE_FIXTURE_DIRECTORY,
|
||||
NESTED_TOGGLE_MARKDOWN,
|
||||
placeCaretInNestedToggleBody,
|
||||
UNSUPPORTED_NESTED_TOGGLE_MARKDOWN
|
||||
} from './helpers/markdown-nested-toggle'
|
||||
import { waitForActiveWorktree, waitForSessionReady } from './helpers/store'
|
||||
|
||||
test.describe('Markdown nested toggle regression', () => {
|
||||
test.beforeEach(async ({ orcaPage }) => {
|
||||
await waitForSessionReady(orcaPage)
|
||||
await waitForActiveWorktree(orcaPage)
|
||||
})
|
||||
|
||||
test('a nested toggle on disk reopens as editable toggles', async ({ orcaPage }, testInfo) => {
|
||||
const context = await getActiveWorktreeContext(orcaPage)
|
||||
let filePath: string | null = null
|
||||
|
||||
try {
|
||||
filePath = await createMarkdownFixture(
|
||||
context,
|
||||
NESTED_TOGGLE_FIXTURE_DIRECTORY,
|
||||
'nested-toggle-reopen',
|
||||
testInfo.workerIndex,
|
||||
NESTED_TOGGLE_MARKDOWN
|
||||
)
|
||||
await openMarkdownFixture(orcaPage, context, filePath)
|
||||
await waitForRichMarkdownEditor(orcaPage)
|
||||
|
||||
await expectEditableNestedToggles(orcaPage)
|
||||
} finally {
|
||||
await cleanupMarkdownFixture(filePath)
|
||||
}
|
||||
})
|
||||
|
||||
test('editing a nested toggle survives save and reopen', async ({ orcaPage }, testInfo) => {
|
||||
const context = await getActiveWorktreeContext(orcaPage)
|
||||
const sentinel = `editedInsideNestedToggle${Date.now()}`
|
||||
let filePath: string | null = null
|
||||
|
||||
try {
|
||||
filePath = await createMarkdownFixture(
|
||||
context,
|
||||
NESTED_TOGGLE_FIXTURE_DIRECTORY,
|
||||
'nested-toggle-edit',
|
||||
testInfo.workerIndex,
|
||||
NESTED_TOGGLE_MARKDOWN
|
||||
)
|
||||
await openMarkdownFixture(orcaPage, context, filePath)
|
||||
await waitForRichMarkdownEditor(orcaPage)
|
||||
await expectEditableNestedToggles(orcaPage)
|
||||
|
||||
await placeCaretInNestedToggleBody(orcaPage)
|
||||
await orcaPage.keyboard.type(` ${sentinel}`)
|
||||
await expectSentinelInsideNestedToggle(orcaPage, sentinel)
|
||||
|
||||
// Save through the real shortcut and assert the bytes that landed on disk.
|
||||
await orcaPage.keyboard.press('ControlOrMeta+S')
|
||||
const savedPath = filePath
|
||||
await expect
|
||||
.poll(() => readFileSync(savedPath, 'utf8'), { timeout: 10_000 })
|
||||
.toContain(sentinel)
|
||||
expectFileKeepsNesting(readFileSync(savedPath, 'utf8'), sentinel)
|
||||
|
||||
// The reported bug only appeared on reopen, so close the tab and parse
|
||||
// the saved file again from scratch.
|
||||
await closeActiveEditorTab(orcaPage, savedPath)
|
||||
await openMarkdownFixture(orcaPage, context, savedPath)
|
||||
await waitForRichMarkdownEditor(orcaPage)
|
||||
await expectEditableNestedToggles(orcaPage)
|
||||
await expectSentinelInsideNestedToggle(orcaPage, sentinel)
|
||||
} finally {
|
||||
await cleanupMarkdownFixture(filePath)
|
||||
}
|
||||
})
|
||||
|
||||
test('a nested toggle that cannot be represented stays raw passthrough', async ({
|
||||
orcaPage
|
||||
}, testInfo) => {
|
||||
const context = await getActiveWorktreeContext(orcaPage)
|
||||
let filePath: string | null = null
|
||||
|
||||
try {
|
||||
filePath = await createMarkdownFixture(
|
||||
context,
|
||||
NESTED_TOGGLE_FIXTURE_DIRECTORY,
|
||||
'nested-toggle-unsupported',
|
||||
testInfo.workerIndex,
|
||||
UNSUPPORTED_NESTED_TOGGLE_MARKDOWN
|
||||
)
|
||||
await openMarkdownFixture(orcaPage, context, filePath)
|
||||
await waitForRichMarkdownEditor(orcaPage)
|
||||
|
||||
await expectPassthroughFallback(orcaPage)
|
||||
} finally {
|
||||
await cleanupMarkdownFixture(filePath)
|
||||
}
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user