From 22263a23b01a98042dcbfbc4a8e01a8a101f3eca Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Mon, 15 Jun 2026 20:24:28 -0700 Subject: [PATCH] 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. --- src/renderer/src/assets/main.css | 9 + src/renderer/src/components/Terminal.tsx | 7 +- .../editor/CheckRunDetailsPanel.tsx | 294 +++++++++++++ .../src/components/editor/EditorContent.tsx | 30 ++ .../src/components/editor/EditorPanel.tsx | 4 + .../components/editor/EditorPanelHeader.tsx | 226 +++------- .../editor/EditorPanelHeaderPath.tsx | 206 +++++++++ .../components/editor/EditorPanelShell.tsx | 2 +- .../editor/check-run-details-tab.test.ts | 51 +++ .../editor/check-run-details-tab.ts | 32 ++ .../components/editor/editor-header.test.ts | 31 ++ .../src/components/editor/editor-header.ts | 10 + .../src/components/editor/editor-labels.ts | 4 + .../editor/useClosedEditorTabCleanup.ts | 2 + .../right-sidebar/check-job-log-tail.tsx | 88 ++++ .../right-sidebar/checks-panel-content.tsx | 395 +++++------------- .../src/components/tab-bar/EditorFileTab.tsx | 7 +- .../components/tab-bar/RecentTabSwitcher.tsx | 6 +- .../tab-group/useTabGroupWorkspaceModel.ts | 9 +- .../terminal/terminal-tab-actions.ts | 7 +- src/renderer/src/store/slices/editor.test.ts | 160 +++++++ src/renderer/src/store/slices/editor.ts | 196 ++++++++- .../src/store/slices/tab-group-state.ts | 4 +- src/renderer/src/store/slices/tabs.ts | 10 +- src/renderer/src/store/slices/worktrees.ts | 6 +- src/shared/types.ts | 1 + src/shared/workspace-session-schema.ts | 1 + 27 files changed, 1321 insertions(+), 477 deletions(-) create mode 100644 src/renderer/src/components/editor/CheckRunDetailsPanel.tsx create mode 100644 src/renderer/src/components/editor/EditorPanelHeaderPath.tsx create mode 100644 src/renderer/src/components/editor/check-run-details-tab.test.ts create mode 100644 src/renderer/src/components/editor/check-run-details-tab.ts create mode 100644 src/renderer/src/components/right-sidebar/check-job-log-tail.tsx diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index ce23b26dcb1..a678d74d683 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -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; diff --git a/src/renderer/src/components/Terminal.tsx b/src/renderer/src/components/Terminal.tsx index 2c2c5b71013..2ff48992cee 100644 --- a/src/renderer/src/components/Terminal.tsx +++ b/src/renderer/src/components/Terminal.tsx @@ -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(['editor', 'diff', 'conflict-review']) +const EDITOR_TAB_CONTENT_TYPES = new Set([ + 'editor', + 'diff', + 'conflict-review', + 'check-details' +]) type TerminalStoreSnapshot = ReturnType diff --git a/src/renderer/src/components/editor/CheckRunDetailsPanel.tsx b/src/renderer/src/components/editor/CheckRunDetailsPanel.tsx new file mode 100644 index 00000000000..ab6d2211d15 --- /dev/null +++ b/src/renderer/src/components/editor/CheckRunDetailsPanel.tsx @@ -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 ( +
+
+
+

+ {check.name} +

+ {onRefresh && ( + + )} +
+
+ + {translate('auto.components.editor.CheckRunDetailsPanel.a54ae21c6f', 'Status:')}{' '} + {details ? getCheckStatusLabel(detailsStatusCheck) : getCheckStatusLabel(check)} + + {startedAt && ( + + {translate('auto.components.editor.CheckRunDetailsPanel.fd46a70f1a', 'Started')}{' '} + {startedAt} + + )} + {completedAt && ( + + {translate('auto.components.editor.CheckRunDetailsPanel.00e1c1658a', 'Completed')}{' '} + {completedAt} + + )} + {check.checkRunId && ( + + {translate('auto.components.editor.CheckRunDetailsPanel.aa8494ae3c', 'check #')} + {check.checkRunId} + + )} + {check.workflowRunId && ( + + {translate('auto.components.editor.CheckRunDetailsPanel.2dd5ddabc4', 'workflow #')} + {check.workflowRunId} + + )} +
+
+ +
+ {loading ? ( +
+ + {translate( + 'auto.components.editor.CheckRunDetailsPanel.1f2b980522', + 'Loading check details…' + )} +
+ ) : ( +
+ {error &&
{error}
} + + {hasOutput && ( +
+
+ {translate('auto.components.editor.CheckRunDetailsPanel.d098e5529a', 'Output')} +
+
+ {details?.title && ( +
{details.title}
+ )} + {details?.summary && ( + + )} + {details?.text && ( + + )} +
+
+ )} + + {hasAnnotations && ( +
+
+ {translate( + 'auto.components.editor.CheckRunDetailsPanel.f2fe8a4e8f', + 'Annotations' + )} +
+
+ {details!.annotations.map((annotation, index) => ( +
+
+ + {annotation.path ?? + translate( + 'auto.components.editor.CheckRunDetailsPanel.cdbfda4dec', + 'Annotation' + )} + {annotation.startLine ? `:${annotation.startLine}` : ''} + + {annotation.annotationLevel && ( + + {annotation.annotationLevel} + + )} +
+ {annotation.title && ( +
+ {annotation.title} +
+ )} +
+ {annotation.message} +
+ {annotation.rawDetails && ( +
+                          {annotation.rawDetails}
+                        
+ )} +
+ ))} +
+
+ )} + + {hasJobs && ( +
+
+ {failedJobs.length > 0 + ? translate( + 'auto.components.editor.CheckRunDetailsPanel.066fedd446', + 'Failed jobs' + ) + : translate('auto.components.editor.CheckRunDetailsPanel.49731703ea', 'Jobs')} +
+
+ {jobs.map((job, index) => ( +
+
+ + {job.name} + + + {job.conclusion ?? + job.status ?? + translate( + 'auto.components.editor.CheckRunDetailsPanel.ee07b33924', + 'unknown' + )} + +
+ {job.steps.length > 0 && ( +
+ {job.steps.map((step) => ( +
+ {step.name} + {step.conclusion ?? step.status} +
+ ))} +
+ )} + {job.logTail && } +
+ ))} +
+
+ )} + + {!error && !hasOutput && !hasAnnotations && !hasJobs && ( +
+ {translate( + 'auto.components.editor.CheckRunDetailsPanel.07eccfa397', + 'No details are available for this check.' + )} +
+ )} +
+ )} +
+ + {openUrl && ( +
+ +
+ )} +
+ ) +} diff --git a/src/renderer/src/components/editor/EditorContent.tsx b/src/renderer/src/components/editor/EditorContent.tsx index 1e572714926..2334875d6f9 100644 --- a/src/renderer/src/components/editor/EditorContent.tsx +++ b/src/renderer/src/components/editor/EditorContent.tsx @@ -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 >({}) @@ -614,6 +616,34 @@ export function EditorContent({ ) } + if (activeFile.mode === 'check-details') { + const checkRunDetails = activeFile.checkRunDetails + if (!checkRunDetails) { + return ( +
+ {translate( + 'auto.components.editor.EditorContent.6c4f1a8d2e', + 'Check details are unavailable.' + )} +
+ ) + } + const details = checkRunDetails.details + const openUrl = details?.detailsUrl ?? details?.url ?? checkRunDetails.check.url + return ( + { + void reloadOpenCheckRunDetailsTab(activeFile.id) + }} + /> + ) + } + if (activeFile.mode === 'conflict-review') { return ( { + // 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) diff --git a/src/renderer/src/components/editor/EditorPanelHeader.tsx b/src/renderer/src/components/editor/EditorPanelHeader.tsx index 7d6ee6142cb..2a98413f507 100644 --- a/src/renderer/src/components/editor/EditorPanelHeader.tsx +++ b/src/renderer/src/components/editor/EditorPanelHeader.tsx @@ -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 (
-
-
{ - event.preventDefault() - window.dispatchEvent(new Event(CLOSE_ALL_CONTEXT_MENUS_EVENT)) - setPathMenuPoint({ x: event.clientX, y: event.clientY }) - setPathMenuOpen(true) - }} - > - {isRenaming ? ( - 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} - /> - ) : ( - - )} - - {headerCopyState.copyToastLabel} - -
- - -
+ {isSingleDiff && ( @@ -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} > @@ -263,9 +118,18 @@ export function EditorPanelHeader({ {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' + )} @@ -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' + )} > - {translate("auto.components.editor.EditorPanelHeader.fb8331694e", "Open Preview to the Side")} + {translate( + 'auto.components.editor.EditorPanelHeader.fb8331694e', + 'Open Preview to the Side' + )} + )} @@ -314,7 +185,15 @@ export function EditorPanelHeader({ - {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' + )} @@ -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} > @@ -350,8 +232,14 @@ export function EditorPanelHeader({ {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' + )} diff --git a/src/renderer/src/components/editor/EditorPanelHeaderPath.tsx b/src/renderer/src/components/editor/EditorPanelHeaderPath.tsx new file mode 100644 index 00000000000..f1d0ae5e7b6 --- /dev/null +++ b/src/renderer/src/components/editor/EditorPanelHeaderPath.tsx @@ -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 ( +
+
{ + event.preventDefault() + window.dispatchEvent(new Event(CLOSE_ALL_CONTEXT_MENUS_EVENT)) + setPathMenuPoint({ x: event.clientX, y: event.clientY }) + setPathMenuOpen(true) + }} + > + {isRenaming ? ( + 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} + /> + ) : ( + + )} + + {headerCopyState.copyToastLabel} + +
+ + +
+ ) +} diff --git a/src/renderer/src/components/editor/EditorPanelShell.tsx b/src/renderer/src/components/editor/EditorPanelShell.tsx index 3eac493d120..d8427a44d68 100644 --- a/src/renderer/src/components/editor/EditorPanelShell.tsx +++ b/src/renderer/src/components/editor/EditorPanelShell.tsx @@ -94,7 +94,7 @@ export function EditorPanelShell({ }: EditorPanelShellProps): JSX.Element { return (
- {!model.isCombinedDiff && ( + {!model.isCombinedDiff && activeFile.mode !== 'check-details' && ( { + 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') + }) +}) diff --git a/src/renderer/src/components/editor/check-run-details-tab.ts b/src/renderer/src/components/editor/check-run-details-tab.ts new file mode 100644 index 00000000000..0acd550f7b2 --- /dev/null +++ b/src/renderer/src/components/editor/check-run-details-tab.ts @@ -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 +} diff --git a/src/renderer/src/components/editor/editor-header.test.ts b/src/renderer/src/components/editor/editor-header.test.ts index 458c024bfe8..7cb9e26deec 100644 --- a/src/renderer/src/components/editor/editor-header.test.ts +++ b/src/renderer/src/components/editor/editor-header.test.ts @@ -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( diff --git a/src/renderer/src/components/editor/editor-header.ts b/src/renderer/src/components/editor/editor-header.ts index f5d03b1c786..9cfc39fe495 100644 --- a/src/renderer/src/components/editor/editor-header.ts +++ b/src/renderer/src/components/editor/editor-header.ts @@ -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' || diff --git a/src/renderer/src/components/editor/editor-labels.ts b/src/renderer/src/components/editor/editor-labels.ts index 20c3ad3414d..50e55931db3 100644 --- a/src/renderer/src/components/editor/editor-labels.ts +++ b/src/renderer/src/components/editor/editor-labels.ts @@ -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)` } diff --git a/src/renderer/src/components/editor/useClosedEditorTabCleanup.ts b/src/renderer/src/components/editor/useClosedEditorTabCleanup.ts index 50f0cf5645e..f220230325d 100644 --- a/src/renderer/src/components/editor/useClosedEditorTabCleanup.ts +++ b/src/renderer/src/components/editor/useClosedEditorTabCleanup.ts @@ -66,5 +66,7 @@ function disposeClosedEditorTab(prevId: string, prevFile: OpenFile): void { break case 'conflict-review': break + case 'check-details': + break } } diff --git a/src/renderer/src/components/right-sidebar/check-job-log-tail.tsx b/src/renderer/src/components/right-sidebar/check-job-log-tail.tsx new file mode 100644 index 00000000000..8f13a4f16a1 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/check-job-log-tail.tsx @@ -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(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 ( + + ) +} + +export function CheckJobLogTail({ logTail }: { logTail: string }): React.JSX.Element { + return ( +
+
+
+ {translate( + 'auto.components.right.sidebar.checks.panel.content.d713f500b2', + 'Log tail (last 200 lines)' + )} +
+ +
+
+        {logTail}
+      
+
+ ) +} diff --git a/src/renderer/src/components/right-sidebar/checks-panel-content.tsx b/src/renderer/src/components/right-sidebar/checks-panel-content.tsx index 2723f02d77d..bebb4bdd0c1 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel-content.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel-content.tsx @@ -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 (
{state?.loading ? ( -
- - {translate( - 'auto.components.right.sidebar.checks.panel.content.1f2b980522', - 'Loading check details…' +
+
+ + {translate( + 'auto.components.right.sidebar.checks.panel.content.1f2b980522', + 'Loading check details…' + )} +
+ {activeWorktree && ( +
+ +
)}
) : ( @@ -717,30 +746,22 @@ function CheckRunDetails({ )}
- {!state?.loading && ( - - - - - - + {activeWorktree && ( + )}
@@ -749,253 +770,7 @@ function CheckRunDetails({ ) } -export function CheckRunDetailsDialog({ - check, - state, - detailsStatusCheck, - jobs, - openUrl -}: { - check: PRCheckDetail - state: CheckDetailsLoadState | undefined - detailsStatusCheck: PRCheckDetail - jobs: NonNullable - 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 ( - event.stopPropagation()} - > - - {check.name} - - - {translate('auto.components.right.sidebar.checks.panel.content.a54ae21c6f', 'Status:')} - {details ? getCheckStatusLabel(detailsStatusCheck) : getCheckStatusLabel(check)} - - {startedAt && ( - - {translate( - 'auto.components.right.sidebar.checks.panel.content.fd46a70f1a', - 'Started' - )} - {startedAt} - - )} - {completedAt && ( - - {translate( - 'auto.components.right.sidebar.checks.panel.content.00e1c1658a', - 'Completed' - )} - {completedAt} - - )} - {check.checkRunId && ( - - {translate( - 'auto.components.right.sidebar.checks.panel.content.aa8494ae3c', - 'check #' - )} - {check.checkRunId} - - )} - {check.workflowRunId && ( - - {translate( - 'auto.components.right.sidebar.checks.panel.content.2dd5ddabc4', - 'workflow #' - )} - {check.workflowRunId} - - )} - - -
-
- {state?.error &&
{state.error}
} - - {hasOutput && ( -
-
- {translate( - 'auto.components.right.sidebar.checks.panel.content.d098e5529a', - 'Output' - )} -
-
- {details?.title && ( -
{details.title}
- )} - {details?.summary && ( - - )} - {details?.text && ( - - )} -
-
- )} - - {hasAnnotations && ( -
-
- {translate( - 'auto.components.right.sidebar.checks.panel.content.f2fe8a4e8f', - 'Annotations' - )} -
-
- {details!.annotations.map((annotation, index) => ( -
-
- - {annotation.path ?? - translate( - 'auto.components.right.sidebar.checks.panel.content.cdbfda4dec', - 'Annotation' - )} - {annotation.startLine ? `:${annotation.startLine}` : ''} - - {annotation.annotationLevel && ( - - {annotation.annotationLevel} - - )} -
- {annotation.title && ( -
- {annotation.title} -
- )} -
- {annotation.message} -
- {annotation.rawDetails && ( -
-                        {annotation.rawDetails}
-                      
- )} -
- ))} -
-
- )} - - {hasJobs && ( -
-
- {translate('auto.components.right.sidebar.checks.panel.content.49731703ea', 'Jobs')} -
-
- {jobs.map((job, index) => ( -
-
- - {job.name} - - - {job.conclusion ?? - job.status ?? - translate( - 'auto.components.right.sidebar.checks.panel.content.ee07b33924', - 'unknown' - )} - -
- {job.steps.length > 0 && ( -
- {job.steps.map((step) => ( -
- {step.name} - {step.conclusion ?? step.status} -
- ))} -
- )} - {job.logTail && } -
- ))} -
-
- )} - - {!state?.error && !hasOutput && !hasAnnotations && !hasJobs && ( -
- {translate( - 'auto.components.right.sidebar.checks.panel.content.07eccfa397', - 'No details are available for this check.' - )} -
- )} -
-
- {openUrl && ( -
- -
- )} -
- ) -} - -export function CheckJobLogTail({ logTail }: { logTail: string }): React.JSX.Element { - return ( -
-
-
- {translate( - 'auto.components.right.sidebar.checks.panel.content.d713f500b2', - 'Log tail (last 200 lines)' - )} -
- -
-
-        {logTail}
-      
-
- ) -} +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 }): React.JSX.Element { + const activeWorktree = useActiveWorktree() + const patchOpenCheckRunDetails = useAppStore((s) => s.patchOpenCheckRunDetails) const [checksExpanded, setChecksExpanded] = useState(true) const [expandedCheckKeys, setExpandedCheckKeys] = useState>(new Set()) const [detailsByCheckKey, setDetailsByCheckKey] = useState>( @@ -1073,6 +850,27 @@ export function ChecksList({ }) }, [checkDetailsContextKey, rows]) + useEffect(() => { + setDetailsByCheckKey((current) => { + let changed = false + const next: Record = { ...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({ )}
- {expanded && } + {expanded && ( + + )}
) })} diff --git a/src/renderer/src/components/tab-bar/EditorFileTab.tsx b/src/renderer/src/components/tab-bar/EditorFileTab.tsx index e61dc03a35a..fdb05e06792 100644 --- a/src/renderer/src/components/tab-bar/EditorFileTab.tsx +++ b/src/renderer/src/components/tab-bar/EditorFileTab.tsx @@ -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({ + ) : isCheckDetails ? ( + ) : isDiff ? ( } - if (item.contentType === 'diff' || item.contentType === 'conflict-review') { + if ( + item.contentType === 'diff' || + item.contentType === 'conflict-review' || + item.contentType === 'check-details' + ) { return } return diff --git a/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts b/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts index 35ac3b7b78e..75461305024 100644 --- a/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts +++ b/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts @@ -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) } diff --git a/src/renderer/src/components/terminal/terminal-tab-actions.ts b/src/renderer/src/components/terminal/terminal-tab-actions.ts index 0956b20b691..639d7cd0624 100644 --- a/src/renderer/src/components/terminal/terminal-tab-actions.ts +++ b/src/renderer/src/components/terminal/terminal-tab-actions.ts @@ -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(['editor', 'diff', 'conflict-review']) +const EDITOR_TAB_CONTENT_TYPES = new Set([ + 'editor', + 'diff', + 'conflict-review', + 'check-details' +]) type TerminalTabActionState = ReturnType diff --git a/src/renderer/src/store/slices/editor.test.ts b/src/renderer/src/store/slices/editor.test.ts index f9774e42dfa..e08a7f08295 100644 --- a/src/renderer/src/store/slices/editor.test.ts +++ b/src/renderer/src/store/slices/editor.test.ts @@ -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()((...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)) + })) as unknown as StoreApi + 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() diff --git a/src/renderer/src/store/slices/editor.ts b/src/renderer/src/store/slices/editor.ts index 9fe672cadb7..4c69d181387 100644 --- a/src/renderer/src/store/slices/editor.ts +++ b/src/renderer/src/store/slices/editor.ts @@ -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 + ) => void + patchOpenCheckRunDetails: ( + worktreeId: string, + contextKey: string, + check: OpenCheckRunDetailsState['check'], + state: Pick + ) => void + reloadOpenCheckRunDetailsTab: (fileId: string) => Promise 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 = (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 = (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 = (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 = (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): 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] } diff --git a/src/renderer/src/store/slices/tab-group-state.ts b/src/renderer/src/store/slices/tab-group-state.ts index 5a7c7669433..fa28f634879 100644 --- a/src/renderer/src/store/slices/tab-group-state.ts +++ b/src/renderer/src/store/slices/tab-group-state.ts @@ -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( diff --git a/src/renderer/src/store/slices/tabs.ts b/src/renderer/src/store/slices/tabs.ts index 07058b5a5ec..5e6f310adc7 100644 --- a/src/renderer/src/store/slices/tabs.ts +++ b/src/renderer/src/store/slices/tabs.ts @@ -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 diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index 191115fd5d8..98e7031e891 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -2798,7 +2798,8 @@ export const createWorktreeSlice: StateCreator 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 const activeFileId = activeUnifiedTab?.contentType === 'editor' || activeUnifiedTab?.contentType === 'diff' || - activeUnifiedTab?.contentType === 'conflict-review' + activeUnifiedTab?.contentType === 'conflict-review' || + activeUnifiedTab?.contentType === 'check-details' ? activeUnifiedTab.entityId : fileStillOpen ? restoredFileId diff --git a/src/shared/types.ts b/src/shared/types.ts index cc6ad66414b..85775089206 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -710,6 +710,7 @@ export type TabContentType = | 'editor' | 'diff' | 'conflict-review' + | 'check-details' | 'browser' | 'simulator' diff --git a/src/shared/workspace-session-schema.ts b/src/shared/workspace-session-schema.ts index 387728cefbb..b21c2a8019d 100644 --- a/src/shared/workspace-session-schema.ts +++ b/src/shared/workspace-session-schema.ts @@ -135,6 +135,7 @@ const tabContentTypeSchema = z.enum([ 'editor', 'diff', 'conflict-review', + 'check-details', 'browser', 'simulator' ])