mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
Open check run details in an editor tab instead of a modal (#5447)
* Open check run full details in an editor tab instead of a modal - Display check run output, annotations, and job lists within the main editor workspace. - Replace the right-sidebar checks panel detail dialog with a tab-opening action. - Suppress local file-system actions (like path copying and revealing) on virtual check-run detail tabs. * Extract path and rename UI to EditorPanelHeaderPath - Offloads path rendering, renaming, and context-menu behaviors from EditorPanelHeader into a new dedicated component. - Improves code maintainability and readability by decoupling distinct responsibilities within the editor panel headers.
This commit is contained in:
@@ -835,6 +835,15 @@
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.editor-header-path--static {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.editor-header-path--static:hover,
|
||||
.editor-header-path--static:focus-visible {
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.editor-header-path-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -112,7 +112,12 @@ const EditorPanel = lazy(() => import('./editor/EditorPanel'))
|
||||
// feel responsive on a deliberate follow-up click; long enough to absorb the
|
||||
// trailing edge of a physical double-click (~150 ms on most hardware).
|
||||
const CLOSE_DIALOG_DEBOUNCE_MS = 200
|
||||
const EDITOR_TAB_CONTENT_TYPES = new Set<TabContentType>(['editor', 'diff', 'conflict-review'])
|
||||
const EDITOR_TAB_CONTENT_TYPES = new Set<TabContentType>([
|
||||
'editor',
|
||||
'diff',
|
||||
'conflict-review',
|
||||
'check-details'
|
||||
])
|
||||
|
||||
type TerminalStoreSnapshot = ReturnType<typeof useAppStore.getState>
|
||||
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
import React from 'react'
|
||||
import { ExternalLink, LoaderCircle, RefreshCw } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import CommentMarkdown from '@/components/sidebar/CommentMarkdown'
|
||||
import type { PRCheckDetail, PRCheckRunDetails } from '../../../../shared/types'
|
||||
import { CheckJobLogTail } from '@/components/right-sidebar/check-job-log-tail'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
function formatCheckTimestamp(value: string | null | undefined): string | null {
|
||||
if (!value) {
|
||||
return null
|
||||
}
|
||||
const parsed = new Date(value)
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
return value
|
||||
}
|
||||
return parsed.toLocaleString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
function getCheckStatusLabel(check: PRCheckDetail): string {
|
||||
const conclusion = check.conclusion ?? 'pending'
|
||||
switch (conclusion) {
|
||||
case 'success':
|
||||
return translate('auto.components.editor.CheckRunDetailsPanel.8f2d0f5a91', 'Passed')
|
||||
case 'failure':
|
||||
return translate('auto.components.editor.CheckRunDetailsPanel.4c8e1b2d73', 'Failed')
|
||||
case 'cancelled':
|
||||
return translate('auto.components.editor.CheckRunDetailsPanel.91a4c7e2b0', 'Cancelled')
|
||||
case 'timed_out':
|
||||
return translate('auto.components.editor.CheckRunDetailsPanel.2f6d8a1c45', 'Timed out')
|
||||
case 'skipped':
|
||||
return translate('auto.components.editor.CheckRunDetailsPanel.7b3e9d4f12', 'Skipped')
|
||||
case 'neutral':
|
||||
return translate('auto.components.editor.CheckRunDetailsPanel.5a1c8e3d67', 'Neutral')
|
||||
case 'pending':
|
||||
return translate('auto.components.editor.CheckRunDetailsPanel.3d9f2b8e14', 'Pending')
|
||||
default:
|
||||
return conclusion
|
||||
}
|
||||
}
|
||||
|
||||
function isFailureState(state: string | null | undefined): boolean {
|
||||
return state === 'failure' || state === 'cancelled' || state === 'timed_out'
|
||||
}
|
||||
|
||||
export function CheckRunDetailsPanel({
|
||||
check,
|
||||
details,
|
||||
loading,
|
||||
error,
|
||||
openUrl,
|
||||
onRefresh
|
||||
}: {
|
||||
check: PRCheckDetail
|
||||
details: PRCheckRunDetails | null
|
||||
loading: boolean
|
||||
error: string | null
|
||||
openUrl: string | null | undefined
|
||||
onRefresh?: () => void
|
||||
}): React.JSX.Element {
|
||||
const startedAt = formatCheckTimestamp(details?.startedAt)
|
||||
const completedAt = formatCheckTimestamp(details?.completedAt)
|
||||
const detailsStatusCheck: PRCheckDetail = {
|
||||
...check,
|
||||
status: (details?.status as PRCheckDetail['status'] | undefined) ?? check.status,
|
||||
conclusion: (details?.conclusion as PRCheckDetail['conclusion'] | undefined) ?? check.conclusion
|
||||
}
|
||||
const failedJobs =
|
||||
details?.jobs.filter((job) => {
|
||||
const state = job.conclusion ?? job.status
|
||||
return isFailureState(state)
|
||||
}) ?? []
|
||||
const jobs = failedJobs.length > 0 ? failedJobs : (details?.jobs ?? [])
|
||||
const hasOutput = Boolean(details?.title || details?.summary || details?.text)
|
||||
const hasAnnotations = (details?.annotations.length ?? 0) > 0
|
||||
const hasJobs = jobs.length > 0
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col bg-editor-surface">
|
||||
<div className="border-b border-border px-5 py-4">
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
<h1 className="min-w-0 flex-1 truncate text-base font-medium text-foreground">
|
||||
{check.name}
|
||||
</h1>
|
||||
{onRefresh && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
disabled={loading}
|
||||
onClick={onRefresh}
|
||||
>
|
||||
<RefreshCw className={`size-3.5${loading ? ' animate-spin' : ''}`} />
|
||||
{translate('auto.components.editor.CheckRunDetailsPanel.b7f5e2c91a', 'Refresh')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
|
||||
<span>
|
||||
{translate('auto.components.editor.CheckRunDetailsPanel.a54ae21c6f', 'Status:')}{' '}
|
||||
{details ? getCheckStatusLabel(detailsStatusCheck) : getCheckStatusLabel(check)}
|
||||
</span>
|
||||
{startedAt && (
|
||||
<span>
|
||||
{translate('auto.components.editor.CheckRunDetailsPanel.fd46a70f1a', 'Started')}{' '}
|
||||
{startedAt}
|
||||
</span>
|
||||
)}
|
||||
{completedAt && (
|
||||
<span>
|
||||
{translate('auto.components.editor.CheckRunDetailsPanel.00e1c1658a', 'Completed')}{' '}
|
||||
{completedAt}
|
||||
</span>
|
||||
)}
|
||||
{check.checkRunId && (
|
||||
<span className="font-mono">
|
||||
{translate('auto.components.editor.CheckRunDetailsPanel.aa8494ae3c', 'check #')}
|
||||
{check.checkRunId}
|
||||
</span>
|
||||
)}
|
||||
{check.workflowRunId && (
|
||||
<span className="font-mono">
|
||||
{translate('auto.components.editor.CheckRunDetailsPanel.2dd5ddabc4', 'workflow #')}
|
||||
{check.workflowRunId}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-4 scrollbar-sleek">
|
||||
{loading ? (
|
||||
<div className="flex items-center gap-2 py-4 text-sm text-muted-foreground">
|
||||
<LoaderCircle className="size-4 animate-spin" />
|
||||
{translate(
|
||||
'auto.components.editor.CheckRunDetailsPanel.1f2b980522',
|
||||
'Loading check details…'
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4">
|
||||
{error && <div className="text-sm text-muted-foreground">{error}</div>}
|
||||
|
||||
{hasOutput && (
|
||||
<section className="rounded-md border border-border bg-background">
|
||||
<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 && (
|
||||
<div className="mb-2 text-sm font-medium text-foreground">{details.title}</div>
|
||||
)}
|
||||
{details?.summary && (
|
||||
<CommentMarkdown
|
||||
content={details.summary}
|
||||
variant="document"
|
||||
className="min-w-0 max-w-full overflow-hidden break-words text-sm leading-relaxed [&_a]:break-all [&_code]:break-words [&_pre]:max-w-full"
|
||||
/>
|
||||
)}
|
||||
{details?.text && (
|
||||
<CommentMarkdown
|
||||
content={details.text}
|
||||
variant="document"
|
||||
className="mt-3 min-w-0 max-w-full overflow-hidden break-words text-sm leading-relaxed [&_a]:break-all [&_code]:break-words [&_pre]:max-w-full"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{hasAnnotations && (
|
||||
<section className="rounded-md border border-border bg-background">
|
||||
<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">
|
||||
{details!.annotations.map((annotation, index) => (
|
||||
<div key={`${annotation.path ?? 'annotation'}-${index}`} className="px-3 py-3">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<span className="min-w-0 break-all font-mono text-xs text-muted-foreground">
|
||||
{annotation.path ??
|
||||
translate(
|
||||
'auto.components.editor.CheckRunDetailsPanel.cdbfda4dec',
|
||||
'Annotation'
|
||||
)}
|
||||
{annotation.startLine ? `:${annotation.startLine}` : ''}
|
||||
</span>
|
||||
{annotation.annotationLevel && (
|
||||
<span className="shrink-0 text-xs text-muted-foreground">
|
||||
{annotation.annotationLevel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{annotation.title && (
|
||||
<div className="mt-2 text-sm font-medium text-foreground">
|
||||
{annotation.title}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-2 break-words text-sm text-foreground">
|
||||
{annotation.message}
|
||||
</div>
|
||||
{annotation.rawDetails && (
|
||||
<pre className="mt-2 max-h-60 overflow-auto whitespace-pre-wrap rounded bg-muted/40 p-3 font-mono text-xs text-muted-foreground scrollbar-sleek">
|
||||
{annotation.rawDetails}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{hasJobs && (
|
||||
<section className="rounded-md border border-border bg-background">
|
||||
<div className="border-b border-border px-3 py-2 text-sm font-medium">
|
||||
{failedJobs.length > 0
|
||||
? 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) => (
|
||||
<div key={`${job.name}-${index}`} className="px-3 py-3">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-medium text-foreground">
|
||||
{job.name}
|
||||
</span>
|
||||
<span className="shrink-0 text-xs text-muted-foreground">
|
||||
{job.conclusion ??
|
||||
job.status ??
|
||||
translate(
|
||||
'auto.components.editor.CheckRunDetailsPanel.ee07b33924',
|
||||
'unknown'
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{job.steps.length > 0 && (
|
||||
<div className="mt-2 grid gap-1">
|
||||
{job.steps.map((step) => (
|
||||
<div
|
||||
key={step.name}
|
||||
className="flex min-w-0 items-center gap-2 text-xs text-muted-foreground"
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate">{step.name}</span>
|
||||
<span className="shrink-0">{step.conclusion ?? step.status}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{job.logTail && <CheckJobLogTail logTail={job.logTail} />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{!error && !hasOutput && !hasAnnotations && !hasJobs && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.editor.CheckRunDetailsPanel.07eccfa397',
|
||||
'No details are available for this check.'
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{openUrl && (
|
||||
<div className="flex justify-end border-t border-border px-5 py-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => window.api.shell.openUrl(openUrl)}
|
||||
>
|
||||
{translate('auto.components.editor.CheckRunDetailsPanel.a916648574', 'Open details')}
|
||||
<ExternalLink className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -28,6 +28,7 @@ import { useMarkdownDocuments } from './useMarkdownDocuments'
|
||||
import { findGitConflictBlocks } from './monaco-conflict-decorations'
|
||||
import { getDiffContentSignature } from './diff-content-signature'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { CheckRunDetailsPanel } from './CheckRunDetailsPanel'
|
||||
|
||||
const MonacoEditor = lazy(() => import('./MonacoEditor'))
|
||||
const DiffViewer = lazy(() => import('./DiffViewer'))
|
||||
@@ -167,6 +168,7 @@ export function EditorContent({
|
||||
const closeFile = useAppStore((s) => s.closeFile)
|
||||
const setRightSidebarTab = useAppStore((s) => s.setRightSidebarTab)
|
||||
const setPendingEditorReveal = useAppStore((s) => s.setPendingEditorReveal)
|
||||
const reloadOpenCheckRunDetailsTab = useAppStore((s) => s.reloadOpenCheckRunDetailsTab)
|
||||
const [conflictNavigationIndexByFile, setConflictNavigationIndexByFile] = React.useState<
|
||||
Record<string, number>
|
||||
>({})
|
||||
@@ -614,6 +616,34 @@ export function EditorContent({
|
||||
)
|
||||
}
|
||||
|
||||
if (activeFile.mode === 'check-details') {
|
||||
const checkRunDetails = activeFile.checkRunDetails
|
||||
if (!checkRunDetails) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.editor.EditorContent.6c4f1a8d2e',
|
||||
'Check details are unavailable.'
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const details = checkRunDetails.details
|
||||
const openUrl = details?.detailsUrl ?? details?.url ?? checkRunDetails.check.url
|
||||
return (
|
||||
<CheckRunDetailsPanel
|
||||
check={checkRunDetails.check}
|
||||
details={checkRunDetails.details}
|
||||
loading={checkRunDetails.loading}
|
||||
error={checkRunDetails.error}
|
||||
openUrl={openUrl}
|
||||
onRefresh={() => {
|
||||
void reloadOpenCheckRunDetailsTab(activeFile.id)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (activeFile.mode === 'conflict-review') {
|
||||
return (
|
||||
<ConflictReviewPanel
|
||||
|
||||
@@ -290,6 +290,10 @@ function EditorPanelInner({
|
||||
)
|
||||
}
|
||||
const handleOpenContainingFolder = (): void => {
|
||||
// Why: virtual editor tabs use synthetic ids instead of on-disk paths.
|
||||
if (activeFile.mode === 'check-details') {
|
||||
return
|
||||
}
|
||||
if (
|
||||
isLocalPathOpenBlocked(settingsForRuntimeOwner(settings, activeFile.runtimeEnvironmentId), {
|
||||
connectionId: getConnectionId(activeFile.worktreeId)
|
||||
|
||||
@@ -1,40 +1,18 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Columns2, Copy, Eye, ExternalLink, FileText, ListTree, Pencil, Rows2 } from 'lucide-react'
|
||||
import { useMemo } from 'react'
|
||||
import { Columns2, Eye, FileText, ListTree, Rows2 } from 'lucide-react'
|
||||
import { useAppStore } from '@/store'
|
||||
import type { MarkdownViewMode, OpenFile } from '@/store/slices/editor'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { CLOSE_ALL_CONTEXT_MENUS_EVENT } from '../tab-bar/SortableTab'
|
||||
import { useShortcutLabel } from '@/hooks/useShortcutLabel'
|
||||
import EditorViewToggle, {
|
||||
CSV_VIEW_MODE_METADATA,
|
||||
NOTEBOOK_VIEW_MODE_METADATA
|
||||
} from './EditorViewToggle'
|
||||
import type { EditorToggleValue } from './EditorViewToggle'
|
||||
import type { EditorHeaderOpenFileState } from './editor-header'
|
||||
import { getEditorHeaderCopyState } from './editor-header'
|
||||
import { DiffNotesSendMenu } from './DiffNotesSendMenu'
|
||||
import { useEditorHeaderFileRename } from './editor-header-file-rename'
|
||||
import { EditorPanelMarkdownActionsMenu } from './EditorPanelMarkdownActionsMenu'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
const isMac = navigator.userAgent.includes('Mac')
|
||||
const isLinux = navigator.userAgent.includes('Linux')
|
||||
|
||||
/** Platform-appropriate label: macOS -> Finder, Windows -> File Explorer, Linux -> Files */
|
||||
const revealLabel = isMac
|
||||
? 'Reveal in Finder'
|
||||
: isLinux
|
||||
? 'Open Containing Folder'
|
||||
: 'Reveal in File Explorer'
|
||||
import { EditorPanelHeaderPath } from './EditorPanelHeaderPath'
|
||||
|
||||
type EditorPanelHeaderProps = {
|
||||
activeFile: OpenFile
|
||||
@@ -103,149 +81,23 @@ export function EditorPanelHeader({
|
||||
onToggleMarkdownFrontmatter,
|
||||
onExportMarkdownToPdf
|
||||
}: EditorPanelHeaderProps): React.JSX.Element {
|
||||
const [pathMenuOpen, setPathMenuOpen] = useState(false)
|
||||
const [pathMenuPoint, setPathMenuPoint] = useState({ x: 0, y: 0 })
|
||||
const skipMenuFocusRestoreRef = useRef(false)
|
||||
const headerCopyState = getEditorHeaderCopyState(activeFile)
|
||||
const {
|
||||
canRename,
|
||||
currentFileName,
|
||||
isRenaming,
|
||||
renameInputRef,
|
||||
openRenameInput,
|
||||
commitRename,
|
||||
cancelRename
|
||||
} = useEditorHeaderFileRename(activeFile)
|
||||
const diffComments = useAppStore((s) => s.getDiffComments(activeFile.worktreeId))
|
||||
const activeGroupId = useAppStore((s) => s.activeGroupIdByWorktree[activeFile.worktreeId])
|
||||
const fileDiffComments = useMemo(
|
||||
() => diffComments.filter((comment) => comment.filePath === activeFile.relativePath),
|
||||
[activeFile.relativePath, diffComments]
|
||||
)
|
||||
const markdownPreviewShortcutLabel = useShortcutLabel('editor.markdownPreview')
|
||||
|
||||
useEffect(() => {
|
||||
const closeMenu = (): void => setPathMenuOpen(false)
|
||||
window.addEventListener(CLOSE_ALL_CONTEXT_MENUS_EVENT, closeMenu)
|
||||
return () => window.removeEventListener(CLOSE_ALL_CONTEXT_MENUS_EVENT, closeMenu)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="editor-header">
|
||||
<div className="editor-header-text">
|
||||
<div
|
||||
className="editor-header-path-row"
|
||||
onContextMenuCapture={(event) => {
|
||||
event.preventDefault()
|
||||
window.dispatchEvent(new Event(CLOSE_ALL_CONTEXT_MENUS_EVENT))
|
||||
setPathMenuPoint({ x: event.clientX, y: event.clientY })
|
||||
setPathMenuOpen(true)
|
||||
}}
|
||||
>
|
||||
{isRenaming ? (
|
||||
<Input
|
||||
ref={renameInputRef}
|
||||
data-editor-header-rename-input="true"
|
||||
aria-label={translate("auto.components.editor.EditorPanelHeader.1bb1e226ec", "Rename file {{value0}}", { value0: currentFileName })}
|
||||
defaultValue={currentFileName}
|
||||
// Why: the header is narrow in floating mode; this keeps the
|
||||
// edit field aligned with the path label without growing chrome.
|
||||
className="h-6 w-[16ch] min-w-[104px] max-w-full rounded-sm bg-input/40 px-1.5 py-0 font-mono text-xs text-foreground md:text-xs focus-visible:ring-[1px]"
|
||||
spellCheck={false}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onDoubleClick={(event) => event.stopPropagation()}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
commitRename()
|
||||
} else if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
cancelRename()
|
||||
}
|
||||
}}
|
||||
onBlur={commitRename}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="editor-header-path"
|
||||
onClick={onCopyPath}
|
||||
title={headerCopyState.pathTitle}
|
||||
>
|
||||
{headerCopyState.pathLabel}
|
||||
</button>
|
||||
)}
|
||||
<span
|
||||
className={`editor-header-copy-toast${copiedPathVisible ? ' is-visible' : ''}`}
|
||||
aria-live="polite"
|
||||
>
|
||||
{headerCopyState.copyToastLabel}
|
||||
</span>
|
||||
</div>
|
||||
<DropdownMenu open={pathMenuOpen} onOpenChange={setPathMenuOpen} modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
aria-hidden
|
||||
tabIndex={-1}
|
||||
className="pointer-events-none fixed size-px opacity-0"
|
||||
style={{ left: pathMenuPoint.x, top: pathMenuPoint.y }}
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="w-56"
|
||||
sideOffset={0}
|
||||
align="start"
|
||||
onCloseAutoFocus={(event) => {
|
||||
if (!skipMenuFocusRestoreRef.current) {
|
||||
return
|
||||
}
|
||||
skipMenuFocusRestoreRef.current = false
|
||||
event.preventDefault()
|
||||
}}
|
||||
>
|
||||
<DropdownMenuItem
|
||||
disabled={!canRename}
|
||||
onSelect={() => {
|
||||
skipMenuFocusRestoreRef.current = true
|
||||
openRenameInput()
|
||||
}}
|
||||
>
|
||||
<Pencil className="w-3.5 h-3.5 mr-1.5" />
|
||||
{translate("auto.components.editor.EditorPanelHeader.84cdc0794b", "Rename")}</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
void window.api.ui.writeClipboardText(activeFile.filePath)
|
||||
}}
|
||||
>
|
||||
<Copy className="w-3.5 h-3.5 mr-1.5" />
|
||||
{translate("auto.components.editor.EditorPanelHeader.7c08a1f990", "Copy Path")}</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
void window.api.ui.writeClipboardText(activeFile.relativePath)
|
||||
}}
|
||||
>
|
||||
<Copy className="w-3.5 h-3.5 mr-1.5" />
|
||||
{translate("auto.components.editor.EditorPanelHeader.269ce4842b", "Copy Relative Path")}</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
{canShowMarkdownPreview && (
|
||||
<DropdownMenuItem onSelect={onOpenMarkdownPreview}>
|
||||
<Eye className="w-3.5 h-3.5 mr-1.5" />
|
||||
{translate("auto.components.editor.EditorPanelHeader.4157f3cbf3", "Open Markdown Preview")}<DropdownMenuShortcut>{markdownPreviewShortcutLabel}</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{canShowMarkdownPreview && <DropdownMenuSeparator />}
|
||||
<DropdownMenuItem onSelect={onOpenContainingFolder}>
|
||||
<ExternalLink className="w-3.5 h-3.5 mr-1.5" />
|
||||
{revealLabel}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<EditorPanelHeaderPath
|
||||
activeFile={activeFile}
|
||||
copiedPathVisible={copiedPathVisible}
|
||||
canShowMarkdownPreview={canShowMarkdownPreview}
|
||||
onCopyPath={onCopyPath}
|
||||
onOpenMarkdownPreview={onOpenMarkdownPreview}
|
||||
onOpenContainingFolder={onOpenContainingFolder}
|
||||
/>
|
||||
{isSingleDiff && (
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<Tooltip>
|
||||
@@ -254,7 +106,10 @@ export function EditorPanelHeader({
|
||||
type="button"
|
||||
className="p-1 rounded hover:bg-accent text-muted-foreground hover:text-foreground transition-colors flex-shrink-0 disabled:opacity-50 disabled:hover:bg-transparent disabled:hover:text-muted-foreground"
|
||||
onClick={() => onOpenDiffTargetFile(isMarkdown ? 'rich' : undefined)}
|
||||
aria-label={translate("auto.components.editor.EditorPanelHeader.a10d9b8337", "Open file")}
|
||||
aria-label={translate(
|
||||
'auto.components.editor.EditorPanelHeader.a10d9b8337',
|
||||
'Open file'
|
||||
)}
|
||||
disabled={!openFileState.canOpen}
|
||||
>
|
||||
<FileText size={14} />
|
||||
@@ -263,9 +118,18 @@ export function EditorPanelHeader({
|
||||
<TooltipContent side="bottom" sideOffset={4}>
|
||||
{openFileState.canOpen
|
||||
? isMarkdown
|
||||
? translate("auto.components.editor.EditorPanelHeader.f0fd4174b5", "Open file tab to use rich markdown editing")
|
||||
: translate("auto.components.editor.EditorPanelHeader.9b80bbe1de", "Open file tab")
|
||||
: translate("auto.components.editor.EditorPanelHeader.c98ce191da", "This diff has no modified-side file to open")}
|
||||
? translate(
|
||||
'auto.components.editor.EditorPanelHeader.f0fd4174b5',
|
||||
'Open file tab to use rich markdown editing'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.editor.EditorPanelHeader.9b80bbe1de',
|
||||
'Open file tab'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.editor.EditorPanelHeader.c98ce191da',
|
||||
'This diff has no modified-side file to open'
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
@@ -291,13 +155,20 @@ export function EditorPanelHeader({
|
||||
type="button"
|
||||
className="p-1 rounded hover:bg-accent text-muted-foreground hover:text-foreground transition-colors flex-shrink-0"
|
||||
onClick={onOpenPreviewToSide}
|
||||
aria-label={translate("auto.components.editor.EditorPanelHeader.fb8331694e", "Open Preview to the Side")}
|
||||
aria-label={translate(
|
||||
'auto.components.editor.EditorPanelHeader.fb8331694e',
|
||||
'Open Preview to the Side'
|
||||
)}
|
||||
>
|
||||
<Eye size={14} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={4}>
|
||||
{translate("auto.components.editor.EditorPanelHeader.fb8331694e", "Open Preview to the Side")}</TooltipContent>
|
||||
{translate(
|
||||
'auto.components.editor.EditorPanelHeader.fb8331694e',
|
||||
'Open Preview to the Side'
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
@@ -314,7 +185,15 @@ export function EditorPanelHeader({
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={4}>
|
||||
{sideBySide ? translate("auto.components.editor.EditorPanelHeader.94756f08ba", "Switch to inline diff") : translate("auto.components.editor.EditorPanelHeader.e836faacfa", "Switch to side-by-side diff")}
|
||||
{sideBySide
|
||||
? translate(
|
||||
'auto.components.editor.EditorPanelHeader.94756f08ba',
|
||||
'Switch to inline diff'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.editor.EditorPanelHeader.e836faacfa',
|
||||
'Switch to side-by-side diff'
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
@@ -342,7 +221,10 @@ export function EditorPanelHeader({
|
||||
}`}
|
||||
onClick={onToggleMarkdownTableOfContents}
|
||||
disabled={isMarkdownTableOfContentsDisabled}
|
||||
aria-label={translate("auto.components.editor.EditorPanelHeader.5447c4f68f", "Table of Contents")}
|
||||
aria-label={translate(
|
||||
'auto.components.editor.EditorPanelHeader.5447c4f68f',
|
||||
'Table of Contents'
|
||||
)}
|
||||
aria-pressed={showMarkdownTableOfContents}
|
||||
>
|
||||
<ListTree size={14} />
|
||||
@@ -350,8 +232,14 @@ export function EditorPanelHeader({
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={4}>
|
||||
{isMarkdownTableOfContentsDisabled
|
||||
? translate("auto.components.editor.EditorPanelHeader.146cb5473c", "Table of Contents is available in rich or preview mode")
|
||||
: translate("auto.components.editor.EditorPanelHeader.5447c4f68f", "Table of Contents")}
|
||||
? translate(
|
||||
'auto.components.editor.EditorPanelHeader.146cb5473c',
|
||||
'Table of Contents is available in rich or preview mode'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.editor.EditorPanelHeader.5447c4f68f',
|
||||
'Table of Contents'
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Copy, ExternalLink, Eye, Pencil } from 'lucide-react'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { useShortcutLabel } from '@/hooks/useShortcutLabel'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import type { OpenFile } from '@/store/slices/editor'
|
||||
import { CLOSE_ALL_CONTEXT_MENUS_EVENT } from '../tab-bar/SortableTab'
|
||||
import { useEditorHeaderFileRename } from './editor-header-file-rename'
|
||||
import { getEditorHeaderCopyState } from './editor-header'
|
||||
|
||||
const isMac = navigator.userAgent.includes('Mac')
|
||||
const isLinux = navigator.userAgent.includes('Linux')
|
||||
|
||||
/** Platform-appropriate label: macOS -> Finder, Windows -> File Explorer, Linux -> Files */
|
||||
const revealLabel = isMac
|
||||
? 'Reveal in Finder'
|
||||
: isLinux
|
||||
? 'Open Containing Folder'
|
||||
: 'Reveal in File Explorer'
|
||||
|
||||
type EditorPanelHeaderPathProps = {
|
||||
activeFile: OpenFile
|
||||
copiedPathVisible: boolean
|
||||
canShowMarkdownPreview: boolean
|
||||
onCopyPath: () => void
|
||||
onOpenMarkdownPreview: () => void
|
||||
onOpenContainingFolder: () => void
|
||||
}
|
||||
|
||||
export function EditorPanelHeaderPath({
|
||||
activeFile,
|
||||
copiedPathVisible,
|
||||
canShowMarkdownPreview,
|
||||
onCopyPath,
|
||||
onOpenMarkdownPreview,
|
||||
onOpenContainingFolder
|
||||
}: EditorPanelHeaderPathProps): React.JSX.Element {
|
||||
const [pathMenuOpen, setPathMenuOpen] = useState(false)
|
||||
const [pathMenuPoint, setPathMenuPoint] = useState({ x: 0, y: 0 })
|
||||
const skipMenuFocusRestoreRef = useRef(false)
|
||||
const headerCopyState = getEditorHeaderCopyState(activeFile)
|
||||
const canCopyHeaderPath = headerCopyState.copyText !== null
|
||||
const isVirtualEditorTab = activeFile.mode === 'check-details'
|
||||
const markdownPreviewShortcutLabel = useShortcutLabel('editor.markdownPreview')
|
||||
const {
|
||||
canRename,
|
||||
currentFileName,
|
||||
isRenaming,
|
||||
renameInputRef,
|
||||
openRenameInput,
|
||||
commitRename,
|
||||
cancelRename
|
||||
} = useEditorHeaderFileRename(activeFile)
|
||||
|
||||
useEffect(() => {
|
||||
const closeMenu = (): void => setPathMenuOpen(false)
|
||||
window.addEventListener(CLOSE_ALL_CONTEXT_MENUS_EVENT, closeMenu)
|
||||
return () => window.removeEventListener(CLOSE_ALL_CONTEXT_MENUS_EVENT, closeMenu)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="editor-header-text">
|
||||
<div
|
||||
className="editor-header-path-row"
|
||||
onContextMenuCapture={(event) => {
|
||||
event.preventDefault()
|
||||
window.dispatchEvent(new Event(CLOSE_ALL_CONTEXT_MENUS_EVENT))
|
||||
setPathMenuPoint({ x: event.clientX, y: event.clientY })
|
||||
setPathMenuOpen(true)
|
||||
}}
|
||||
>
|
||||
{isRenaming ? (
|
||||
<Input
|
||||
ref={renameInputRef}
|
||||
data-editor-header-rename-input="true"
|
||||
aria-label={translate(
|
||||
'auto.components.editor.EditorPanelHeader.1bb1e226ec',
|
||||
'Rename file {{value0}}',
|
||||
{ value0: currentFileName }
|
||||
)}
|
||||
defaultValue={currentFileName}
|
||||
// Why: the header is narrow in floating mode; this keeps the
|
||||
// edit field aligned with the path label without growing chrome.
|
||||
className="h-6 w-[16ch] min-w-[104px] max-w-full rounded-sm bg-input/40 px-1.5 py-0 font-mono text-xs text-foreground md:text-xs focus-visible:ring-[1px]"
|
||||
spellCheck={false}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onDoubleClick={(event) => event.stopPropagation()}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
commitRename()
|
||||
} else if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
cancelRename()
|
||||
}
|
||||
}}
|
||||
onBlur={commitRename}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className={`editor-header-path${canCopyHeaderPath ? '' : ' editor-header-path--static'}`}
|
||||
onClick={canCopyHeaderPath ? onCopyPath : undefined}
|
||||
disabled={!canCopyHeaderPath}
|
||||
title={headerCopyState.pathTitle}
|
||||
>
|
||||
{headerCopyState.pathLabel}
|
||||
</button>
|
||||
)}
|
||||
<span
|
||||
className={`editor-header-copy-toast${copiedPathVisible ? ' is-visible' : ''}`}
|
||||
aria-live="polite"
|
||||
>
|
||||
{headerCopyState.copyToastLabel}
|
||||
</span>
|
||||
</div>
|
||||
<DropdownMenu open={pathMenuOpen} onOpenChange={setPathMenuOpen} modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
aria-hidden
|
||||
tabIndex={-1}
|
||||
className="pointer-events-none fixed size-px opacity-0"
|
||||
style={{ left: pathMenuPoint.x, top: pathMenuPoint.y }}
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="w-56"
|
||||
sideOffset={0}
|
||||
align="start"
|
||||
onCloseAutoFocus={(event) => {
|
||||
if (!skipMenuFocusRestoreRef.current) {
|
||||
return
|
||||
}
|
||||
skipMenuFocusRestoreRef.current = false
|
||||
event.preventDefault()
|
||||
}}
|
||||
>
|
||||
<DropdownMenuItem
|
||||
disabled={!canRename}
|
||||
onSelect={() => {
|
||||
skipMenuFocusRestoreRef.current = true
|
||||
openRenameInput()
|
||||
}}
|
||||
>
|
||||
<Pencil className="w-3.5 h-3.5 mr-1.5" />
|
||||
{translate('auto.components.editor.EditorPanelHeader.84cdc0794b', 'Rename')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
{!isVirtualEditorTab && (
|
||||
<>
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
void window.api.ui.writeClipboardText(activeFile.filePath)
|
||||
}}
|
||||
>
|
||||
<Copy className="w-3.5 h-3.5 mr-1.5" />
|
||||
{translate('auto.components.editor.EditorPanelHeader.7c08a1f990', 'Copy Path')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
void window.api.ui.writeClipboardText(activeFile.relativePath)
|
||||
}}
|
||||
>
|
||||
<Copy className="w-3.5 h-3.5 mr-1.5" />
|
||||
{translate(
|
||||
'auto.components.editor.EditorPanelHeader.269ce4842b',
|
||||
'Copy Relative Path'
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
{canShowMarkdownPreview && (
|
||||
<DropdownMenuItem onSelect={onOpenMarkdownPreview}>
|
||||
<Eye className="w-3.5 h-3.5 mr-1.5" />
|
||||
{translate(
|
||||
'auto.components.editor.EditorPanelHeader.4157f3cbf3',
|
||||
'Open Markdown Preview'
|
||||
)}
|
||||
<DropdownMenuShortcut>{markdownPreviewShortcutLabel}</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{canShowMarkdownPreview && <DropdownMenuSeparator />}
|
||||
{!isVirtualEditorTab && (
|
||||
<DropdownMenuItem onSelect={onOpenContainingFolder}>
|
||||
<ExternalLink className="w-3.5 h-3.5 mr-1.5" />
|
||||
{revealLabel}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -94,7 +94,7 @@ export function EditorPanelShell({
|
||||
}: EditorPanelShellProps): JSX.Element {
|
||||
return (
|
||||
<div ref={panelRef} className="flex flex-col flex-1 min-w-0 min-h-0">
|
||||
{!model.isCombinedDiff && (
|
||||
{!model.isCombinedDiff && activeFile.mode !== 'check-details' && (
|
||||
<EditorPanelHeader
|
||||
activeFile={activeFile}
|
||||
copiedPathVisible={copiedPathVisible}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
buildCheckRunDetailsTabId,
|
||||
getCheckRunDetailsTabLabel,
|
||||
getCheckRunTabIdentity
|
||||
} from './check-run-details-tab'
|
||||
|
||||
describe('check-run-details-tab', () => {
|
||||
it('builds a stable tab id from worktree and check identity', () => {
|
||||
expect(
|
||||
buildCheckRunDetailsTabId('wt-1', {
|
||||
name: 'verify',
|
||||
status: 'completed',
|
||||
conclusion: 'failure',
|
||||
url: null,
|
||||
checkRunId: 99
|
||||
})
|
||||
).toBe('wt-1::check-details::check-run:99')
|
||||
})
|
||||
|
||||
it('falls back to workflow and url identities when check run id is missing', () => {
|
||||
expect(
|
||||
getCheckRunTabIdentity({
|
||||
name: 'verify',
|
||||
status: 'completed',
|
||||
conclusion: 'failure',
|
||||
url: 'https://github.com/acme/widgets/actions/runs/1',
|
||||
workflowRunId: 12
|
||||
})
|
||||
).toBe('workflow-run:12')
|
||||
expect(
|
||||
getCheckRunTabIdentity({
|
||||
name: 'verify',
|
||||
status: 'completed',
|
||||
conclusion: 'failure',
|
||||
url: 'https://github.com/acme/widgets/actions/runs/1'
|
||||
})
|
||||
).toBe('url:https://github.com/acme/widgets/actions/runs/1')
|
||||
})
|
||||
|
||||
it('uses the check name for the tab label', () => {
|
||||
expect(
|
||||
getCheckRunDetailsTabLabel({
|
||||
name: 'verify',
|
||||
status: 'completed',
|
||||
conclusion: 'failure',
|
||||
url: null
|
||||
})
|
||||
).toBe('verify')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { PRCheckDetail, PRCheckRunDetails } from '../../../../shared/types'
|
||||
|
||||
export type OpenCheckRunDetailsState = {
|
||||
contextKey: string
|
||||
check: PRCheckDetail
|
||||
details: PRCheckRunDetails | null
|
||||
loading: boolean
|
||||
error: string | null
|
||||
}
|
||||
|
||||
export function getCheckRunTabIdentity(check: PRCheckDetail): string {
|
||||
if (check.checkRunId) {
|
||||
return `check-run:${check.checkRunId}`
|
||||
}
|
||||
if (check.workflowRunId) {
|
||||
return `workflow-run:${check.workflowRunId}`
|
||||
}
|
||||
if (check.url) {
|
||||
return `url:${check.url}`
|
||||
}
|
||||
return `name:${check.name}`
|
||||
}
|
||||
|
||||
export function buildCheckRunDetailsTabId(worktreeId: string, check: PRCheckDetail): string {
|
||||
// Why: one tab per hosted check identity keeps the center pane stable across
|
||||
// PR head refreshes; contextKey lives on the tab state instead of the tab id.
|
||||
return `${worktreeId}::check-details::${getCheckRunTabIdentity(check)}`
|
||||
}
|
||||
|
||||
export function getCheckRunDetailsTabLabel(check: PRCheckDetail): string {
|
||||
return check.name
|
||||
}
|
||||
@@ -59,6 +59,37 @@ describe('getEditorHeaderCopyState', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('shows the check name without a copyable path for check-details tabs', () => {
|
||||
expect(
|
||||
getEditorHeaderCopyState(
|
||||
makeOpenFile({
|
||||
id: 'wt-1::check-details::check-run:99',
|
||||
filePath: 'wt-1::check-details::check-run:99',
|
||||
relativePath: 'verify',
|
||||
mode: 'check-details',
|
||||
checkRunDetails: {
|
||||
contextKey: 'repo:42',
|
||||
check: {
|
||||
name: 'verify',
|
||||
status: 'completed',
|
||||
conclusion: 'failure',
|
||||
url: null,
|
||||
checkRunId: 99
|
||||
},
|
||||
details: null,
|
||||
loading: false,
|
||||
error: null
|
||||
}
|
||||
})
|
||||
)
|
||||
).toEqual({
|
||||
copyText: null,
|
||||
copyToastLabel: 'Check details copied',
|
||||
pathLabel: 'verify',
|
||||
pathTitle: 'verify'
|
||||
})
|
||||
})
|
||||
|
||||
it('shows All Changes while still copying the worktree path', () => {
|
||||
expect(
|
||||
getEditorHeaderCopyState(
|
||||
|
||||
@@ -23,6 +23,16 @@ export function getEditorHeaderCopyState(file: OpenFile): EditorHeaderCopyState
|
||||
}
|
||||
}
|
||||
|
||||
if (file.mode === 'check-details') {
|
||||
const label = file.checkRunDetails?.check.name ?? 'Check details'
|
||||
return {
|
||||
copyText: null,
|
||||
copyToastLabel: 'Check details copied',
|
||||
pathLabel: label,
|
||||
pathTitle: label
|
||||
}
|
||||
}
|
||||
|
||||
const isCombinedDiff =
|
||||
file.mode === 'diff' &&
|
||||
(file.diffSource === 'combined-uncommitted' ||
|
||||
|
||||
@@ -29,6 +29,10 @@ export function getEditorDisplayLabel(
|
||||
return 'Conflict Review'
|
||||
}
|
||||
|
||||
if (file.mode === 'check-details') {
|
||||
return file.checkRunDetails?.check.name ?? getBaseLabel(file, variant)
|
||||
}
|
||||
|
||||
if (file.mode === 'markdown-preview') {
|
||||
return `${getBaseLabel(file, variant)} (preview)`
|
||||
}
|
||||
|
||||
@@ -66,5 +66,7 @@ function disposeClosedEditorTab(prevId: string, prevFile: OpenFile): void {
|
||||
break
|
||||
case 'conflict-review':
|
||||
break
|
||||
case 'check-details':
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import React, { useCallback, useRef, useState } from 'react'
|
||||
import { Check, Copy } from 'lucide-react'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
function CopyButton({
|
||||
text,
|
||||
title = 'Copy comment'
|
||||
}: {
|
||||
text: string
|
||||
title?: string
|
||||
}): React.JSX.Element {
|
||||
const [copied, setCopied] = useState(false)
|
||||
const copiedResetTimerRef = useRef<number | null>(null)
|
||||
// Why: clipboard IPC can resolve after this row action unmounts; avoid
|
||||
// starting a reset timer that will outlive the component.
|
||||
const isMountedRef = useRef(false)
|
||||
|
||||
const clearCopiedResetTimer = useCallback((): void => {
|
||||
if (copiedResetTimerRef.current !== null) {
|
||||
window.clearTimeout(copiedResetTimerRef.current)
|
||||
copiedResetTimerRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
const setCopyButtonRef = useCallback(
|
||||
(node: HTMLButtonElement | null) => {
|
||||
isMountedRef.current = node !== null
|
||||
if (node === null) {
|
||||
clearCopiedResetTimer()
|
||||
}
|
||||
},
|
||||
[clearCopiedResetTimer]
|
||||
)
|
||||
|
||||
const handleCopy = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
void window.api.ui.writeClipboardText(text).then(() => {
|
||||
if (!isMountedRef.current) {
|
||||
return
|
||||
}
|
||||
clearCopiedResetTimer()
|
||||
setCopied(true)
|
||||
copiedResetTimerRef.current = window.setTimeout(() => {
|
||||
copiedResetTimerRef.current = null
|
||||
setCopied(false)
|
||||
}, 1500)
|
||||
})
|
||||
},
|
||||
[clearCopiedResetTimer, text]
|
||||
)
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={setCopyButtonRef}
|
||||
className="p-1 rounded hover:bg-accent text-muted-foreground/40 hover:text-foreground transition-colors shrink-0"
|
||||
title={title}
|
||||
onClick={handleCopy}
|
||||
>
|
||||
{copied ? <Check className="size-3" /> : <Copy className="size-3" />}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export function CheckJobLogTail({ logTail }: { logTail: string }): React.JSX.Element {
|
||||
return (
|
||||
<div className="mt-3 min-w-0">
|
||||
<div className="mb-1.5 flex min-w-0 items-center gap-2">
|
||||
<div className="min-w-0 flex-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.right.sidebar.checks.panel.content.d713f500b2',
|
||||
'Log tail (last 200 lines)'
|
||||
)}
|
||||
</div>
|
||||
<CopyButton
|
||||
text={logTail}
|
||||
title={translate(
|
||||
'auto.components.right.sidebar.checks.panel.content.679bf2093c',
|
||||
'Copy log tail'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<pre className="max-h-72 overflow-auto whitespace-pre-wrap rounded bg-muted/40 p-3 font-mono text-xs text-muted-foreground scrollbar-sleek">
|
||||
{logTail}
|
||||
</pre>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -34,14 +34,6 @@ import {
|
||||
AccordionItem,
|
||||
AccordionTrigger
|
||||
} from '@/components/ui/accordion'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -85,6 +77,8 @@ import {
|
||||
} from './right-panel-comment-composer'
|
||||
import { usePRCommentsListSelection } from './pr-comments-list-selection'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { useActiveWorktree } from '@/store/selectors'
|
||||
import { useAppStore } from '@/store'
|
||||
|
||||
export const PullRequestIcon = GitPullRequest
|
||||
|
||||
@@ -477,13 +471,16 @@ export function getFailedChecksForDetails(checks: PRCheckDetail[]): PRCheckDetai
|
||||
|
||||
function CheckRunDetails({
|
||||
check,
|
||||
state
|
||||
state,
|
||||
checkDetailsContextKey
|
||||
}: {
|
||||
check: PRCheckDetail
|
||||
state: CheckDetailsLoadState | undefined
|
||||
checkDetailsContextKey: string
|
||||
}): React.JSX.Element {
|
||||
const activeWorktree = useActiveWorktree()
|
||||
const openCheckRunDetails = useAppStore((s) => s.openCheckRunDetails)
|
||||
const details = state?.details
|
||||
const openUrl = details?.detailsUrl ?? details?.url ?? check.url
|
||||
const startedAt = formatCheckTimestamp(details?.startedAt)
|
||||
const completedAt = formatCheckTimestamp(details?.completedAt)
|
||||
const detailsStatusCheck: PRCheckDetail = {
|
||||
@@ -502,14 +499,46 @@ function CheckRunDetails({
|
||||
const hasJobs = jobs.length > 0
|
||||
const hasLogTail = jobs.some((job) => Boolean(job.logTail))
|
||||
|
||||
const openFullDetailsTab = (): void => {
|
||||
if (!activeWorktree) {
|
||||
return
|
||||
}
|
||||
openCheckRunDetails(activeWorktree.id, checkDetailsContextKey, check, {
|
||||
details: state?.details ?? null,
|
||||
loading: state?.loading ?? false,
|
||||
error: state?.error ?? null
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mb-1 ml-[26px] mr-3 min-w-0 border-l border-border pl-3">
|
||||
{state?.loading ? (
|
||||
<div className="flex items-center gap-2 py-1.5 text-[12px] text-muted-foreground">
|
||||
<LoaderCircle className="size-3.5 animate-spin" />
|
||||
{translate(
|
||||
'auto.components.right.sidebar.checks.panel.content.1f2b980522',
|
||||
'Loading check details…'
|
||||
<div className="flex min-w-0 flex-col gap-2 py-1.5">
|
||||
<div className="flex items-center gap-2 text-[12px] text-muted-foreground">
|
||||
<LoaderCircle className="size-3.5 animate-spin" />
|
||||
{translate(
|
||||
'auto.components.right.sidebar.checks.panel.content.1f2b980522',
|
||||
'Loading check details…'
|
||||
)}
|
||||
</div>
|
||||
{activeWorktree && (
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className="h-7 gap-1 px-2 text-[11px]"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
openFullDetailsTab()
|
||||
}}
|
||||
>
|
||||
{translate(
|
||||
'auto.components.right.sidebar.checks.panel.content.e4e3af15ee',
|
||||
'View full details'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
@@ -717,30 +746,22 @@ function CheckRunDetails({
|
||||
)}
|
||||
|
||||
<div className="flex justify-end pt-1">
|
||||
{!state?.loading && (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className="h-7 gap-1 px-2 text-[11px]"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
{translate(
|
||||
'auto.components.right.sidebar.checks.panel.content.e4e3af15ee',
|
||||
'View full details'
|
||||
)}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<CheckRunDetailsDialog
|
||||
check={check}
|
||||
state={state}
|
||||
detailsStatusCheck={detailsStatusCheck}
|
||||
jobs={jobs}
|
||||
openUrl={openUrl}
|
||||
/>
|
||||
</Dialog>
|
||||
{activeWorktree && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className="h-7 gap-1 px-2 text-[11px]"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
openFullDetailsTab()
|
||||
}}
|
||||
>
|
||||
{translate(
|
||||
'auto.components.right.sidebar.checks.panel.content.e4e3af15ee',
|
||||
'View full details'
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -749,253 +770,7 @@ function CheckRunDetails({
|
||||
)
|
||||
}
|
||||
|
||||
export function CheckRunDetailsDialog({
|
||||
check,
|
||||
state,
|
||||
detailsStatusCheck,
|
||||
jobs,
|
||||
openUrl
|
||||
}: {
|
||||
check: PRCheckDetail
|
||||
state: CheckDetailsLoadState | undefined
|
||||
detailsStatusCheck: PRCheckDetail
|
||||
jobs: NonNullable<PRCheckRunDetails['jobs']>
|
||||
openUrl: string | null | undefined
|
||||
}): React.JSX.Element {
|
||||
const details = state?.details
|
||||
const startedAt = formatCheckTimestamp(details?.startedAt)
|
||||
const completedAt = formatCheckTimestamp(details?.completedAt)
|
||||
const hasOutput = Boolean(details?.title || details?.summary || details?.text)
|
||||
const hasAnnotations = (details?.annotations.length ?? 0) > 0
|
||||
const hasJobs = jobs.length > 0
|
||||
|
||||
return (
|
||||
<DialogContent
|
||||
className="flex max-h-[85vh] w-[min(760px,calc(100vw-2rem))] max-w-none flex-col gap-0 overflow-hidden p-0"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<DialogHeader className="border-b border-border px-5 py-4 pr-12">
|
||||
<DialogTitle className="truncate text-base">{check.name}</DialogTitle>
|
||||
<DialogDescription className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
|
||||
<span>
|
||||
{translate('auto.components.right.sidebar.checks.panel.content.a54ae21c6f', 'Status:')}
|
||||
{details ? getCheckStatusLabel(detailsStatusCheck) : getCheckStatusLabel(check)}
|
||||
</span>
|
||||
{startedAt && (
|
||||
<span>
|
||||
{translate(
|
||||
'auto.components.right.sidebar.checks.panel.content.fd46a70f1a',
|
||||
'Started'
|
||||
)}
|
||||
{startedAt}
|
||||
</span>
|
||||
)}
|
||||
{completedAt && (
|
||||
<span>
|
||||
{translate(
|
||||
'auto.components.right.sidebar.checks.panel.content.00e1c1658a',
|
||||
'Completed'
|
||||
)}
|
||||
{completedAt}
|
||||
</span>
|
||||
)}
|
||||
{check.checkRunId && (
|
||||
<span className="font-mono">
|
||||
{translate(
|
||||
'auto.components.right.sidebar.checks.panel.content.aa8494ae3c',
|
||||
'check #'
|
||||
)}
|
||||
{check.checkRunId}
|
||||
</span>
|
||||
)}
|
||||
{check.workflowRunId && (
|
||||
<span className="font-mono">
|
||||
{translate(
|
||||
'auto.components.right.sidebar.checks.panel.content.2dd5ddabc4',
|
||||
'workflow #'
|
||||
)}
|
||||
{check.workflowRunId}
|
||||
</span>
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-4 scrollbar-sleek">
|
||||
<div className="grid gap-4">
|
||||
{state?.error && <div className="text-sm text-muted-foreground">{state.error}</div>}
|
||||
|
||||
{hasOutput && (
|
||||
<section className="rounded-md border border-border bg-background">
|
||||
<div className="border-b border-border px-3 py-2 text-sm font-medium">
|
||||
{translate(
|
||||
'auto.components.right.sidebar.checks.panel.content.d098e5529a',
|
||||
'Output'
|
||||
)}
|
||||
</div>
|
||||
<div className="px-3 py-3">
|
||||
{details?.title && (
|
||||
<div className="mb-2 text-sm font-medium text-foreground">{details.title}</div>
|
||||
)}
|
||||
{details?.summary && (
|
||||
<CommentMarkdown
|
||||
content={details.summary}
|
||||
variant="document"
|
||||
className="min-w-0 max-w-full overflow-hidden break-words text-sm leading-relaxed [&_a]:break-all [&_code]:break-words [&_pre]:max-w-full"
|
||||
/>
|
||||
)}
|
||||
{details?.text && (
|
||||
<CommentMarkdown
|
||||
content={details.text}
|
||||
variant="document"
|
||||
className="mt-3 min-w-0 max-w-full overflow-hidden break-words text-sm leading-relaxed [&_a]:break-all [&_code]:break-words [&_pre]:max-w-full"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{hasAnnotations && (
|
||||
<section className="rounded-md border border-border bg-background">
|
||||
<div className="border-b border-border px-3 py-2 text-sm font-medium">
|
||||
{translate(
|
||||
'auto.components.right.sidebar.checks.panel.content.f2fe8a4e8f',
|
||||
'Annotations'
|
||||
)}
|
||||
</div>
|
||||
<div className="divide-y divide-border/50">
|
||||
{details!.annotations.map((annotation, index) => (
|
||||
<div key={`${annotation.path ?? 'annotation'}-${index}`} className="px-3 py-3">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<span className="min-w-0 break-all font-mono text-xs text-muted-foreground">
|
||||
{annotation.path ??
|
||||
translate(
|
||||
'auto.components.right.sidebar.checks.panel.content.cdbfda4dec',
|
||||
'Annotation'
|
||||
)}
|
||||
{annotation.startLine ? `:${annotation.startLine}` : ''}
|
||||
</span>
|
||||
{annotation.annotationLevel && (
|
||||
<span className="shrink-0 text-xs text-muted-foreground">
|
||||
{annotation.annotationLevel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{annotation.title && (
|
||||
<div className="mt-2 text-sm font-medium text-foreground">
|
||||
{annotation.title}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-2 break-words text-sm text-foreground">
|
||||
{annotation.message}
|
||||
</div>
|
||||
{annotation.rawDetails && (
|
||||
<pre className="mt-2 max-h-60 overflow-auto whitespace-pre-wrap rounded bg-muted/40 p-3 font-mono text-xs text-muted-foreground scrollbar-sleek">
|
||||
{annotation.rawDetails}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{hasJobs && (
|
||||
<section className="rounded-md border border-border bg-background">
|
||||
<div className="border-b border-border px-3 py-2 text-sm font-medium">
|
||||
{translate('auto.components.right.sidebar.checks.panel.content.49731703ea', 'Jobs')}
|
||||
</div>
|
||||
<div className="divide-y divide-border/50">
|
||||
{jobs.map((job, index) => (
|
||||
<div key={`${job.name}-${index}`} className="px-3 py-3">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-medium text-foreground">
|
||||
{job.name}
|
||||
</span>
|
||||
<span className="shrink-0 text-xs text-muted-foreground">
|
||||
{job.conclusion ??
|
||||
job.status ??
|
||||
translate(
|
||||
'auto.components.right.sidebar.checks.panel.content.ee07b33924',
|
||||
'unknown'
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{job.steps.length > 0 && (
|
||||
<div className="mt-2 grid gap-1">
|
||||
{job.steps.map((step) => (
|
||||
<div
|
||||
key={step.name}
|
||||
className="flex min-w-0 items-center gap-2 text-xs text-muted-foreground"
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate">{step.name}</span>
|
||||
<span className="shrink-0">{step.conclusion ?? step.status}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{job.logTail && <CheckJobLogTail logTail={job.logTail} />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{!state?.error && !hasOutput && !hasAnnotations && !hasJobs && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.right.sidebar.checks.panel.content.07eccfa397',
|
||||
'No details are available for this check.'
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{openUrl && (
|
||||
<div className="flex justify-end border-t border-border px-5 py-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
window.api.shell.openUrl(openUrl)
|
||||
}}
|
||||
>
|
||||
{translate(
|
||||
'auto.components.right.sidebar.checks.panel.content.a916648574',
|
||||
'Open details'
|
||||
)}
|
||||
<ExternalLink className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
)
|
||||
}
|
||||
|
||||
export function CheckJobLogTail({ logTail }: { logTail: string }): React.JSX.Element {
|
||||
return (
|
||||
<div className="mt-3 min-w-0">
|
||||
<div className="mb-1.5 flex min-w-0 items-center gap-2">
|
||||
<div className="min-w-0 flex-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.right.sidebar.checks.panel.content.d713f500b2',
|
||||
'Log tail (last 200 lines)'
|
||||
)}
|
||||
</div>
|
||||
<CopyButton
|
||||
text={logTail}
|
||||
title={translate(
|
||||
'auto.components.right.sidebar.checks.panel.content.679bf2093c',
|
||||
'Copy log tail'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<pre className="max-h-72 overflow-auto whitespace-pre-wrap rounded bg-muted/40 p-3 font-mono text-xs text-muted-foreground scrollbar-sleek">
|
||||
{logTail}
|
||||
</pre>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export { CheckJobLogTail } from './check-job-log-tail'
|
||||
|
||||
/** Renders the checks summary bar + scrollable check list. */
|
||||
export function ChecksList({
|
||||
@@ -1009,6 +784,8 @@ export function ChecksList({
|
||||
checkDetailsContextKey: string
|
||||
onLoadCheckDetails?: (check: PRCheckDetail) => Promise<PRCheckRunDetails | null>
|
||||
}): React.JSX.Element {
|
||||
const activeWorktree = useActiveWorktree()
|
||||
const patchOpenCheckRunDetails = useAppStore((s) => s.patchOpenCheckRunDetails)
|
||||
const [checksExpanded, setChecksExpanded] = useState(true)
|
||||
const [expandedCheckKeys, setExpandedCheckKeys] = useState<Set<string>>(new Set())
|
||||
const [detailsByCheckKey, setDetailsByCheckKey] = useState<Record<string, CheckDetailsLoadState>>(
|
||||
@@ -1073,6 +850,27 @@ export function ChecksList({
|
||||
})
|
||||
}, [checkDetailsContextKey, rows])
|
||||
|
||||
useEffect(() => {
|
||||
setDetailsByCheckKey((current) => {
|
||||
let changed = false
|
||||
const next: Record<string, CheckDetailsLoadState> = { ...current }
|
||||
for (const row of rows) {
|
||||
const cached = next[row.key]
|
||||
if (!cached?.details) {
|
||||
continue
|
||||
}
|
||||
if (
|
||||
cached.details.status !== row.check.status ||
|
||||
cached.details.conclusion !== row.check.conclusion
|
||||
) {
|
||||
delete next[row.key]
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
return changed ? next : current
|
||||
})
|
||||
}, [rows])
|
||||
|
||||
const requestCheckDetails = useCallback(
|
||||
(row: { check: PRCheckDetail; key: string }) => {
|
||||
if (detailsByCheckKey[row.key]?.loading || detailsByCheckKey[row.key]?.details) {
|
||||
@@ -1153,6 +951,23 @@ export function ChecksList({
|
||||
}
|
||||
}, [checksExpanded, detailsByCheckKey, expandedCheckKeys, requestCheckDetails, rows])
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeWorktree) {
|
||||
return
|
||||
}
|
||||
for (const row of rows) {
|
||||
const detailsState = detailsByCheckKey[row.key]
|
||||
if (!detailsState) {
|
||||
continue
|
||||
}
|
||||
patchOpenCheckRunDetails(activeWorktree.id, checkDetailsContextKey, row.check, {
|
||||
details: detailsState.details ?? null,
|
||||
loading: detailsState.loading ?? false,
|
||||
error: detailsState.error ?? null
|
||||
})
|
||||
}
|
||||
}, [activeWorktree, checkDetailsContextKey, detailsByCheckKey, patchOpenCheckRunDetails, rows])
|
||||
|
||||
const toggleCheckExpanded = useCallback(
|
||||
(row: { check: PRCheckDetail; key: string }) => {
|
||||
const willExpand = !expandedCheckKeys.has(row.key)
|
||||
@@ -1304,7 +1119,13 @@ export function ChecksList({
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{expanded && <CheckRunDetails check={check} state={detailsByCheckKey[row.key]} />}
|
||||
{expanded && (
|
||||
<CheckRunDetails
|
||||
check={check}
|
||||
state={detailsByCheckKey[row.key]}
|
||||
checkDetailsContextKey={checkDetailsContextKey}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useSortable } from '@dnd-kit/sortable'
|
||||
import { X, GitCompareArrows, Eye, ShieldAlert, Pin } from 'lucide-react'
|
||||
import { X, GitCompareArrows, Eye, ShieldAlert, Pin, ListChecks } from 'lucide-react'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { basename, normalizeRelativePath } from '@/lib/path'
|
||||
@@ -77,6 +77,7 @@ export default function EditorFileTab({
|
||||
|
||||
const isDiff = file.mode === 'diff'
|
||||
const isConflictReview = file.mode === 'conflict-review'
|
||||
const isCheckDetails = file.mode === 'check-details'
|
||||
const isMarkdownPreviewTab = file.mode === 'markdown-preview'
|
||||
const resolvedLanguage =
|
||||
file.mode === 'diff'
|
||||
@@ -241,6 +242,10 @@ export default function EditorFileTab({
|
||||
<ShieldAlert
|
||||
className={`w-3 h-3 mr-1 shrink-0 ${isActive ? 'text-orange-400' : 'text-orange-400/70'}`}
|
||||
/>
|
||||
) : isCheckDetails ? (
|
||||
<ListChecks
|
||||
className={`w-3 h-3 mr-1 shrink-0 ${isActive ? 'text-foreground' : 'text-muted-foreground'}`}
|
||||
/>
|
||||
) : isDiff ? (
|
||||
<GitCompareArrows
|
||||
className={`w-3 h-3 mr-1 shrink-0 ${isActive ? 'text-foreground' : 'text-muted-foreground'}`}
|
||||
|
||||
@@ -34,7 +34,11 @@ function TabIcon({ item }: { item: RecentTabSwitcherItem }): React.JSX.Element {
|
||||
if (item.type === 'browser') {
|
||||
return <Globe2 className={className} />
|
||||
}
|
||||
if (item.contentType === 'diff' || item.contentType === 'conflict-review') {
|
||||
if (
|
||||
item.contentType === 'diff' ||
|
||||
item.contentType === 'conflict-review' ||
|
||||
item.contentType === 'check-details'
|
||||
) {
|
||||
return <GitCompare className={className} />
|
||||
}
|
||||
return <FileText className={className} />
|
||||
|
||||
@@ -166,7 +166,8 @@ export function useTabGroupWorkspaceModel({
|
||||
(item) =>
|
||||
item.contentType === 'editor' ||
|
||||
item.contentType === 'diff' ||
|
||||
item.contentType === 'conflict-review'
|
||||
item.contentType === 'conflict-review' ||
|
||||
item.contentType === 'check-details'
|
||||
)
|
||||
.map((item) => {
|
||||
const file = worktreeState.openFiles.find((candidate) => candidate.id === item.entityId)
|
||||
@@ -196,7 +197,8 @@ export function useTabGroupWorkspaceModel({
|
||||
item.entityId === entityId &&
|
||||
(item.contentType === 'editor' ||
|
||||
item.contentType === 'diff' ||
|
||||
item.contentType === 'conflict-review')
|
||||
item.contentType === 'conflict-review' ||
|
||||
item.contentType === 'check-details')
|
||||
)
|
||||
if (!otherReference) {
|
||||
const file = useAppStore.getState().openFiles.find((candidate) => candidate.id === entityId)
|
||||
@@ -509,7 +511,8 @@ export function useTabGroupWorkspaceModel({
|
||||
if (
|
||||
item.contentType === 'editor' ||
|
||||
item.contentType === 'diff' ||
|
||||
item.contentType === 'conflict-review'
|
||||
item.contentType === 'conflict-review' ||
|
||||
item.contentType === 'check-details'
|
||||
) {
|
||||
closeItem(item.id)
|
||||
}
|
||||
|
||||
@@ -12,7 +12,12 @@ import {
|
||||
import { resolveHostSessionTabIdForWebSessionTab } from '@/runtime/web-session-tabs-sync'
|
||||
import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner'
|
||||
|
||||
const EDITOR_TAB_CONTENT_TYPES = new Set<TabContentType>(['editor', 'diff', 'conflict-review'])
|
||||
const EDITOR_TAB_CONTENT_TYPES = new Set<TabContentType>([
|
||||
'editor',
|
||||
'diff',
|
||||
'conflict-review',
|
||||
'check-details'
|
||||
])
|
||||
|
||||
type TerminalTabActionState = ReturnType<typeof useAppStore.getState>
|
||||
|
||||
|
||||
@@ -1839,6 +1839,166 @@ describe('createEditorSlice conflict status reconciliation', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('reloads an open check-details tab from the hosted provider', async () => {
|
||||
const fetchPRCheckDetails = vi.fn().mockResolvedValue({
|
||||
name: 'verify',
|
||||
status: 'completed',
|
||||
conclusion: 'success',
|
||||
url: null,
|
||||
detailsUrl: null,
|
||||
startedAt: null,
|
||||
completedAt: null,
|
||||
title: 'Build passed',
|
||||
summary: null,
|
||||
text: null,
|
||||
annotations: [],
|
||||
jobs: []
|
||||
})
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const store = createStore<any>()((...args: any[]) => ({
|
||||
activeWorktreeId: 'wt-1',
|
||||
repos: [{ id: 'repo-1', path: '/repo' }],
|
||||
worktreesByRepo: { 'repo-1': [{ id: 'wt-1', repoId: 'repo-1', path: '/repo' }] },
|
||||
fetchPRCheckDetails,
|
||||
...createEditorSlice(...(args as Parameters<typeof createEditorSlice>))
|
||||
})) as unknown as StoreApi<AppState>
|
||||
const check = {
|
||||
name: 'verify',
|
||||
status: 'completed' as const,
|
||||
conclusion: 'failure' as const,
|
||||
url: null,
|
||||
checkRunId: 42
|
||||
}
|
||||
|
||||
store.getState().openCheckRunDetails('wt-1', 'repo:99', check, {
|
||||
details: null,
|
||||
loading: false,
|
||||
error: null
|
||||
})
|
||||
|
||||
await store.getState().reloadOpenCheckRunDetailsTab('wt-1::check-details::check-run:42')
|
||||
|
||||
expect(fetchPRCheckDetails).toHaveBeenCalledWith(
|
||||
'/repo',
|
||||
expect.objectContaining({ checkRunId: 42, checkName: 'verify' }),
|
||||
{ repoId: 'repo-1' }
|
||||
)
|
||||
expect(store.getState().openFiles).toContainEqual(
|
||||
expect.objectContaining({
|
||||
id: 'wt-1::check-details::check-run:42',
|
||||
checkRunDetails: expect.objectContaining({
|
||||
loading: false,
|
||||
details: expect.objectContaining({ title: 'Build passed', conclusion: 'success' })
|
||||
})
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('patches an open check-details tab without changing the active file', () => {
|
||||
const store = createEditorTabsStore()
|
||||
const check = {
|
||||
name: 'verify',
|
||||
status: 'completed' as const,
|
||||
conclusion: 'failure' as const,
|
||||
url: null,
|
||||
checkRunId: 42
|
||||
}
|
||||
|
||||
store.getState().openCheckRunDetails('wt-1', 'repo:99', check, {
|
||||
details: null,
|
||||
loading: true,
|
||||
error: null
|
||||
})
|
||||
store.getState().openFile({
|
||||
filePath: '/repo/other.ts',
|
||||
relativePath: 'other.ts',
|
||||
worktreeId: 'wt-1',
|
||||
language: 'typescript',
|
||||
mode: 'edit'
|
||||
})
|
||||
|
||||
store.getState().patchOpenCheckRunDetails('wt-1', 'repo:99', check, {
|
||||
details: {
|
||||
name: 'verify',
|
||||
status: 'completed',
|
||||
conclusion: 'failure',
|
||||
url: null,
|
||||
detailsUrl: null,
|
||||
startedAt: null,
|
||||
completedAt: null,
|
||||
title: 'Build failed',
|
||||
summary: null,
|
||||
text: null,
|
||||
annotations: [],
|
||||
jobs: []
|
||||
},
|
||||
loading: false,
|
||||
error: null
|
||||
})
|
||||
|
||||
expect(store.getState().activeFileId).toBe('/repo/other.ts')
|
||||
expect(store.getState().openFiles).toContainEqual(
|
||||
expect.objectContaining({
|
||||
id: 'wt-1::check-details::check-run:42',
|
||||
checkRunDetails: expect.objectContaining({
|
||||
loading: false,
|
||||
details: expect.objectContaining({ title: 'Build failed' })
|
||||
})
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('opens check full details as a center-pane editor tab', () => {
|
||||
const store = createEditorTabsStore()
|
||||
const check = {
|
||||
name: 'verify',
|
||||
status: 'completed' as const,
|
||||
conclusion: 'failure' as const,
|
||||
url: null,
|
||||
checkRunId: 42
|
||||
}
|
||||
|
||||
store.getState().openCheckRunDetails('wt-1', 'repo:99', check, {
|
||||
details: {
|
||||
name: 'verify',
|
||||
status: 'completed',
|
||||
conclusion: 'failure',
|
||||
url: null,
|
||||
detailsUrl: null,
|
||||
startedAt: null,
|
||||
completedAt: null,
|
||||
title: 'Build failed',
|
||||
summary: null,
|
||||
text: null,
|
||||
annotations: [],
|
||||
jobs: []
|
||||
},
|
||||
loading: false,
|
||||
error: null
|
||||
})
|
||||
|
||||
expect(store.getState().activeFileId).toBe('wt-1::check-details::check-run:42')
|
||||
expect(store.getState().openFiles).toContainEqual(
|
||||
expect.objectContaining({
|
||||
id: 'wt-1::check-details::check-run:42',
|
||||
mode: 'check-details',
|
||||
relativePath: 'verify',
|
||||
checkRunDetails: expect.objectContaining({
|
||||
contextKey: 'repo:99',
|
||||
check,
|
||||
details: expect.objectContaining({ title: 'Build failed' })
|
||||
})
|
||||
})
|
||||
)
|
||||
expect(store.getState().unifiedTabsByWorktree['wt-1']).toContainEqual(
|
||||
expect.objectContaining({
|
||||
entityId: 'wt-1::check-details::check-run:42',
|
||||
contentType: 'check-details',
|
||||
label: 'verify'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps the conflict review active when selecting a conflict from its tree', () => {
|
||||
const store = createEditorStore()
|
||||
|
||||
|
||||
@@ -5,6 +5,11 @@ import { joinPath } from '@/lib/path'
|
||||
import { toast } from 'sonner'
|
||||
import { isPathInsideOrEqual } from '../../../../shared/cross-platform-path'
|
||||
import { resolveMarkdownLinkTarget } from '@/components/editor/markdown-internal-links'
|
||||
import {
|
||||
buildCheckRunDetailsTabId,
|
||||
getCheckRunDetailsTabLabel,
|
||||
type OpenCheckRunDetailsState
|
||||
} from '@/components/editor/check-run-details-tab'
|
||||
import { openHttpLink } from '@/lib/http-link-routing'
|
||||
import { isLocalPathOpenBlocked, showLocalPathOpenBlockedToast } from '@/lib/local-path-open-guard'
|
||||
import { detectLanguage } from '@/lib/language-detect'
|
||||
@@ -217,7 +222,10 @@ export type OpenFile = {
|
||||
* tab from the tree bumps this so the panel refetches instead of reusing a
|
||||
* stale snapshot. */
|
||||
diffContentReloadNonce?: number
|
||||
mode: 'edit' | 'diff' | 'conflict-review' | 'markdown-preview'
|
||||
/** Why: CI check full-details tabs are virtual editor tabs backed by fetched
|
||||
* PR check-run metadata instead of a file on disk. */
|
||||
checkRunDetails?: OpenCheckRunDetailsState
|
||||
mode: 'edit' | 'diff' | 'conflict-review' | 'markdown-preview' | 'check-details'
|
||||
}
|
||||
|
||||
export type ActivityBarPosition = 'top' | 'side'
|
||||
@@ -471,6 +479,19 @@ export type EditorSlice = {
|
||||
entries: ConflictReviewEntry[],
|
||||
source: ConflictReviewState['source']
|
||||
) => void
|
||||
openCheckRunDetails: (
|
||||
worktreeId: string,
|
||||
contextKey: string,
|
||||
check: OpenCheckRunDetailsState['check'],
|
||||
state: Pick<OpenCheckRunDetailsState, 'details' | 'loading' | 'error'>
|
||||
) => void
|
||||
patchOpenCheckRunDetails: (
|
||||
worktreeId: string,
|
||||
contextKey: string,
|
||||
check: OpenCheckRunDetailsState['check'],
|
||||
state: Pick<OpenCheckRunDetailsState, 'details' | 'loading' | 'error'>
|
||||
) => void
|
||||
reloadOpenCheckRunDetailsTab: (fileId: string) => Promise<void>
|
||||
openBranchAllDiffs: (
|
||||
worktreeId: string,
|
||||
worktreePath: string,
|
||||
@@ -623,7 +644,7 @@ function openWorkspaceEditorItem(
|
||||
fileId: string,
|
||||
worktreeId: string,
|
||||
label: string,
|
||||
contentType: 'editor' | 'diff' | 'conflict-review',
|
||||
contentType: 'editor' | 'diff' | 'conflict-review' | 'check-details',
|
||||
isPreview?: boolean,
|
||||
targetGroupId?: string
|
||||
): string {
|
||||
@@ -652,7 +673,12 @@ function openWorkspaceEditorItem(
|
||||
}
|
||||
|
||||
function isEditorTabContentType(contentType: Tab['contentType']): boolean {
|
||||
return contentType === 'editor' || contentType === 'diff' || contentType === 'conflict-review'
|
||||
return (
|
||||
contentType === 'editor' ||
|
||||
contentType === 'diff' ||
|
||||
contentType === 'conflict-review' ||
|
||||
contentType === 'check-details'
|
||||
)
|
||||
}
|
||||
|
||||
function getReplaceablePreviewFileId(
|
||||
@@ -1630,8 +1656,14 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
|
||||
let editorItemWorktreeId = file.worktreeId
|
||||
let editorItemFileId = file.filePath
|
||||
let editorItemLabel = file.relativePath
|
||||
let editorItemContentType: 'editor' | 'diff' | 'conflict-review' =
|
||||
file.mode === 'conflict-review' ? 'conflict-review' : file.mode === 'diff' ? 'diff' : 'editor'
|
||||
let editorItemContentType: 'editor' | 'diff' | 'conflict-review' | 'check-details' =
|
||||
file.mode === 'conflict-review'
|
||||
? 'conflict-review'
|
||||
: file.mode === 'check-details'
|
||||
? 'check-details'
|
||||
: file.mode === 'diff'
|
||||
? 'diff'
|
||||
: 'editor'
|
||||
let editorItemTargetGroupId = options?.targetGroupId
|
||||
set((s) => {
|
||||
const worktreeId = file.worktreeId
|
||||
@@ -2218,7 +2250,8 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
|
||||
entry.entityId === fileId &&
|
||||
(entry.contentType === 'editor' ||
|
||||
entry.contentType === 'diff' ||
|
||||
entry.contentType === 'conflict-review')
|
||||
entry.contentType === 'conflict-review' ||
|
||||
entry.contentType === 'check-details')
|
||||
)
|
||||
if (unifiedTab) {
|
||||
get().closeUnifiedTab(unifiedTab.id)
|
||||
@@ -2261,7 +2294,8 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
|
||||
(item) =>
|
||||
(item.contentType === 'editor' ||
|
||||
item.contentType === 'diff' ||
|
||||
item.contentType === 'conflict-review') &&
|
||||
item.contentType === 'conflict-review' ||
|
||||
item.contentType === 'check-details') &&
|
||||
(!activeWorktreeId || item.worktreeId === activeWorktreeId)
|
||||
)
|
||||
.map((item) => item.id)
|
||||
@@ -3097,6 +3131,152 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
|
||||
void openWorkspaceEditorItem(get(), id, worktreeId, 'Conflict Review', 'conflict-review')
|
||||
},
|
||||
|
||||
// Why: the checks sidebar only has room for inline summaries; full logs and
|
||||
// annotations belong in the center editor pane like diff tabs.
|
||||
openCheckRunDetails: (worktreeId, contextKey, check, state) => {
|
||||
const id = buildCheckRunDetailsTabId(worktreeId, check)
|
||||
const label = getCheckRunDetailsTabLabel(check)
|
||||
const checkRunDetails: OpenCheckRunDetailsState = {
|
||||
contextKey,
|
||||
check,
|
||||
details: state.details,
|
||||
loading: state.loading,
|
||||
error: state.error
|
||||
}
|
||||
set((s) => {
|
||||
const existing = s.openFiles.find((f) => f.id === id)
|
||||
if (existing) {
|
||||
return {
|
||||
openFiles: s.openFiles.map((f) =>
|
||||
f.id === id
|
||||
? {
|
||||
...f,
|
||||
mode: 'check-details' as const,
|
||||
relativePath: label,
|
||||
language: 'plaintext',
|
||||
checkRunDetails
|
||||
}
|
||||
: f
|
||||
),
|
||||
activeFileId: id,
|
||||
activeTabType: 'editor',
|
||||
activeFileIdByWorktree: { ...s.activeFileIdByWorktree, [worktreeId]: id },
|
||||
activeTabTypeByWorktree: { ...s.activeTabTypeByWorktree, [worktreeId]: 'editor' }
|
||||
}
|
||||
}
|
||||
|
||||
const newFile: OpenFile = {
|
||||
id,
|
||||
filePath: id,
|
||||
relativePath: label,
|
||||
worktreeId,
|
||||
language: 'plaintext',
|
||||
isDirty: false,
|
||||
mode: 'check-details',
|
||||
checkRunDetails
|
||||
}
|
||||
|
||||
return {
|
||||
openFiles: [...s.openFiles, newFile],
|
||||
activeFileId: id,
|
||||
activeTabType: 'editor',
|
||||
activeFileIdByWorktree: { ...s.activeFileIdByWorktree, [worktreeId]: id },
|
||||
activeTabTypeByWorktree: { ...s.activeTabTypeByWorktree, [worktreeId]: 'editor' }
|
||||
}
|
||||
})
|
||||
void openWorkspaceEditorItem(get(), id, worktreeId, label, 'check-details')
|
||||
},
|
||||
|
||||
// Why: sidebar detail fetches can finish after a full-details tab is already
|
||||
// open; this updates the tab snapshot without stealing focus from the user.
|
||||
patchOpenCheckRunDetails: (worktreeId, contextKey, check, state) => {
|
||||
const id = buildCheckRunDetailsTabId(worktreeId, check)
|
||||
const nextCheckRunDetails: OpenCheckRunDetailsState = {
|
||||
contextKey,
|
||||
check,
|
||||
details: state.details,
|
||||
loading: state.loading,
|
||||
error: state.error
|
||||
}
|
||||
set((s) => {
|
||||
const existing = s.openFiles.find((f) => f.id === id)
|
||||
if (!existing?.checkRunDetails) {
|
||||
return s
|
||||
}
|
||||
const current = existing.checkRunDetails
|
||||
if (
|
||||
current.contextKey === nextCheckRunDetails.contextKey &&
|
||||
current.check.status === nextCheckRunDetails.check.status &&
|
||||
current.check.conclusion === nextCheckRunDetails.check.conclusion &&
|
||||
current.loading === nextCheckRunDetails.loading &&
|
||||
current.error === nextCheckRunDetails.error &&
|
||||
current.details === nextCheckRunDetails.details
|
||||
) {
|
||||
return s
|
||||
}
|
||||
return {
|
||||
openFiles: s.openFiles.map((f) =>
|
||||
f.id === id ? { ...f, checkRunDetails: nextCheckRunDetails } : f
|
||||
)
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
reloadOpenCheckRunDetailsTab: async (fileId) => {
|
||||
const state = get()
|
||||
const file = state.openFiles.find((candidate) => candidate.id === fileId)
|
||||
const checkRunDetails = file?.checkRunDetails
|
||||
if (!file || file.mode !== 'check-details' || !checkRunDetails) {
|
||||
return
|
||||
}
|
||||
const worktree = findWorktreeById(state.worktreesByRepo, file.worktreeId)
|
||||
const repoId = worktree?.repoId ?? getRepoIdFromWorktreeId(file.worktreeId)
|
||||
const repo = state.repos.find((candidate) => candidate.id === repoId)
|
||||
if (!repo?.path) {
|
||||
return
|
||||
}
|
||||
const { contextKey, check } = checkRunDetails
|
||||
const patch = (next: Pick<OpenCheckRunDetailsState, 'details' | 'loading' | 'error'>): void => {
|
||||
get().patchOpenCheckRunDetails(file.worktreeId, contextKey, check, next)
|
||||
}
|
||||
patch({ details: checkRunDetails.details, loading: true, error: null })
|
||||
try {
|
||||
const details = await get().fetchPRCheckDetails(
|
||||
repo.path,
|
||||
{
|
||||
checkRunId: check.checkRunId,
|
||||
workflowRunId: check.workflowRunId,
|
||||
checkName: check.name,
|
||||
url: check.url,
|
||||
prRepo: null
|
||||
},
|
||||
{ repoId: repo.id }
|
||||
)
|
||||
patch({
|
||||
details,
|
||||
loading: false,
|
||||
error: details
|
||||
? null
|
||||
: translate(
|
||||
'auto.store.slices.editor.checkRunDetailsUnavailable',
|
||||
'No details are available for this check.'
|
||||
)
|
||||
})
|
||||
} catch (error) {
|
||||
patch({
|
||||
details: null,
|
||||
loading: false,
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: translate(
|
||||
'auto.store.slices.editor.checkRunDetailsLoadFailed',
|
||||
'Failed to load check details.'
|
||||
)
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
openBranchAllDiffs: (worktreeId, worktreePath, compare, alternate) => {
|
||||
const branchCompare = toBranchCompareSnapshot(compare)
|
||||
const id = `${worktreeId}::all-diffs::branch::${compare.baseRef}::${branchCompare.compareVersion}`
|
||||
@@ -4271,7 +4451,7 @@ function reconcileOpenFilesForStatus(
|
||||
return [file]
|
||||
}
|
||||
|
||||
if (file.mode === 'conflict-review') {
|
||||
if (file.mode === 'conflict-review' || file.mode === 'check-details') {
|
||||
return [file]
|
||||
}
|
||||
|
||||
|
||||
@@ -155,7 +155,9 @@ export function updateGroup(groups: TabGroup[], updated: TabGroup): TabGroup[] {
|
||||
}
|
||||
|
||||
export function isTransientEditorContentType(contentType: TabContentType): boolean {
|
||||
return contentType === 'diff' || contentType === 'conflict-review'
|
||||
return (
|
||||
contentType === 'diff' || contentType === 'conflict-review' || contentType === 'check-details'
|
||||
)
|
||||
}
|
||||
|
||||
export function getPersistedEditFileIdsByWorktree(
|
||||
|
||||
@@ -239,7 +239,12 @@ function applyTabOrderSortValues(tabs: Tab[], tabOrder: string[]): Tab[] {
|
||||
}
|
||||
|
||||
function isReplaceablePreviewContentType(contentType: Tab['contentType']): boolean {
|
||||
return contentType === 'editor' || contentType === 'diff' || contentType === 'conflict-review'
|
||||
return (
|
||||
contentType === 'editor' ||
|
||||
contentType === 'diff' ||
|
||||
contentType === 'conflict-review' ||
|
||||
contentType === 'check-details'
|
||||
)
|
||||
}
|
||||
|
||||
function canReplacePreviewContentType(
|
||||
@@ -382,7 +387,8 @@ function deriveActiveSurfaceForWorktree(
|
||||
activeFileId =
|
||||
activeUnifiedTab.contentType === 'editor' ||
|
||||
activeUnifiedTab.contentType === 'diff' ||
|
||||
activeUnifiedTab.contentType === 'conflict-review'
|
||||
activeUnifiedTab.contentType === 'conflict-review' ||
|
||||
activeUnifiedTab.contentType === 'check-details'
|
||||
? activeUnifiedTab.entityId
|
||||
: fileStillOpen
|
||||
? restoredFileId
|
||||
|
||||
@@ -2798,7 +2798,8 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
|
||||
activeFileId =
|
||||
activeUnifiedTab.contentType === 'editor' ||
|
||||
activeUnifiedTab.contentType === 'diff' ||
|
||||
activeUnifiedTab.contentType === 'conflict-review'
|
||||
activeUnifiedTab.contentType === 'conflict-review' ||
|
||||
activeUnifiedTab.contentType === 'check-details'
|
||||
? activeUnifiedTab.entityId
|
||||
: fileStillOpen
|
||||
? restoredFileId
|
||||
@@ -3108,7 +3109,8 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
|
||||
const activeFileId =
|
||||
activeUnifiedTab?.contentType === 'editor' ||
|
||||
activeUnifiedTab?.contentType === 'diff' ||
|
||||
activeUnifiedTab?.contentType === 'conflict-review'
|
||||
activeUnifiedTab?.contentType === 'conflict-review' ||
|
||||
activeUnifiedTab?.contentType === 'check-details'
|
||||
? activeUnifiedTab.entityId
|
||||
: fileStillOpen
|
||||
? restoredFileId
|
||||
|
||||
@@ -710,6 +710,7 @@ export type TabContentType =
|
||||
| 'editor'
|
||||
| 'diff'
|
||||
| 'conflict-review'
|
||||
| 'check-details'
|
||||
| 'browser'
|
||||
| 'simulator'
|
||||
|
||||
|
||||
@@ -135,6 +135,7 @@ const tabContentTypeSchema = z.enum([
|
||||
'editor',
|
||||
'diff',
|
||||
'conflict-review',
|
||||
'check-details',
|
||||
'browser',
|
||||
'simulator'
|
||||
])
|
||||
|
||||
Reference in New Issue
Block a user