diff --git a/.github/workflows/build-app.yml b/.github/workflows/build-app.yml index cb893aa1..ebe87066 100644 --- a/.github/workflows/build-app.yml +++ b/.github/workflows/build-app.yml @@ -73,8 +73,11 @@ jobs: - name: Install Node Dependencies run: pnpm install - - name: Build Renderer - run: pnpm exec vp build + - name: Build Renderer and Runtime Bridges + run: | + pnpm run build:runtime + pnpm exec vp build + node ./scripts/verify-package-runtime.mjs - name: Build Linux App timeout-minutes: 45 @@ -139,8 +142,11 @@ jobs: - name: Install Node Dependencies run: pnpm install - - name: Build Renderer - run: pnpm exec vp build + - name: Build Renderer and Runtime Bridges + run: | + pnpm run build:runtime + pnpm exec vp build + node ./scripts/verify-package-runtime.mjs - name: Build Windows App shell: bash diff --git a/.gitignore b/.gitignore index 113cc798..77d9de93 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ **/.env.*.local .cache/ .pnpm-debug.log +/.pnpm-store/ /node_modules/ **/node_modules/ coverage/ diff --git a/core/App.css b/core/App.css index 412c2e4b..6585cb0d 100644 --- a/core/App.css +++ b/core/App.css @@ -728,7 +728,7 @@ html[data-codiff-platform='darwin'] .workspace-top-bar { position: relative; } -.share-shell .review-top-bar { +.share-shell:not(.merge-request-shell) .review-top-bar { padding-left: 14px; } @@ -1093,6 +1093,12 @@ a.review-top-bar-source:focus-visible { text-decoration: none; } +.repository-refresh-banner.stale { + background: color-mix(in srgb, #d9822b 12%, var(--code-bg)); + border-color: color-mix(in srgb, #d9822b 32%, transparent); + color: color-mix(in srgb, #d9822b 38%, var(--text)); +} + .repository-change-dismiss { align-items: center; border-radius: 10px; @@ -1173,7 +1179,7 @@ a.review-top-bar-source:focus-visible { corner-shape: squircle; display: grid; gap: 4px; - grid-template-columns: minmax(140px, 240px) 54px repeat(3, 28px); + grid-template-columns: minmax(140px, 240px) 54px auto repeat(3, 28px); min-height: 38px; opacity: 0; padding: 4px; @@ -1216,6 +1222,26 @@ a.review-top-bar-source:focus-visible { color: var(--muted); } +.diff-search-filters { + align-items: center; + display: flex; + gap: 6px; + white-space: nowrap; +} + +.diff-search-filters label { + align-items: center; + color: var(--muted); + display: inline-flex; + font: 11px/1 var(--font-sans); + gap: 3px; +} + +.diff-search-filters input { + accent-color: var(--tree-selection-focus); + margin: 0; +} + .diff-search-count { font: 12px/1 var(--font-mono); text-align: center; @@ -1563,7 +1589,6 @@ html[data-codiff-platform='darwin'] .sidebar { } .sidebar-walkthrough-status.codex .walkthrough-progress { - animation: codex-loading-pulse 1600ms ease-in-out infinite; color: var(--sidebar-text); user-select: none; } @@ -1577,16 +1602,89 @@ html[data-codiff-platform='darwin'] .sidebar { width: 100%; } +.walkthrough-progress-copy { + display: grid; + gap: 6px; + min-width: 0; + text-align: left; + width: 100%; +} + +.walkthrough-progress-heading { + align-items: baseline; + display: inline-flex; + gap: 0.5ch; + justify-content: center; + max-width: 100%; + min-width: 0; +} + .walkthrough-progress-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.walkthrough-progress-units { + display: grid; + gap: 3px; + list-style: none; + margin: 0; + max-height: 12rem; + overflow: auto; + padding: 0; +} + +.walkthrough-progress-unit { + align-items: baseline; + color: var(--muted, inherit); + display: grid; + font-size: 12px; + font-weight: 400; + gap: 8px; + grid-template-columns: 5.5rem minmax(0, 1fr); + opacity: 0.9; +} + +.walkthrough-progress-unit.is-generating { + color: inherit; + opacity: 1; +} + +.walkthrough-progress-unit.is-generating .walkthrough-progress-unit-status { + animation: codex-loading-pulse 1600ms ease-in-out infinite; +} + +.walkthrough-progress-unit.is-ready { + opacity: 0.75; +} + +.walkthrough-progress-unit.is-failed { + color: var(--danger, #a33a3a); +} + +.walkthrough-progress-unit-status { + font-variant-numeric: tabular-nums; + opacity: 0.8; + text-transform: lowercase; +} + +.walkthrough-progress-unit-label { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.walkthrough-progress-unit-detail { + grid-column: 2; + overflow-wrap: anywhere; + white-space: normal; +} + .walkthrough-progress-timer { flex: 0 0 2ch; font-variant-numeric: tabular-nums; - margin-left: 0.5ch; text-align: left; visibility: hidden; width: 2ch; @@ -1604,10 +1702,124 @@ html[data-codiff-platform='darwin'] .sidebar { padding-top: 6px; } +.sidebar-comment-section { + border-bottom: 1px solid var(--sidebar-border); + min-width: 0; +} + +.sidebar-comment-section-toggle { + align-items: center; + background: transparent; + border: 0; + color: var(--sidebar-text); + cursor: pointer; + display: flex; + justify-content: space-between; + min-width: 0; + padding: 8px 10px; + text-align: left; + width: 100%; +} + +.sidebar-comment-section-toggle > span { + align-items: baseline; + display: flex; + gap: 6px; + min-width: 0; +} + +.sidebar-comment-section-toggle strong { + font: 700 11px/1.25 var(--font-sans); + text-transform: uppercase; +} + +.sidebar-comment-section-toggle small { + color: var(--muted); + font: 11px/1.25 var(--font-mono); +} + +.sidebar-comment-section-toggle svg { + color: var(--muted); + flex: none; + transition: transform 120ms ease; +} + +.sidebar-comment-section-toggle svg.collapsed { + transform: rotate(-90deg); +} + +.sidebar-comment-section-body { + min-width: 0; +} + .sidebar-comment-entry.history-entry { grid-template-columns: 34px minmax(0, 1fr); } +.sidebar-inline-comment-entry.history-entry { + display: flex; + flex-direction: column; + gap: 4px; + padding: 7px 8px; +} + +.sidebar-inline-comment-location { + align-items: baseline; + display: flex; + gap: 8px; + justify-content: space-between; + min-width: 0; +} + +.sidebar-inline-comment-location > span:first-child { + color: var(--sidebar-ref); + font: 11px/1.35 var(--font-mono); + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.sidebar-inline-comment-location > span:last-child { + color: var(--muted); + flex: none; + font: 10px/1.35 var(--font-mono); +} + +.sidebar-inline-comment-entry .history-entry-subject { + display: -webkit-box; + font-size: 12px; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; + line-height: 1.4; + white-space: normal; +} + +.sidebar-inline-comment-entry .history-entry-meta { + justify-content: flex-start; +} + +.sidebar-inline-comment-entry .history-entry-author > span:last-child { + flex: initial; + overflow: hidden; + text-overflow: ellipsis; + visibility: visible; + width: auto; +} + +.sidebar-inline-comment-status { + align-items: center; + color: var(--muted); + display: flex; + flex-wrap: wrap; + font: 10px/1.35 var(--font-sans); + gap: 4px 8px; +} + +.sidebar-inline-comment-status a { + color: var(--link); +} + .sidebar-comment-entry .history-entry-ref { font-size: 11px; } @@ -1931,23 +2143,28 @@ html[data-codiff-platform='darwin'] .sidebar { padding: 0 8px 8px 50px; } -.codiff-source-description-footer-row { - align-items: stretch; +.codiff-source-description-overview { + align-items: start; display: grid; - gap: 8px; - grid-template-columns: minmax(0, 1fr) minmax(260px, 320px); + gap: 12px; + grid-template-columns: minmax(0, 1fr) minmax(340px, 380px); + min-width: 0; +} + +.codiff-source-description-overview-main, +.codiff-source-description-overview-aside { + min-width: 0; } -.codiff-source-description-footer-main, -.codiff-source-description-footer-aside { +.codiff-source-description-overview-aside { display: flex; flex-direction: column; - min-width: 0; + gap: 8px; + padding: 8px 8px 8px 0; } -.codiff-source-description-footer-main > *, -.codiff-source-description-footer-aside > * { - flex: 1; +.codiff-source-description-overview-aside > * { + min-width: 0; } .source-description-comment-anonymous + .codiff-source-description-footer { @@ -2157,10 +2374,14 @@ html[data-codiff-platform='darwin'] .sidebar { } @media (max-width: 1024px) { - .codiff-source-description-footer-row { + .codiff-source-description-overview { grid-template-columns: minmax(0, 1fr); } + .codiff-source-description-overview-aside { + padding: 0 8px 8px; + } + .pull-request-merge-controls, .pull-request-merge-actions { justify-content: flex-start; @@ -2177,6 +2398,58 @@ html[data-codiff-platform='darwin'] .sidebar { padding: 11px 12px 48px; } +.review-comments-overview .merge-request-comments-view { + flex: none; + min-height: auto; + overflow: visible; + padding-bottom: 12px; +} + +.review-comments-section-heading { + color: var(--text); + font: 700 13px/1.35 var(--font-sans); + padding: 12px 16px 6px; +} + +.missing-review-comments { + padding-bottom: 16px; +} + +.missing-review-comment-list { + display: flex; + flex-direction: column; + gap: 12px; + padding: 0 16px; +} + +.missing-review-comment-thread { + background: var(--file-bg); + border: 1px solid var(--file-border); + border-radius: 20px; + corner-shape: squircle; + overflow: hidden; +} + +.missing-review-comment-location { + align-items: baseline; + border-bottom: 1px solid var(--file-border); + color: var(--muted); + display: flex; + flex-wrap: wrap; + font: 11px/1.35 var(--font-sans); + gap: 4px 10px; + padding: 8px 12px; +} + +.missing-review-comment-location strong { + color: var(--text); + font-family: var(--font-mono); +} + +.missing-review-comment-thread > .review-comment-thread-group { + padding: 10px 12px; +} + .merge-request-comments-source-description { --codiff-diff-radius: 28px; @@ -3514,6 +3787,26 @@ diffs-container .review-comment-thread { white-space: nowrap; } +.review-comment-header a { + align-items: center; + color: inherit; + display: inline-flex; + gap: 4px; + min-width: 0; + text-decoration: none; +} + +.review-comment-header a:hover { + color: var(--text); +} + +.review-comment-header .review-comment-permalink { + flex: none; + margin-left: auto; + overflow: visible; + white-space: nowrap; +} + .review-comment-header .general-comment-edit-actions { margin-left: auto; overflow: visible; @@ -4126,11 +4419,16 @@ diffs-container .review-comment-thread { @media (max-width: 880px) { .diff-search-panel { - grid-template-columns: minmax(120px, 1fr) 48px 28px 28px 28px; + grid-template-columns: minmax(120px, 1fr) 48px minmax(0, auto) repeat(3, 28px); left: 230px; right: 10px; } + .diff-search-filters { + min-width: 0; + overflow-x: auto; + } + .code-view { font-size: 12px; } diff --git a/core/App.tsx b/core/App.tsx index 6e2fa347..e022baaf 100644 --- a/core/App.tsx +++ b/core/App.tsx @@ -1,840 +1,193 @@ -import { ArrowSquareOutIcon as ArrowSquareOut } from '@phosphor-icons/react/ArrowSquareOut'; -import { ClockCounterClockwiseIcon as ClockCounterClockwise } from '@phosphor-icons/react/ClockCounterClockwise'; -import { PathIcon as Path } from '@phosphor-icons/react/Path'; -import { TreeStructureIcon as TreeStructure } from '@phosphor-icons/react/TreeStructure'; -import type { FileDiffLoadedFiles } from '@pierre/diffs'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { CommandBar } from './app/components/CommandBar.tsx'; -import { KeyboardShortcutsHelp } from './app/components/KeyboardShortcutsHelp.tsx'; -import { OpenReviewSourceDialog } from './app/components/OpenReviewSourceDialog.tsx'; -import { OpenReviewSourceMenu } from './app/components/OpenReviewSourceMenu.tsx'; -import { - AgentUnavailablePanel, - CopyCommentsButton, - DiffSearchPanel, - FirstRunPanel, - isPullRequestReviewActionDisabled, - PullRequestReviewButtons, - RepositoryChangeBanner, - RepositoryLoadErrorPanel, - ReviewSourceLoading, - UpdatePill, - WalkthroughOutdatedBanner, -} from './app/components/Panels.tsx'; +import { useCallback, useEffect, useState } from 'react'; +import { FirstRunPanel, RepositoryLoadErrorPanel } from './app/components/Panels.tsx'; import { PlanEditorView } from './app/components/PlanEditorView.tsx'; -import { ReviewCodeView, type ReviewDiffBlock } from './app/components/ReviewCodeView.tsx'; -import type { ReviewModeItem } from './app/components/ReviewModeControl.tsx'; -import { ReviewTopBar } from './app/components/ReviewTopBar.tsx'; -import { Sidebar } from './app/components/Sidebar.tsx'; -import { CommitView } from './app/components/walkthrough/CommitView.tsx'; -import { - NarrativeWalkthroughView, - type WalkthroughBlockScrollTarget, -} from './app/components/walkthrough/NarrativeWalkthroughView.tsx'; -import { WalkthroughDiffSurface } from './app/components/walkthrough/WalkthroughDiffSurface.tsx'; import { WalkthroughProgress } from './app/components/walkthrough/WalkthroughProgress.tsx'; -import { useAppCommands } from './app/hooks/useAppCommands.ts'; -import { useAppKeyboardShortcuts } from './app/hooks/useAppKeyboardShortcuts.ts'; -import { useAppReviewComments } from './app/hooks/useAppReviewComments.ts'; -import { useAppWalkthrough } from './app/hooks/useAppWalkthrough.ts'; -import { useDiffSearch } from './app/hooks/useDiffSearch.ts'; -import { - getCodeFontLineHeight, - normalizeCodeFontSizePreference, - useDocumentAppearance, -} from './app/hooks/useDocumentAppearance.ts'; -import { useResizableSidebar } from './app/hooks/useResizableSidebar.ts'; -import { useReviewFileState } from './app/hooks/useReviewState.ts'; +import type { WalkthroughFileError } from './app/components/WalkthroughFileError.tsx'; +import { RepositoryReviewHost } from './app/RepositoryReviewHost.tsx'; import { createDefaultConfig } from './config/defaults.ts'; -import { getShortcutLabel } from './config/keymap.ts'; import type { CodiffConfig } from './config/types.ts'; import { defaultAgentSkillStatus, defaultLaunchOptions, defaultTerminalHelperStatus, getAgentLabel, - HISTORY_PAGE_SIZE, } from './lib/app-constants.ts'; +import type { RepositoryLoadError } from './lib/app-types.ts'; +import { sortFiles } from './lib/files.ts'; +import { consumeReloadSelection } from './lib/reload-selection.ts'; import { - type CodeViewInstance, - type RepositoryLoadError, - type ReviewComment, - type ReviewIdentity, - type ReviewScrollBehavior, - type ReviewScrollTarget, - type SourceSession, - type WalkthroughNote, -} from './lib/app-types.ts'; -import { - isPatchOnlyDiffSection, - shouldLoadDiffSectionContents, - shouldPreloadSectionContentsForSearch, -} from './lib/diff.ts'; -import { sortFiles, splitRepositoryPath } from './lib/files.ts'; -import { - consumeReloadSelection, - getChangedPaths, - getReloadDeltaPaths, - getReloadHistorySource, - getReloadMainMode, - getReloadSelectionPath, - haveChangedFiles, - haveReloadedFilesChanged, - writeReloadSelection, -} from './lib/reload-selection.ts'; -import { resolveReviewCommandTarget } from './lib/review-command-target.ts'; -import { - buildReviewCommentsMarkdown, - getReviewCommentsFromState, - getVisibleReviewComments, -} from './lib/review-comments.ts'; -import { getSelectedPathFromScroll } from './lib/review-scroll.ts'; -import { - SIDEBAR_COLLAPSE_THRESHOLD, - readSidebarWidth, - writeSidebarWidth, -} from './lib/sidebar-width.ts'; -import { - getEmptySourceDetail, - getEmptySourceTitle, - getHistorySource, - getRefreshSource, - getRepositoryLoadError, - getSourceKey, - getSourceLabel, - shouldStartInHistoryWhenEmpty, - supportsDiffSearchContentPreload, - supportsLazyDiffContent, - usesViewedFileState, -} from './lib/source.ts'; -import { readViewed, writeViewed } from './lib/viewed.ts'; + resolveReloadSourceForLaunch, + resolveRepositoryReviewBootstrap, + type RepositoryReviewBootstrap, +} from './lib/repository-review-bootstrap.ts'; +import { getRepositoryLoadError } from './lib/source.ts'; import type { - ChangedFile, AgentSkillStatus, + CodiffFeatureFlags, CodiffLaunchOptions, CodiffMarkdownDocument, - CodiffPreferences, - CodiffUpdateStatus, - DefinitionCandidate, GitIdentity, - HistoryEntry, - OpenReviewSourceKind, - RepositoryState, - ReviewSource, + NarrativeWalkthroughResult, TerminalHelperStatus, - DiffSection, } from './types.ts'; -const emptyReviewComments: ReadonlyArray = []; -const emptyWalkthroughNotes = new Map(); -const disableCodeViewWorkerPool = process.env.NODE_ENV === 'test'; - -const getFailedSectionLoadState = (section: DiffSection): DiffSection => - isPatchOnlyDiffSection(section) - ? { - ...section, - summary: { - canLoad: false, - reason: 'Codiff could not load full file context.', - }, - } - : { - ...section, - loadState: 'error', - summary: { - canLoad: false, - reason: 'Codiff could not load this file.', - }, - }; - -const getPreferencesFromConfig = ({ settings }: CodiffConfig): CodiffPreferences => ({ - ...settings, -}); - -const defaultPreferences = getPreferencesFromConfig(createDefaultConfig()); - -const getCollapsedViewedPaths = ( - files: ReadonlyArray, - viewedFiles: Readonly>, -) => - new Set( - files.filter((file) => viewedFiles[file.path] === file.fingerprint).map((file) => file.path), - ); - -const getReloadSourceForLaunch = ( - reloadSelection: ReturnType, - launchOptions: CodiffLaunchOptions, -) => { - if (!reloadSelection) { - return undefined; - } - - if (!launchOptions.source) { - return reloadSelection.source; - } - - return getSourceKey(reloadSelection.source) === getSourceKey(launchOptions.source) - ? reloadSelection.source - : undefined; +const defaultConfig = createDefaultConfig(); +const defaultFeatures: CodiffFeatureFlags = { + planSharing: false, + walkthroughSharing: false, }; export default function App() { - const [loadError, setLoadError] = useState(null); - const [gitIdentity, setGitIdentity] = useState(null); - const [historyEntries, setHistoryEntries] = useState>([]); - const [historyHasMore, setHistoryHasMore] = useState(true); - const [historyLimit, setHistoryLimit] = useState(HISTORY_PAGE_SIZE); - const [historyLoading, setHistoryLoading] = useState(false); - const [historySource, setHistorySource] = useState(null); - const [localChangesDetected, setLocalChangesDetected] = useState(false); - const [updateStatus, setUpdateStatus] = useState(null); - const [launchOptions, setLaunchOptions] = useState(defaultLaunchOptions); - const [codiffConfig, setCodiffConfig] = useState(createDefaultConfig); const [agentSkillInstalling, setAgentSkillInstalling] = useState(false); const [agentSkillStatus, setAgentSkillStatus] = useState(defaultAgentSkillStatus); - const [preferences, setPreferences] = useState(defaultPreferences); - const [reloadDeltaPaths, setReloadDeltaPaths] = useState>(() => new Set()); - const [scrollTarget, setScrollTarget] = useState(null); - const [fileSearchQuery, setFileSearchQuery] = useState(''); - const [historySearchQuery, setHistorySearchQuery] = useState(''); - const [isWindowFullScreen, setIsWindowFullScreen] = useState(false); - const [pendingSource, setPendingSource] = useState(null); + const [config, setConfig] = useState(defaultConfig); + const [features, setFeatures] = useState(defaultFeatures); + const [gitIdentity, setGitIdentity] = useState(null); + const [gitIdentityReady, setGitIdentityReady] = useState(false); + const [launchOptions, setLaunchOptions] = useState(defaultLaunchOptions); + const [launchOptionsLoaded, setLaunchOptionsLoaded] = useState(false); + const [loadError, setLoadError] = useState(null); const [planDocument, setPlanDocument] = useState(null); const [planLoadError, setPlanLoadError] = useState(null); - const [loadingSectionIds, setLoadingSectionIds] = useState>(() => new Set()); - const [openReviewSourceKind, setOpenReviewSourceKind] = useState( + const [repositoryBootstrap, setRepositoryBootstrap] = useState( null, ); - const [sidebarCollapsed, setSidebarCollapsed] = useState(false); - const [state, setState] = useState(null); const [terminalHelperInstalling, setTerminalHelperInstalling] = useState(false); const [terminalHelperStatus, setTerminalHelperStatus] = useState( defaultTerminalHelperStatus, ); - const [sharePlanEnabled, setSharePlanEnabled] = useState(false); - const historyRequestRef = useRef(0); - const historySourceRef = useRef(null); - const loadingSectionKeysRef = useRef>(new Set()); - const programmaticScrollPathRef = useRef(null); - const programmaticScrollTimerRef = useRef(null); - const sourceSessionsRef = useRef>(new Map()); - const stateRef = useRef(null); - const collapsedRef = useRef>(new Set()); - const expandedGeneratedRef = useRef>(new Set()); - const preferencesRef = useRef(defaultPreferences); - const selectedPathRef = useRef(null); - const sourceRequestRef = useRef(0); - const stateGenerationRef = useRef(0); - const markdownRefreshQueueRef = useRef>(Promise.resolve()); - const viewedRef = useRef>({}); - const persistViewed = useCallback((nextViewed: Record) => { - const currentState = stateRef.current; - if (currentState && usesViewedFileState(currentState.source)) { - writeViewed(currentState.root, nextViewed); - } - }, []); - const { - bumpItemVersion, - collapsed, - expandedGenerated, - itemVersionByKey, - selectedPath, - setCollapsed, - setExpandedGenerated, - setItemVersionByKey, - setSelectedPath, - setViewed, - toggleCollapsed, - toggleViewed: toggleReviewViewed, - viewed, - } = useReviewFileState({ onViewedChange: persistViewed }); - const toggleViewed = useCallback( - (file: ChangedFile, isViewed: boolean, reviewIdentity?: ReviewIdentity) => { - if (!stateRef.current) { - return; - } - if (reviewIdentity) { - toggleReviewViewed(file, isViewed, reviewIdentity); - } else { - toggleReviewViewed(file, isViewed); - } - }, - [toggleReviewViewed], - ); - const { - askCodex, - createComment, - deleteComment, - focusCommentId, - focusCommentRequest, - hasPendingReviewComments, - pullRequestReviewSubmitting, - resetCommentFocus, - reviewComments, - reviewCommentsRef, - setReviewComments, - submitPullRequestComment, - submitPullRequestReview, - updateActiveReviewCommentDraft, - updateComment, - } = useAppReviewComments({ - isReviewActionDisabled: isPullRequestReviewActionDisabled, - onCommentFileChange: bumpItemVersion, - stateRef, - }); - const collapseSidebar = useCallback(() => { - setSidebarCollapsed(true); - }, []); - const { resizeSidebar, sidebarWidth } = useResizableSidebar({ - collapseThreshold: SIDEBAR_COLLAPSE_THRESHOLD, - onCollapse: collapseSidebar, - onWidthCommit: writeSidebarWidth, - position: preferences.sidebarPosition, - readWidth: readSidebarWidth, - }); - const { - activeReviewCommandTargetRef, - changeSidebarMode, - closeCommitView, - commitWalkthrough, - enabledShareWalkthrough, - mainModeRef, - narrativeNavigation, - narrativeWalkthrough, - narrativeWalkthroughRef, - openCommitView, - plainCommitModel, - refreshWalkthroughForState, - setMainMode, - setNarrativeWalkthrough, - setShareWalkthroughEnabled, - setSidebarMode, - setWalkthroughError, - setWalkthroughFileError, - setWalkthroughLoading, - setWalkthroughUnread, - showNarrativeWalkthrough, - showPlainCommitView, - sidebarMode, - sidebarModeRef, - startWalkthroughLoading, - subscribeToCommitOutput, - updateActiveWalkthroughReviewTarget, - updateWalkthroughCommitMessage, - walkthroughError, - walkthroughErrorRef, - walkthroughFileError, - walkthroughLoading, - walkthroughProgress, - walkthroughSharing, - walkthroughUnread, - } = useAppWalkthrough({ - preferencesRef, - state, - stateGenerationRef, - stateRef, - }); - const [hunkNavigation, setHunkNavigation] = useState<{ - direction: 1 | -1; - request: number; - } | null>(null); - const showWhitespace = preferences.showWhitespace; - const orderedFiles = useMemo(() => (state ? sortFiles(state.files) : []), [state]); - const { - activeMatch: activeDiffSearchMatch, - activeMatchIndex: effectiveActiveDiffSearchMatchIndex, - closeSearch: closeDiffSearch, - fileFilteredFiles, - focusRequest: diffSearchFocusRequest, - hasQuery: hasDiffSearchQuery, - matches: diffSearchMatches, - matchPathSet: diffSearchMatchPathSet, - moveMatch: moveDiffSearchMatch, - openSearch: openDiffSearch, - query: diffSearchQuery, - resetSearch: resetDiffSearch, - updateQuery: updateDiffSearchQuery, - visible: diffSearchVisible, - visibleFiles, - } = useDiffSearch({ - files: orderedFiles, - fileSearchQuery, - showWhitespace, - }); - - const navigateHunks = useCallback((direction: 1 | -1) => { - setHunkNavigation((current) => ({ - direction, - request: (current?.request ?? 0) + 1, - })); - }, []); - - const loadDiffSection = useCallback( - (file: ChangedFile, section: DiffSection, repositoryState = stateRef.current) => { - const currentState = repositoryState; - if ( - !currentState || - !supportsLazyDiffContent(currentState.source) || - !shouldLoadDiffSectionContents(section) - ) { - return; - } - - const sourceKey = getSourceKey(currentState.source); - const stateGeneration = stateGenerationRef.current; - const key = `${currentState.root}:${sourceKey}:${section.id}`; - if (loadingSectionKeysRef.current.has(key)) { - return; - } - - loadingSectionKeysRef.current.add(key); - setLoadingSectionIds((current) => new Set(current).add(section.id)); - - window.codiff - .getDiffSectionContent({ - force: true, - kind: section.kind, - path: file.path, - showWhitespace: preferencesRef.current.showWhitespace, - source: currentState.source, - }) - .then((loadedSection) => { - setState((current) => { - if ( - stateGenerationRef.current !== stateGeneration || - !current || - current.root !== currentState.root || - getSourceKey(current.source) !== sourceKey - ) { - return current; - } - - return { - ...current, - files: current.files.map((candidate) => - candidate.path === file.path - ? { - ...candidate, - sections: candidate.sections.map((candidateSection) => - candidateSection.id === section.id ? loadedSection : candidateSection, - ), - } - : candidate, - ), - }; - }); - bumpItemVersion(file.path); - }) - .catch(() => { - setState((current) => { - if ( - stateGenerationRef.current !== stateGeneration || - !current || - current.root !== currentState.root || - getSourceKey(current.source) !== sourceKey - ) { - return current; - } - - return { - ...current, - files: current.files.map((candidate) => - candidate.path === file.path - ? { - ...candidate, - sections: candidate.sections.map((candidateSection) => - candidateSection.id === section.id - ? getFailedSectionLoadState(candidateSection) - : candidateSection, - ), - } - : candidate, - ), - }; - }); - bumpItemVersion(file.path); - }) - .finally(() => { - loadingSectionKeysRef.current.delete(key); - setLoadingSectionIds((current) => { - const next = new Set(current); - next.delete(section.id); - return next; - }); - }); - }, - [bumpItemVersion], - ); - - // Fetches full file contents for a patch-only section so the CodeView - // `loadDiffFiles` option can hydrate the rendered diff in place. Unlike - // `loadDiffSection`, this must not touch React state: replacing the section - // would reset the hydrated diff object's identity. - const loadDiffSectionContents = useCallback( - async (file: ChangedFile, section: DiffSection): Promise => { - const currentState = stateRef.current; - if (!currentState || !supportsLazyDiffContent(currentState.source)) { - throw new Error(`Cannot load diff contents for '${file.path}'.`); - } - - const loadedSection = await window.codiff.getDiffSectionContent({ - force: true, - kind: section.kind, - path: file.path, - showWhitespace: preferencesRef.current.showWhitespace, - source: currentState.source, - }); - if (!loadedSection.newFile) { - throw new Error(`No file contents available for '${file.path}'.`); - } - - return { - newFile: loadedSection.newFile, - oldFile: loadedSection.oldFile ?? null, - }; - }, - [], + const [walkthroughLoading, setWalkthroughLoading] = useState(false); + const [walkthroughResult, setWalkthroughResult] = useState(); + const [walkthroughFileError, setWalkthroughFileError] = useState( + null, ); - const refreshMarkdownFile = useCallback( - (file: ChangedFile, _section: DiffSection) => { - const refresh = async () => { - const currentState = stateRef.current; - if ( - !currentState || - (currentState.source.type !== 'working-tree' && - currentState.source.type !== 'branch-working-tree') - ) { - return true; - } - const sourceRequest = sourceRequestRef.current; - const stateGeneration = stateGenerationRef.current; - const sourceKey = getSourceKey(currentState.source); - - try { - const nextState = await window.codiff.getRepositoryState(currentState.source); - const orderedState = { - ...nextState, - files: sortFiles(nextState.files), - }; - if ( - sourceRequestRef.current !== sourceRequest || - stateGenerationRef.current !== stateGeneration || - stateRef.current?.root !== currentState.root || - getSourceKey(stateRef.current.source) !== sourceKey - ) { - return false; - } + useEffect(() => { + let canceled = false; - const changedPaths = getChangedPaths(currentState.files, orderedState.files); - const walkthroughNeedsRefresh = haveChangedFiles(currentState.files, orderedState.files); - stateGenerationRef.current += 1; - stateRef.current = orderedState; - setState(orderedState); - setLocalChangesDetected(false); - setReviewComments(getReviewCommentsFromState(orderedState)); - if (walkthroughNeedsRefresh) { - refreshWalkthroughForState(orderedState); + const loadOptionalBootstrap = () => { + void window.codiff.getConfig().then( + (nextConfig) => { + if (!canceled) { + setConfig(nextConfig); } - setCollapsed((current) => { - const next = new Set(current); - for (const path of changedPaths) { - next.delete(path); - } - return next; - }); - setSelectedPath((current) => - current && orderedState.files.some((candidate) => candidate.path === current) - ? current - : (orderedState.files[0]?.path ?? null), - ); - if (changedPaths.size === 0) { - bumpItemVersion(file.path); - } else { - for (const path of changedPaths) { - bumpItemVersion(path); - } - } - return true; - } catch { - setLocalChangesDetected(true); - return false; - } - }; - - const result = markdownRefreshQueueRef.current.then(refresh, refresh); - markdownRefreshQueueRef.current = result.then( - () => {}, + }, () => {}, ); - return result; - }, - [bumpItemVersion, refreshWalkthroughForState, setCollapsed, setReviewComments, setSelectedPath], - ); - - const scrollPathIntoReview = useCallback((path: string, behavior: ReviewScrollBehavior) => { - setScrollTarget((current) => ({ - behavior, - path, - request: (current?.request ?? 0) + 1, - })); - programmaticScrollPathRef.current = path; - if (programmaticScrollTimerRef.current != null) { - window.clearTimeout(programmaticScrollTimerRef.current); - } - - programmaticScrollTimerRef.current = window.setTimeout(() => { - programmaticScrollPathRef.current = null; - programmaticScrollTimerRef.current = null; - }, 1200); - }, []); - - const saveCurrentSourceSession = useCallback(() => { - const currentState = stateRef.current; - if (!currentState) { - return; - } - - sourceSessionsRef.current.set(getSourceKey(currentState.source), { - collapsed: new Set(collapsedRef.current), - expandedGenerated: new Set(expandedGeneratedRef.current), - narrativeWalkthrough: narrativeWalkthroughRef.current, - reviewComments: reviewCommentsRef.current, - selectedPath: selectedPathRef.current, - viewed: viewedRef.current, - walkthroughError: walkthroughErrorRef.current, - walkthroughFiles: currentState.files.map(({ fingerprint, path, status }) => ({ - fingerprint, - path, - status, - })), - }); - }, [narrativeWalkthroughRef, reviewCommentsRef, walkthroughErrorRef]); - - useEffect(() => { - let canceled = false; - let loadingPlan = false; - - const load = async () => { - const reloadSelection = consumeReloadSelection(); - const nextLaunchOptions = await window.codiff.getLaunchOptions(); - if (canceled) { - return; - } - setLaunchOptions(nextLaunchOptions); - - if (nextLaunchOptions.planFile) { - loadingPlan = true; - const nextPlanDocument = await window.codiff.getMarkdownDocument({ - kind: 'plan', - path: nextLaunchOptions.planFile, - }); - if (!canceled) { - setPlanDocument(nextPlanDocument); - setPlanLoadError(null); - } - return; - } - - const nextAgentSkillStatus = await window.codiff - .getAgentSkillStatus() - .catch(() => defaultAgentSkillStatus); - if (canceled) { - return; - } - setAgentSkillStatus(nextAgentSkillStatus); - - const nextTerminalHelperStatus = await window.codiff - .getTerminalHelperStatus() - .catch(() => defaultTerminalHelperStatus); - if (canceled) { - return; - } - setTerminalHelperStatus(nextTerminalHelperStatus); - - const nextState = await window.codiff.getRepositoryState( - getReloadSourceForLaunch(reloadSelection, nextLaunchOptions), + void window.codiff.getFeatureFlags().then( + (nextFeatures) => { + if (!canceled) { + setFeatures(nextFeatures); + } + }, + () => {}, ); - - if (canceled) { - return; - } - - const orderedState = { - ...nextState, - files: sortFiles(nextState.files), - }; - const nextHistorySource: ReviewSource | null = - getReloadHistorySource(reloadSelection, orderedState) ?? - getHistorySource(orderedState.source) ?? - null; - const history = await window.codiff.getRepositoryHistory( - HISTORY_PAGE_SIZE, - nextHistorySource ?? undefined, + void window.codiff.getAgentSkillStatus().then( + (nextStatus) => { + if (!canceled) { + setAgentSkillStatus(nextStatus); + } + }, + () => {}, ); - - if (canceled) { - return; - } - - const filesPresent = orderedState.files.length > 0; - const reloadSelectedPath = getReloadSelectionPath(reloadSelection, orderedState); - const nextReloadDeltaPaths = getReloadDeltaPaths(reloadSelection, orderedState); - const reloadFilesChanged = haveReloadedFilesChanged(reloadSelection, orderedState); - // A pre-authored `--walkthrough-file` is an explicit request to open in - // walkthrough mode, even without the `-w` flag. Treat it like `walkthrough` - // so the file is actually loaded instead of being silently ignored. - const walkthroughFilePath = nextLaunchOptions.walkthroughFile ?? null; - const shouldLoadNarrative = - (nextLaunchOptions.walkthrough || walkthroughFilePath != null) && filesPresent; - const shouldStartInHistory = - shouldStartInHistoryWhenEmpty(orderedState.source) && orderedState.files.length === 0; - - setLaunchOptions(nextLaunchOptions); - setSidebarMode( - shouldLoadNarrative ? 'walkthrough' : shouldStartInHistory ? 'history' : 'tree', + void window.codiff.getTerminalHelperStatus().then( + (nextStatus) => { + if (!canceled) { + setTerminalHelperStatus(nextStatus); + } + }, + () => {}, ); - if (shouldLoadNarrative) { - startWalkthroughLoading(); - } else { - setWalkthroughLoading(false); - } - - // Always consult the main process for a pre-authored walkthrough file, even - // when the diff is empty, so it can diagnose *why* (e.g. the changes were - // committed) rather than us guessing in the renderer. - const shouldFetchNarrative = shouldLoadNarrative || walkthroughFilePath != null; - const narrativeResult = shouldFetchNarrative - ? reloadFilesChanged && walkthroughFilePath == null - ? await window.codiff.getNarrativeWalkthrough(orderedState.source, { force: true }) - : await window.codiff.getNarrativeWalkthrough(orderedState.source) - : null; - if (canceled) { - return; - } - const loadedNarrative = - narrativeResult?.status === 'ready' ? narrativeResult.walkthrough : null; - setNarrativeWalkthrough(loadedNarrative); - - if (narrativeResult?.status === 'unavailable') { - setWalkthroughError(narrativeResult); - } else { - setWalkthroughError(null); - } - - // When a walkthrough file was explicitly passed but did not anchor, drop - // the reviewer into the history view and float a dismissible banner - // explaining that the diff has moved on, rather than blocking with a modal. - if (walkthroughFilePath != null && loadedNarrative == null) { - setSidebarMode('history'); - setWalkthroughFileError({ - path: walkthroughFilePath, - reason: - narrativeResult?.status === 'unavailable' - ? narrativeResult.reason - : !filesPresent - ? 'No changed files were found for this diff, so the walkthrough file has nothing to anchor to.' - : 'The walkthrough file could not be loaded.', - }); - } else { - setWalkthroughFileError(null); - } - - setWalkthroughLoading(false); - - const nextViewed = usesViewedFileState(orderedState.source) - ? readViewed(orderedState.root) - : {}; - // Reopen the commit view after a reload, but only while it would still be - // openable (same conditions as openCommitView); e.g. once the commit - // lands the working tree may be empty and we fall back to the review. - const restoreCommitView = - getReloadMainMode(reloadSelection, orderedState) === 'commit' && - orderedState.source.type === 'working-tree' && - orderedState.files.length > 0; - if (restoreCommitView) { - setSidebarMode('tree'); - setMainMode('commit'); - } - - setHistoryEntries(history.entries); - setHistoryHasMore(history.entries.length >= HISTORY_PAGE_SIZE); - setHistoryLimit(HISTORY_PAGE_SIZE); - setHistorySource(nextHistorySource ?? null); - stateGenerationRef.current += 1; - stateRef.current = orderedState; - setState(orderedState); - setLoadError(null); - setCollapsed(getCollapsedViewedPaths(orderedState.files, nextViewed)); - setExpandedGenerated(new Set()); - setItemVersionByKey({}); - resetCommentFocus(); - setReloadDeltaPaths(nextReloadDeltaPaths); - setReviewComments(getReviewCommentsFromState(orderedState)); - setViewed(nextViewed); - const nextSelectedPath = reloadSelectedPath ?? orderedState.files[0]?.path ?? null; - setSelectedPath(nextSelectedPath); - if (reloadSelectedPath) { - scrollPathIntoReview(reloadSelectedPath, 'instant'); - } - }; - - load().catch((error: unknown) => { - if (canceled) { - return; - } - - if (loadingPlan) { - setPlanLoadError(error instanceof Error ? error.message : String(error)); - } else { - setLoadError(getRepositoryLoadError(error)); - } - setWalkthroughLoading(false); - }); - - return () => { - canceled = true; }; - }, [ - resetCommentFocus, - scrollPathIntoReview, - setCollapsed, - setExpandedGenerated, - setItemVersionByKey, - setMainMode, - setNarrativeWalkthrough, - setReviewComments, - setSelectedPath, - setSidebarMode, - setViewed, - setWalkthroughError, - setWalkthroughFileError, - setWalkthroughLoading, - startWalkthroughLoading, - ]); - useEffect( - () => - window.codiff.onRepositoryChanged(() => { - setLocalChangesDetected(true); - }), - [], - ); + void window.codiff.getLaunchOptions().then( + (options) => { + if (canceled) { + return; + } + setLaunchOptions(options); + setLaunchOptionsLoaded(true); + loadOptionalBootstrap(); + + if (options.planFile) { + void window.codiff + .getMarkdownDocument({ kind: 'plan', path: options.planFile }) + .then((document) => { + if (!canceled) { + setPlanDocument(document); + setPlanLoadError(null); + } + }) + .catch((error: unknown) => { + if (!canceled) { + setPlanLoadError(error instanceof Error ? error.message : String(error)); + } + }); + return; + } - useEffect(() => { - let canceled = false; + const reloadSelection = consumeReloadSelection(); + void window.codiff + .getRepositoryState(resolveReloadSourceForLaunch(reloadSelection, options)) + .then((loadedState) => { + if (canceled) { + return; + } + const state = { ...loadedState, files: sortFiles(loadedState.files) }; + const bootstrap = resolveRepositoryReviewBootstrap({ + launchOptions: options, + reloadSelection, + state, + }); + setRepositoryBootstrap(bootstrap); + setLoadError(null); + + if (!options.walkthrough && !options.walkthroughFile) { + setWalkthroughLoading(false); + setWalkthroughResult(undefined); + setWalkthroughFileError(null); + return; + } - window.codiff - .getUpdateStatus() - .then((status) => { + setWalkthroughLoading(true); + void window.codiff + .getNarrativeWalkthrough( + state.source, + bootstrap.forceInitialWalkthrough ? { force: true } : undefined, + ) + .catch((error: unknown): NarrativeWalkthroughResult => ({ + reason: error instanceof Error ? error.message : String(error), + status: 'unavailable', + })) + .then((result) => { + if (canceled) { + return; + } + setWalkthroughLoading(false); + setWalkthroughResult(result); + setWalkthroughFileError( + options.walkthroughFile && result.status === 'unavailable' + ? { path: options.walkthroughFile, reason: result.reason } + : null, + ); + }); + }) + .catch((error: unknown) => { + if (!canceled) { + setLoadError(getRepositoryLoadError(error)); + } + }); + }, + (error: unknown) => { if (!canceled) { - setUpdateStatus(status); + setLaunchOptionsLoaded(true); + setLoadError(getRepositoryLoadError(error)); } - }) - .catch(() => {}); + }, + ); - const unsubscribe = window.codiff.onUpdateStatusChanged(setUpdateStatus); + const unsubscribe = window.codiff.onConfigChanged(setConfig); return () => { canceled = true; unsubscribe(); @@ -843,766 +196,46 @@ export default function App() { useEffect(() => { let canceled = false; - - window.codiff - .getGitIdentity() - .then((identity) => { + window.codiff.getGitIdentity().then( + (identity) => { if (!canceled) { setGitIdentity(identity); + setGitIdentityReady(true); } - }) - .catch(() => { + }, + () => { if (!canceled) { setGitIdentity(null); - } - }); - - return () => { - canceled = true; - }; - }, []); - - useEffect(() => { - if (!state || !supportsLazyDiffContent(state.source) || !selectedPath) { - return; - } - - const selectedFile = state.files.find((file) => file.path === selectedPath); - if (!selectedFile) { - return; - } - - const loadableSections = selectedFile.sections.filter(shouldLoadDiffSectionContents); - - if (!loadableSections.length) { - return; - } - - for (const section of loadableSections) { - loadDiffSection(selectedFile, section, state); - } - }, [loadDiffSection, selectedPath, state]); - - useEffect(() => { - if (!state || !supportsDiffSearchContentPreload(state.source) || !diffSearchQuery.trim()) { - return; - } - - const requests = fileFilteredFiles.flatMap((file) => - file.sections.filter(shouldPreloadSectionContentsForSearch).map((section) => ({ - file, - section, - })), - ); - - if (!requests.length) { - return; - } - - let canceled = false; - let cursor = 0; - const sourceKey = getSourceKey(state.source); - const stateGeneration = stateGenerationRef.current; - - const loadNext = async (): Promise => { - if (canceled) { - return; - } - - const request = requests[cursor]; - cursor += 1; - if (!request) { - return; - } - - const key = `${state.root}:${sourceKey}:${request.section.id}`; - if (loadingSectionKeysRef.current.has(key)) { - return loadNext(); - } - - loadingSectionKeysRef.current.add(key); - - try { - const loadedSection = await window.codiff.getDiffSectionContent({ - force: true, - kind: request.section.kind, - path: request.file.path, - showWhitespace: preferences.showWhitespace, - source: state.source, - }); - - if (!canceled) { - setState((current) => { - if ( - stateGenerationRef.current !== stateGeneration || - !current || - current.root !== state.root || - getSourceKey(current.source) !== sourceKey - ) { - return current; - } - - return { - ...current, - files: current.files.map((file) => - file.path === request.file.path - ? { - ...file, - sections: file.sections.map((candidate) => - candidate.id === request.section.id ? loadedSection : candidate, - ), - } - : file, - ), - }; - }); - bumpItemVersion(request.file.path); - } - } catch { - if (!canceled) { - setState((current) => { - if ( - stateGenerationRef.current !== stateGeneration || - !current || - current.root !== state.root || - getSourceKey(current.source) !== sourceKey - ) { - return current; - } - - return { - ...current, - files: current.files.map((file) => - file.path === request.file.path - ? { - ...file, - sections: file.sections.map((candidate) => - candidate.id === request.section.id - ? getFailedSectionLoadState(candidate) - : candidate, - ), - } - : file, - ), - }; - }); - bumpItemVersion(request.file.path); - } - } finally { - loadingSectionKeysRef.current.delete(key); - } - - return loadNext(); - }; - - void Promise.all(Array.from({ length: Math.min(3, requests.length) }, () => loadNext())); - - return () => { - canceled = true; - }; - }, [bumpItemVersion, diffSearchQuery, fileFilteredFiles, preferences.showWhitespace, state]); - - useEffect(() => { - let canceled = false; - - void window.codiff.getFeatureFlags().then( - (flags) => { - if (!canceled) { - setSharePlanEnabled(flags.planSharing); - setShareWalkthroughEnabled(flags.walkthroughSharing); + setGitIdentityReady(true); } }, - () => {}, ); - - window.codiff.getConfig().then((nextConfig) => { - if (!canceled) { - const nextPreferences = getPreferencesFromConfig(nextConfig); - preferencesRef.current = nextPreferences; - setCodiffConfig(nextConfig); - setPreferences(nextPreferences); - } - }); - - const removeConfigListener = window.codiff.onConfigChanged((nextConfig) => { - const previousShowWhitespace = preferencesRef.current.showWhitespace; - const nextPreferences = getPreferencesFromConfig(nextConfig); - preferencesRef.current = nextPreferences; - setCodiffConfig(nextConfig); - setPreferences(nextPreferences); - - if (previousShowWhitespace === nextPreferences.showWhitespace) { - return; - } - - const currentState = stateRef.current; - if (!currentState) { - return; - } - - const request = sourceRequestRef.current + 1; - sourceRequestRef.current = request; - stateGenerationRef.current += 1; - loadingSectionKeysRef.current.clear(); - setLoadingSectionIds(new Set()); - - window.codiff - .getRepositoryState(currentState.source) - .then((nextState) => { - if (sourceRequestRef.current !== request) { - return; - } - - const orderedState = { - ...nextState, - files: sortFiles(nextState.files), - }; - const nextSelectedPath = - selectedPathRef.current && - orderedState.files.some((file) => file.path === selectedPathRef.current) - ? selectedPathRef.current - : (orderedState.files[0]?.path ?? null); - const nextViewed = usesViewedFileState(orderedState.source) - ? readViewed(orderedState.root) - : {}; - const walkthroughNeedsRefresh = haveChangedFiles(currentState.files, orderedState.files); - - stateRef.current = orderedState; - setState(orderedState); - if (walkthroughNeedsRefresh) { - refreshWalkthroughForState(orderedState); - } - setSelectedPath(nextSelectedPath); - setReloadDeltaPaths(new Set()); - setItemVersionByKey({}); - setReviewComments(getReviewCommentsFromState(orderedState)); - setViewed(nextViewed); - setCollapsed(getCollapsedViewedPaths(orderedState.files, nextViewed)); - setExpandedGenerated(new Set()); - setLoadError(null); - }) - .catch((error: unknown) => { - if (sourceRequestRef.current !== request) { - return; - } - setLoadError(getRepositoryLoadError(error)); - }); - }); - return () => { canceled = true; - removeConfigListener(); - }; - }, [ - setCollapsed, - setExpandedGenerated, - setItemVersionByKey, - refreshWalkthroughForState, - setReviewComments, - setShareWalkthroughEnabled, - setSelectedPath, - setViewed, - ]); - - useDocumentAppearance({ - cleanupCodeFontProperties: true, - clearEmptyCodeFontFamily: true, - codeFontFamily: preferences.codeFontFamily, - codeFontSize: preferences.codeFontSize, - theme: preferences.theme, - }); - - useEffect( - () => () => { - if (programmaticScrollTimerRef.current != null) { - window.clearTimeout(programmaticScrollTimerRef.current); - } - }, - [], - ); - - useEffect(() => { - stateRef.current = state; - }, [state]); - - useEffect(() => { - historySourceRef.current = historySource; - }, [historySource]); - - useEffect(() => { - collapsedRef.current = collapsed; - }, [collapsed]); - - useEffect(() => { - expandedGeneratedRef.current = expandedGenerated; - }, [expandedGenerated]); - - useEffect(() => { - preferencesRef.current = preferences; - }, [preferences]); - - useEffect(() => { - const removeListener = window.codiff.onCopyPendingCommentsRequest(() => { - const currentState = stateRef.current; - if (!currentState) { - return ''; - } - - return buildReviewCommentsMarkdown( - currentState.files, - reviewCommentsRef.current, - preferencesRef.current.showWhitespace, - preferencesRef.current.reviewCommentsPrefix, - ); - }); - return removeListener; - }, [reviewCommentsRef]); - - useEffect(() => { - void window.codiff.isWindowFullScreen().then(setIsWindowFullScreen, () => {}); - return window.codiff.onWindowFullScreenChanged(setIsWindowFullScreen); - }, []); - - useEffect(() => { - selectedPathRef.current = selectedPath; - }, [selectedPath]); - - useEffect(() => { - viewedRef.current = viewed; - }, [viewed]); - - const showOutdated = preferences.showOutdated; - const diffStyle = preferences.diffStyle; - const wordWrap = preferences.wordWrap; - const visibleReviewComments = useMemo( - () => getVisibleReviewComments(reviewComments, showOutdated), - [reviewComments, showOutdated], - ); - const toggleSidebar = useCallback(() => { - setSidebarCollapsed((current) => !current); - }, []); - - const toggleWordWrap = useCallback(() => { - void window.codiff.setWordWrap(!preferencesRef.current.wordWrap).catch(() => {}); - }, []); - - const expandSidebar = useCallback(() => { - setSidebarCollapsed(false); - }, []); - - const focusFileFilter = useCallback(() => { - expandSidebar(); - requestAnimationFrame(() => { - const input = document.querySelector('.sidebar-search'); - input?.focus(); - input?.select(); - }); - }, [expandSidebar]); - - const openFile = useCallback((file: ChangedFile) => { - // Deleted files are still shown in diffs, but there is no current file to open. - if (file.status === 'deleted') { - return; - } - - void window.codiff.openFile(file.path).catch(() => {}); - }, []); - - const getReviewCommandTarget = useCallback(() => { - const currentState = stateRef.current; - if (!currentState) { - return null; - } - - return resolveReviewCommandTarget({ - activeTarget: activeReviewCommandTargetRef.current, - files: currentState.files, - selectedPath: selectedPathRef.current, - source: currentState.source, - useActiveTarget: - mainModeRef.current === 'review' && - sidebarModeRef.current === 'walkthrough' && - narrativeWalkthroughRef.current != null, - }); - }, [activeReviewCommandTargetRef, mainModeRef, narrativeWalkthroughRef, sidebarModeRef]); - - const openSelectedFile = useCallback(() => { - const target = getReviewCommandTarget(); - - if (target) { - openFile(target.file); - } - }, [getReviewCommandTarget, openFile]); - - const shouldDeferHunkNavigation = useCallback( - () => sidebarModeRef.current === 'walkthrough' && narrativeWalkthroughRef.current != null, - [narrativeWalkthroughRef, sidebarModeRef], - ); - const { closeCommandBar, commandBarVisible, shortcutsHelpVisible } = useAppKeyboardShortcuts({ - keymap: codiffConfig.keymap, - navigateHunks, - onFocusFileFilter: focusFileFilter, - onOpenDiffSearch: openDiffSearch, - onOpenSelectedFile: openSelectedFile, - onToggleSidebar: toggleSidebar, - onToggleWordWrap: toggleWordWrap, - shouldDeferHunkNavigation, - sidebarCollapsed, - }); - - useEffect(() => window.codiff.onFindInDiffs(openDiffSearch), [openDiffSearch]); - - const loadMoreHistory = useCallback(() => { - if (historyLoading || !historyHasMore) { - return; - } - - const nextLimit = historyLimit + HISTORY_PAGE_SIZE; - const request = historyRequestRef.current + 1; - historyRequestRef.current = request; - setHistoryLoading(true); - window.codiff - .getRepositoryHistory(nextLimit, historySource ?? undefined) - .then((history) => { - if (historyRequestRef.current !== request) { - return; - } - - setHistoryEntries(history.entries); - setHistoryLimit(nextLimit); - setHistoryHasMore(history.entries.length >= nextLimit); - }) - .catch(() => { - if (historyRequestRef.current === request) { - setHistoryHasMore(false); - } - }) - .finally(() => { - if (historyRequestRef.current === request) { - setHistoryLoading(false); - } - }); - }, [historyHasMore, historyLimit, historyLoading, historySource]); - - const activatePath = useCallback( - (path: string) => { - setMainMode('review'); - setSelectedPath(path); - scrollPathIntoReview(path, 'smooth'); - }, - [scrollPathIntoReview, setMainMode, setSelectedPath], - ); - - // Refresh the repository state in place after the working tree changed. - // Unlike a window reload, this keeps all review UI state (selection, scroll, - // search, walkthrough navigation, commit drafts, pending comments) and only - // re-renders the files whose fingerprints actually moved. - const refreshRepository = useCallback(() => { - const previousState = stateRef.current; - if (!previousState || pendingSource) { - return; - } - - const request = sourceRequestRef.current + 1; - sourceRequestRef.current = request; - const refreshSource = getRefreshSource(previousState.source); - const refreshHistorySource = historySourceRef.current - ? getRefreshSource(historySourceRef.current) - : undefined; - - Promise.all([ - window.codiff.getRepositoryState(refreshSource), - window.codiff.getRepositoryHistory(historyLimit, refreshHistorySource), - ]) - .then(([nextState, history]) => { - if (sourceRequestRef.current !== request) { - return; - } - - const orderedState = { - ...nextState, - files: sortFiles(nextState.files), - }; - const changedPaths = getChangedPaths(previousState.files, orderedState.files); - const walkthroughNeedsRefresh = haveChangedFiles(previousState.files, orderedState.files); - - stateGenerationRef.current += 1; - stateRef.current = orderedState; - setState(orderedState); - setReloadDeltaPaths(changedPaths); - if (walkthroughNeedsRefresh) { - refreshWalkthroughForState(orderedState); - } - for (const path of changedPaths) { - bumpItemVersion(path); - } - setCollapsed((current) => { - const next = new Set(current); - for (const path of changedPaths) { - next.delete(path); - } - return next; - }); - setHistoryEntries(history.entries); - setHistoryHasMore(history.entries.length >= historyLimit); - setHistorySource(getHistorySource(orderedState.source) ?? historySourceRef.current); - setSelectedPath((current) => - current != null && orderedState.files.some((file) => file.path === current) - ? current - : (orderedState.files[0]?.path ?? null), - ); - if ( - mainModeRef.current === 'commit' && - (orderedState.source.type !== 'working-tree' || orderedState.files.length === 0) - ) { - setMainMode('review'); - } - setLocalChangesDetected(false); - }) - .catch(() => { - // Keep the current state; the banner stays up as a retry affordance. - }); - }, [ - bumpItemVersion, - historyLimit, - mainModeRef, - pendingSource, - refreshWalkthroughForState, - setCollapsed, - setMainMode, - setSelectedPath, - ]); - - // ⌘R / the View menu's "Refresh Changes" item route here from the main - // process instead of reloading the window. - useEffect(() => window.codiff.onRefreshRequest(refreshRepository), [refreshRepository]); - - useEffect(() => { - const writeCurrentReloadSelection = () => { - writeReloadSelection( - stateRef.current, - selectedPathRef.current, - historySourceRef.current, - mainModeRef.current, - ); }; - - window.addEventListener('beforeunload', writeCurrentReloadSelection); - return () => window.removeEventListener('beforeunload', writeCurrentReloadSelection); - }, [mainModeRef]); - - const selectSource = useCallback( - ( - source: ReviewSource, - { throwOnError = false }: { throwOnError?: boolean } = {}, - ): Promise => { - const currentState = stateRef.current; - const sourceKey = getSourceKey(source); - const currentDisplayKey = getSourceKey(pendingSource ?? currentState?.source ?? source); - if (currentDisplayKey === sourceKey) { - return Promise.resolve(); - } - - saveCurrentSourceSession(); - const request = sourceRequestRef.current + 1; - sourceRequestRef.current = request; - setPendingSource(source); - setLoadError(null); - resetCommentFocus(); - setReloadDeltaPaths(new Set()); - resetDiffSearch(); - setScrollTarget(null); - setMainMode('review'); - - return window.codiff - .getRepositoryState(source) - .then((nextState) => { - if (sourceRequestRef.current !== request) { - return; - } - - const orderedState = { - ...nextState, - files: sortFiles(nextState.files), - }; - const session = sourceSessionsRef.current.get(getSourceKey(orderedState.source)); - const nextViewed = - session?.viewed ?? - (usesViewedFileState(orderedState.source) ? readViewed(orderedState.root) : {}); - const nextSelectedPath = - session?.selectedPath && - orderedState.files.some((file) => file.path === session.selectedPath) - ? session.selectedPath - : (orderedState.files[0]?.path ?? null); - const nextCollapsed = - session?.collapsed ?? getCollapsedViewedPaths(orderedState.files, nextViewed); - const nextExpandedGenerated = session?.expandedGenerated ?? new Set(); - const sessionWalkthroughIsCurrent = - session?.narrativeWalkthrough != null && - !haveChangedFiles(session.walkthroughFiles, orderedState.files); - const nextNarrativeWalkthrough = sessionWalkthroughIsCurrent - ? (session?.narrativeWalkthrough ?? null) - : null; - - stateGenerationRef.current += 1; - stateRef.current = orderedState; - setState(orderedState); - setHistorySource(getHistorySource(orderedState.source) ?? historySource); - setCollapsed(new Set(nextCollapsed)); - setExpandedGenerated(new Set(nextExpandedGenerated)); - setItemVersionByKey({}); - setReviewComments(session?.reviewComments ?? getReviewCommentsFromState(orderedState)); - setReloadDeltaPaths(new Set()); - setViewed(nextViewed); - setSelectedPath(nextSelectedPath); - setNarrativeWalkthrough(nextNarrativeWalkthrough); - setWalkthroughError( - sessionWalkthroughIsCurrent ? (session.walkthroughError ?? null) : null, - ); - setWalkthroughLoading(false); - setWalkthroughUnread(false); - setLocalChangesDetected(false); - setPendingSource(null); - if (!sessionWalkthroughIsCurrent) { - refreshWalkthroughForState(orderedState, session?.narrativeWalkthrough ?? null); - } - }) - .catch((error: unknown) => { - if (sourceRequestRef.current === request) { - if (!throwOnError) { - setLoadError(getRepositoryLoadError(error)); - } - setWalkthroughLoading(false); - setPendingSource(null); - } - if (throwOnError) { - throw error; - } - }); - }, - [ - historySource, - pendingSource, - refreshWalkthroughForState, - resetCommentFocus, - resetDiffSearch, - saveCurrentSourceSession, - setCollapsed, - setExpandedGenerated, - setItemVersionByKey, - setMainMode, - setNarrativeWalkthrough, - setReviewComments, - setSelectedPath, - setViewed, - setWalkthroughError, - setWalkthroughLoading, - setWalkthroughUnread, - ], - ); - - const openReviewSource = useCallback( - async (kind: OpenReviewSourceKind, value: string) => { - if (kind === 'pull-request') { - const url = await window.codiff.resolvePullRequestUrl(value); - await selectSource({ type: 'pull-request', url }, { throwOnError: true }); - return; - } - - await selectSource( - kind === 'branch' - ? { ref: value, type: 'branch-working-tree' } - : { ref: value, type: 'commit' }, - { throwOnError: true }, - ); - }, - [selectSource], - ); - - const showOpenReviewSourceDialog = useCallback((kind: OpenReviewSourceKind) => { - setOpenReviewSourceKind(kind); - }, []); - - const openRepositoryFolder = useCallback(() => { - void window.codiff.openRepositoryFolder().catch(() => {}); }, []); - useEffect( - () => window.codiff.onOpenReviewSource(showOpenReviewSourceDialog), - [showOpenReviewSourceDialog], - ); - - const commandBarCommands = useAppCommands({ - changeSidebarMode, - focusFileFilter, - getReviewCommandTarget, - onOpenDiffSearch: openDiffSearch, - onOpenReviewSource: showOpenReviewSourceDialog, - onOpenSelectedFile: openSelectedFile, - onRefreshRepository: refreshRepository, - onToggleSidebar: toggleSidebar, - onToggleViewed: toggleViewed, - onToggleWordWrap: toggleWordWrap, - preferencesRef, - reviewCommentsRef, - stateRef, - viewedRef, - }); - - const updateSelectedPathFromScroll = useCallback( - (viewer: CodeViewInstance) => { - const nextPath = getSelectedPathFromScroll(viewer, visibleFiles, showWhitespace); - if (!nextPath) { - return; - } - - const programmaticScrollPath = programmaticScrollPathRef.current; - if (programmaticScrollPath && nextPath !== programmaticScrollPath) { - return; - } - - if (programmaticScrollPath) { - programmaticScrollPathRef.current = null; - if (programmaticScrollTimerRef.current != null) { - window.clearTimeout(programmaticScrollTimerRef.current); - programmaticScrollTimerRef.current = null; - } - } - - setSelectedPath((current) => (current === nextPath ? current : nextPath)); - }, - [setSelectedPath, showWhitespace, visibleFiles], - ); - const installTerminalHelper = useCallback(() => { setTerminalHelperInstalling(true); window.codiff .installTerminalHelper() - .then((status) => setTerminalHelperStatus(status)) - .catch(() => { - setTerminalHelperStatus(defaultTerminalHelperStatus); - }) - .finally(() => { - setTerminalHelperInstalling(false); - }); + .then(setTerminalHelperStatus) + .catch(() => setTerminalHelperStatus(defaultTerminalHelperStatus)) + .finally(() => setTerminalHelperInstalling(false)); }, []); const installAgentSkill = useCallback(() => { setAgentSkillInstalling(true); window.codiff .installAgentSkill() - .then((status) => setAgentSkillStatus(status)) - .catch(() => { - setAgentSkillStatus(defaultAgentSkillStatus); - }) - .finally(() => { - setAgentSkillInstalling(false); - }); + .then(setAgentSkillStatus) + .catch(() => setAgentSkillStatus(defaultAgentSkillStatus)) + .finally(() => setAgentSkillInstalling(false)); }, []); - const activeAgentBackend = launchOptions.agentBackend ?? codiffConfig.settings.agentBackend; - const agentLabel = getAgentLabel(activeAgentBackend); - const agentSkillLabel = `${agentLabel} Skill`; + if (!launchOptionsLoaded) { + return
Loading…
; + } if (launchOptions.planFile) { if (planLoadError) { @@ -1616,7 +249,7 @@ export default function App() { ); } return planDocument ? ( - + ) : (
Loading…
); @@ -1627,6 +260,7 @@ export default function App() { loadError.kind === 'not-a-repository' && !launchOptions.repositoryPathProvided && !terminalHelperStatus.installed; + const agentLabel = getAgentLabel(launchOptions.agentBackend ?? config.settings.agentBackend); return (
@@ -1635,7 +269,7 @@ export default function App() { - {launchOptions.walkthrough ? ( - +
+ {isWalkthroughBootstrap ? ( + ) : ( - 'Thinking…' + 'Loading repository…' )}
); } - const selectedOrSearchPath = activeDiffSearchMatch?.filePath ?? selectedPath; - const visibleSelectedPath = - selectedOrSearchPath && visibleFiles.some((file) => file.path === selectedOrSearchPath) - ? selectedOrSearchPath - : (visibleFiles[0]?.path ?? null); - const isPullRequest = state.source.type === 'pull-request'; - const isSwitchingSource = pendingSource != null; - const showAgentUnavailablePanel = - sidebarMode === 'walkthrough' && - !narrativeWalkthrough && - !walkthroughLoading && - (walkthroughError?.code === 'CODEX_NOT_FOUND' || - walkthroughError?.code === 'CLAUDE_NOT_FOUND' || - walkthroughError?.code === 'OPENCODE_NOT_FOUND' || - walkthroughError?.code === 'PI_NOT_FOUND'); - - const repositoryPathParts = splitRepositoryPath(state.root); - const sidebarSourceLabel = - state.source.type !== 'working-tree' ? getSourceLabel(state.source) : null; - const pullRequestUrl = state.source.type === 'pull-request' ? state.source.url : null; - const emptySourceDetail = getEmptySourceDetail(state.source, state.root); - - const diffLineHeight = getCodeFontLineHeight( - normalizeCodeFontSizePreference(preferences.codeFontSize), - ); - const commitMetadata = - state.source.type === 'commit' && state.commitMetadata - ? (() => { - const historyAvatarUrl = historyEntries.find( - (entry) => entry.ref === state.commitMetadata?.ref, - )?.gravatarUrl; - return historyAvatarUrl - ? { - ...state.commitMetadata, - author: { - ...state.commitMetadata.author, - gravatarUrl: historyAvatarUrl, - }, - } - : state.commitMetadata; - })() - : null; - // Props shared by the full review and the per-stop scoped diffs, so the two - // render paths can't drift apart. - const commonReviewProps = { - activeSearchMatch: activeDiffSearchMatch, - agentId: activeAgentBackend, - agentLabel, - codeQualityFindings: state.codeQualityFindings, - collapsed, - comments: visibleReviewComments, - commitMetadata, - diffLineHeight, - diffStyle, - disableWorkerPool: disableCodeViewWorkerPool, - expandedGenerated, - focusCommentId, - focusCommentRequest, - gitIdentity, - hunkNavigation, - itemVersionByKey, - keymap: codiffConfig.keymap, - loadingSectionIds, - onAskCodex: askCodex, - onCommentDraftChange: updateActiveReviewCommentDraft, - onCreateComment: createComment, - onDeleteComment: deleteComment, - onFindDefinitions: window.codiff.findDefinitions, - onLoadImageContent: window.codiff.getDiffImageContent, - onLoadSection: loadDiffSection, - onLoadSectionContents: loadDiffSectionContents, - onOpenDefinition: (candidate: DefinitionCandidate) => { - void window.codiff.openFile(candidate.path, candidate.lineNumber).catch(() => {}); - }, - onOpenFile: openFile, - onRefreshMarkdown: refreshMarkdownFile, - onSaveCommentEdit: updateComment, - onSelectPathFromScroll: updateSelectedPathFromScroll, - onSubmitComment: submitPullRequestComment, - onToggleCollapsed: toggleCollapsed, - onToggleViewed: toggleViewed, - onUpdateComment: updateComment, - searchQuery: diffSearchQuery, - showWhitespace, - source: state.source, - sourceDescriptionActions: isPullRequest ? ( - - ) : undefined, - supportsReviewCommentActions: isPullRequest, - theme: preferences.theme, - viewed, - wordWrap, - }; - const renderWalkthroughDiffBlocks = ( - blocks: ReadonlyArray, - blockScrollTarget: WalkthroughBlockScrollTarget | null, - onActiveBlockChange: (blockId: string) => void, - ) => { - return ( - - ); - }; - const reviewModes = [ - { - icon: , - indicator: walkthroughUnread ? : undefined, - label: 'Walkthrough', - value: 'walkthrough', - }, - { - icon: , - label: 'Tree', - value: 'tree', - }, - { - icon: , - label: 'History', - value: 'history', - }, - ] satisfies ReadonlyArray>; - return ( -
-
- - } - context={ - <> - {state.branch ? ( - - {state.branch} - - ) : null} - {sidebarSourceLabel ? ( - pullRequestUrl ? ( - - {sidebarSourceLabel} - - - ) : ( - {sidebarSourceLabel} - ) - ) : null} - - } - mode={sidebarMode} - modes={reviewModes} - onModeChange={changeSidebarMode} - onToggleSidebar={toggleSidebar} - repository={ - - {repositoryPathParts.head} - {repositoryPathParts.tail} - - } - repositoryTooltip={state.root} - sidebarCollapsed={sidebarCollapsed} - sidebarPosition={preferences.sidebarPosition} - sourceMenu={ - - } - toggleTitle={`${sidebarCollapsed ? 'Expand' : 'Collapse'} sidebar (${getShortcutLabel( - codiffConfig.keymap, - 'toggleSidebar', - )})`} - /> - - setWalkthroughFileError(null)} - reason={walkthroughFileError?.reason ?? null} - /> - moveDiffSearchMatch(1)} - onPrevious={() => moveDiffSearchMatch(-1)} - query={diffSearchQuery} - visible={diffSearchVisible} - /> - - {openReviewSourceKind ? ( - setOpenReviewSourceKind(null)} - onOpen={(value) => openReviewSource(openReviewSourceKind, value)} - /> - ) : null} - - -
-
- {isSwitchingSource ? ( - - ) : showPlainCommitView ? ( - - ) : showNarrativeWalkthrough && narrativeWalkthrough ? ( - - ) : showAgentUnavailablePanel ? ( -
-
- setSidebarMode('tree')} - reason={walkthroughError?.reason} - title={walkthroughError?.code === 'PI_NOT_FOUND' ? 'Pi CLI not found' : undefined} - /> -
-
- ) : state.files.length === 0 ? ( -
-
- {getEmptySourceTitle(state.source)} - {emptySourceDetail.kind === 'code' ? ( - - {emptySourceDetail.text} - - ) : ( - {emptySourceDetail.text} - )} -
-
- ) : visibleFiles.length === 0 ? ( -
-
- {hasDiffSearchQuery ? 'No matches in diffs' : 'No matching files'} - - {diffSearchQuery || - fileSearchQuery || - (showWhitespace ? state.root : 'Whitespace-only changes hidden')} - -
-
- ) : ( - - )} -
-
+ ); } diff --git a/core/README.md b/core/README.md index 78f0152a..20bac2a0 100644 --- a/core/README.md +++ b/core/README.md @@ -13,3 +13,29 @@ export function Review({ snapshot }: { snapshot: SharedWalkthroughSnapshot }) { return ; } ``` + +## Review identity + +`codiff main` is a selector. After Git resolves it, the stored source keeps +the exact 40-character commit SHA that was read, not the string `main`. A +GitHub PR or GitLab MR number is also not a commit: the same PR can point at +a new head tomorrow. + +`GitSha` is only a full object id. Branch names, tags, bookmarks, and PR/MR +numbers stay ordinary strings. A `Revision` carries a SHA only when it is a +commit. The working copy and index have no SHA. + +`RepositoryHistory.entries` is a newest-first navigation feed. Review commit +stacks are parent-before-child values: parents come first, and a non-empty +stack ends at the declared head. + +## Commit diffs + +A merge commit `M` with parents `A` and `B` has two different diffs: `M` vs +`A`, and `M` vs `B`. Reads and in-flight maps key that work by +`commitSha:parentSha` (`M:A`, `M:B`). A root commit uses `M:root`. + +Asking GitHub for `requestedBase...head` may return a different +`merge_base_commit`. Dedupe the in-flight read by the pair we asked for, and +record the effective base GitHub actually used on the returned range and +stack. diff --git a/core/ReviewSurface.tsx b/core/ReviewSurface.tsx new file mode 100644 index 00000000..4ec81554 --- /dev/null +++ b/core/ReviewSurface.tsx @@ -0,0 +1,2516 @@ +import { ArrowSquareOutIcon as ArrowSquareOut } from '@phosphor-icons/react/ArrowSquareOut'; +import { ChatCircleIcon as ChatCircle } from '@phosphor-icons/react/ChatCircle'; +import { ClockCounterClockwiseIcon as ClockCounterClockwise } from '@phosphor-icons/react/ClockCounterClockwise'; +import { PathIcon as Path } from '@phosphor-icons/react/Path'; +import { TreeStructureIcon as TreeStructure } from '@phosphor-icons/react/TreeStructure'; +import type { FileDiffLoadedFiles } from '@pierre/diffs'; +import { Trash2 } from 'lucide-react'; +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type ComponentProps, + type Dispatch, + type ReactNode, + type SetStateAction, +} from 'react'; +import { Button } from './app/components/Button.tsx'; +import { CommandBar } from './app/components/CommandBar.tsx'; +import { ReviewFileTree } from './app/components/FileTree.tsx'; +import { KeyboardShortcutsHelp } from './app/components/KeyboardShortcutsHelp.tsx'; +import { + MergeRequestCommentsView, + SidebarCommentSection, + SidebarGeneralCommentList, + SidebarInlineReviewCommentList, +} from './app/components/merge-request/GeneralComments.tsx'; +import { + AgentUnavailablePanel, + isTerminalPullRequestMergeState, + CopyCommentsButton, + DiffSearchPanel, + isPullRequestReviewActionDisabled, + PullRequestMergeControls, + PullRequestMergeStatusBadge, + PullRequestReviewButtons, +} from './app/components/Panels.tsx'; +import { + PullRequestSourceDescription, + ReviewCommentThreadList, + ReviewCodeView, + type ReviewDiffBlock, +} from './app/components/ReviewCodeView.tsx'; +import type { ReviewModeItem } from './app/components/ReviewModeControl.tsx'; +import { ReviewTopBar } from './app/components/ReviewTopBar.tsx'; +import { DiffLineCountBadge, HistorySidebar } from './app/components/Sidebar.tsx'; +import { + CommitView, + type CommitHandler, + type CommitMessageHandler, + type CommitOutputSubscriber, +} from './app/components/walkthrough/CommitView.tsx'; +import { NarrativeSidebar } from './app/components/walkthrough/NarrativeSidebar.tsx'; +import { + NarrativeWalkthroughView, + type WalkthroughBlockScrollTarget, + type WalkthroughReviewTarget, +} from './app/components/walkthrough/NarrativeWalkthroughView.tsx'; +import { useNarrativeNavigation } from './app/components/walkthrough/useNarrativeNavigation.ts'; +import { WalkthroughDiffSurface } from './app/components/walkthrough/WalkthroughDiffSurface.tsx'; +import { WalkthroughProgress } from './app/components/walkthrough/WalkthroughProgress.tsx'; +import { useAppKeyboardShortcuts } from './app/hooks/useAppKeyboardShortcuts.ts'; +import { useDiffSearch } from './app/hooks/useDiffSearch.ts'; +import { + getCodeFontLineHeight, + normalizeCodeFontSizePreference, + useDocumentAppearance, +} from './app/hooks/useDocumentAppearance.ts'; +import { useResizableSidebar } from './app/hooks/useResizableSidebar.ts'; +import { useReviewCommentDrafts } from './app/hooks/useReviewCommentDrafts.ts'; +import { useReviewFileState } from './app/hooks/useReviewState.ts'; +import { createDefaultConfig } from './config/defaults.ts'; +import { getShortcutLabel } from './config/keymap.ts'; +import type { CodiffDiffStyle, CodiffKeymap } from './config/types.ts'; +import { getAgentLabel } from './lib/app-constants.ts'; +import type { + CodeViewInstance, + LocalReviewNote, + ProviderCommentDraft, + RenderedSubmittedReviewComment, + ReviewComment, + ReviewCommentCreation, + ReviewDraft, + ReviewScrollTarget, + ShareCommentDraft, + WalkthroughError, +} from './lib/app-types.ts'; +import type { Command } from './lib/command-registry.ts'; +import { getDiffLineCount, getTotalDiffLineCount, isMarkdownFilePath } from './lib/diff.ts'; +import { abbreviateHomePath, sortFiles } from './lib/files.ts'; +import { isNativeInputTarget } from './lib/keyboard.ts'; +import { isGeneratedWalkthroughFile } from './lib/narrative-walkthrough-diff.js'; +import { + resolveProviderCommentTarget, + resolveShareCommentTarget, +} from './lib/review-comment-target.ts'; +import { + buildReviewCommentsMarkdown, + getPendingPullRequestReviewComments, + getReviewCommentRendererSectionId, + getReviewCommentsFromState, + isFileReviewComment, + isLocalReviewNote, + isProviderCommentDraft, + isReviewCommentRegionSection, + isReviewDraft, + isShareCommentDraft, + isSubmittedReviewComment, + mergeReviewComments, + toProviderCommentSubmission, + toRenderedSubmittedReviewComment, + toShareCommentSubmission, +} from './lib/review-comments.ts'; +import { getSelectedPathFromScroll } from './lib/review-scroll.ts'; +import { + SIDEBAR_COLLAPSE_THRESHOLD, + SIDEBAR_DEFAULT_WIDTH, + readSidebarWidth, + writeSidebarWidth, +} from './lib/sidebar-width.ts'; +import { buildSourceDescriptionModel } from './lib/source-description.ts'; +import { + getEmptySourceDetail, + getEmptySourceTitle, + getSourceLabel, + getSourceKey, +} from './lib/source.ts'; +import type { + ChangedFile, + DefinitionCandidate, + DefinitionSearchRequest, + DefinitionSearchResult, + DiffImageContentResult, + DiffSection, + GitIdentity, + HistoryEntry, + NarrativeWalkthrough, + PullRequestMergeOptions, + PullRequestGeneralComment, + PullRequestGeneralCommentThread, + PullRequestExistingReviewComment, + PullRequestReviewEvent, + ProviderCommentSubmission, + ResolvedReviewSource, + ReviewCommenting, + ReviewSource, + RepositoryState, + ShareCommentSubmission, + SharedWalkthroughSnapshot, + SubmittedReviewComment, + SubmitPullRequestReviewResult, + WalkthroughCommitMessageResult, + WalkthroughCommitResult, + WalkthroughGenerationProgress, +} from './types.ts'; + +export { ReadOnlyGeneralCommentCard } from './app/components/merge-request/GeneralComments.tsx'; +export type { ReviewCommenting } from './types.ts'; + +const emptyReviewComments: ReadonlyArray = []; +const emptyReviewDrafts: ReadonlyArray = []; +const emptyGeneralCommentThreads: ReadonlyArray = []; +const emptyPaths = new Set(); +const emptyWalkthroughNotes = new Map(); +const reviewSurfacePreferencesKey = 'codiff:web-review-surface-preferences:v1'; +const mobileSidebarMediaQuery = '(max-width: 720px)'; +const getLocationHashTarget = () => { + const hash = window.location.hash.slice(1); + if (!hash) { + return null; + } + try { + return decodeURIComponent(hash); + } catch { + return hash; + } +}; +const commentMatchesHashTarget = ( + comment: { id: string; threadId?: string; url?: string }, + target: string, +) => + comment.id === target || + comment.threadId === target || + comment.url?.slice(comment.url.lastIndexOf('#') + 1) === target; +const agentUnavailableCodes = new Set>([ + 'CODEX_NOT_FOUND', + 'CLAUDE_NOT_FOUND', + 'OPENCODE_NOT_FOUND', + 'PI_NOT_FOUND', +]); +const readSharedSidebarWidth = () => + typeof localStorage === 'undefined' ? SIDEBAR_DEFAULT_WIDTH : readSidebarWidth(); + +const writeSharedSidebarWidth = (width: number) => { + if (typeof localStorage !== 'undefined') { + writeSidebarWidth(width); + } +}; + +const readStoredSidebarCollapsed = (): boolean | null => { + if (typeof localStorage === 'undefined') { + return null; + } + try { + const stored = JSON.parse(localStorage.getItem(reviewSurfacePreferencesKey) ?? '{}') as unknown; + if ( + stored && + typeof stored === 'object' && + 'sidebarCollapsed' in stored && + typeof stored.sidebarCollapsed === 'boolean' + ) { + return stored.sidebarCollapsed; + } + } catch { + // Ignore unavailable or invalid browser storage and use the viewport default. + } + return null; +}; + +const writeStoredSidebarCollapsed = (sidebarCollapsed: boolean) => { + if (typeof localStorage === 'undefined') { + return; + } + try { + localStorage.setItem(reviewSurfacePreferencesKey, JSON.stringify({ sidebarCollapsed })); + } catch { + // Ignore unavailable or full browser storage; the preference remains in memory. + } +}; + +const shouldCollapseSidebarInitially = () => + readStoredSidebarCollapsed() ?? + (typeof window !== 'undefined' && + typeof window.matchMedia === 'function' && + window.matchMedia(mobileSidebarMediaQuery).matches); + +export type ReviewWalkthroughStatus = 'failed' | 'generating' | 'idle' | 'ready'; +export type ReviewMode = 'comments' | 'history' | 'tree' | 'walkthrough'; +export type ReviewSurfaceCommandBridge = { + copyPendingComments: () => string; + getPersistenceState: () => { mode: ReviewMode; selectedPath: string | null }; + openDiffSearch: () => void; +}; + +export type ControlledReviewValue = Readonly<{ + onChange: (value: Value) => void; + value: Value; +}>; + +export type ControlledReviewDrafts = Readonly<{ + onChange: Dispatch>>; + value: ReadonlyArray; +}>; + +type ReviewDraftCapabilities = { + canCreateInline?: boolean; + drafts?: ControlledReviewDrafts; + onAsk?: (comment: Draft) => void; +}; + +export type LocalReviewNoteCapabilities = ReviewDraftCapabilities; + +export type CommentDestination = 'provider' | 'share'; +export type CommentAnchorPolicy = 'provider-target' | 'share-snapshot'; +export type ProviderReviewOutcome = 'approve' | 'comment' | 'request-changes'; +export type SubmitProviderReviewResult = SubmitPullRequestReviewResult; + +export type SubmitProviderReviewRequest = { + comments: ReadonlyArray; + outcome: ProviderReviewOutcome; + summary?: string; +}; + +export type ProviderReviewSessionCapabilities = { + drafts: ControlledReviewDrafts; + submit: (request: SubmitProviderReviewRequest) => Promise; +}; + +type ReviewCommentDraftForDestination = + Destination extends 'share' ? ShareCommentDraft : ProviderCommentDraft; + +type ReviewCommentSubmission = Destination extends 'share' + ? ShareCommentSubmission + : ProviderCommentSubmission; + +type CommonReviewCommentCapabilities = { + authoring: ReviewDraftCapabilities>; + destination: Destination; + general?: { + onCreate?: (body: string) => Promise; + onDelete?: (commentId: string) => Promise; + onReply?: (threadId: string, body: string) => Promise; + onResolve?: (threadId: string, resolved: boolean) => Promise; + onUpdate?: (commentId: string, body: string) => Promise; + }; + inline: { + onDelete?: (commentId: string) => Promise; + onResolve?: (threadId: string, resolved: boolean) => Promise; + onSubmit?: (comment: ReviewCommentSubmission) => Promise; + onUpdate?: (commentId: string, body: string) => Promise; + }; + onSignIn?: () => Promise | void; +}; + +export type ShareReviewCommentCapabilities = CommonReviewCommentCapabilities<'share'>; + +export type ProviderReviewCommentCapabilities = CommonReviewCommentCapabilities<'provider'> & { + reviewSession?: ProviderReviewSessionCapabilities; +}; + +export type ReviewCommentCapabilities = + | ProviderReviewCommentCapabilities + | ShareReviewCommentCapabilities; + +export type ReviewContentCapabilities = { + forceExpandedPaths?: ReadonlySet; + initialScrollTarget?: ReviewScrollTarget | null; + itemVersionByKey?: Readonly>; + loadingSectionIds?: ReadonlySet; + onLoadCommentRegion?: (comment: PullRequestExistingReviewComment) => Promise | void; + onLoadSection?: (file: ChangedFile, section: DiffSection) => Promise | void; + onRefreshMarkdown?: (file: ChangedFile, section: DiffSection) => Promise; + resolveImage?: (file: ChangedFile, section: DiffSection) => Promise; + resolveSectionContents?: ( + file: ChangedFile, + section: DiffSection, + ) => Promise; +}; + +export type ReviewDesktopCapabilities = { + beforeContent?: ReactNode; + collapsed?: ReadonlySet; + commands?: ReadonlyArray; + commit?: ComponentProps & { + onToggle: () => void; + open: boolean; + }; + disableCodeViewWorkerPool?: boolean; + isSwitchingSource?: boolean; + isWindowFullscreen?: boolean; + onActiveWalkthroughReviewTargetChange?: (target: WalkthroughReviewTarget | null) => void; + onCollapsedChange?: (collapsed: Set) => void; + onFindDefinitions?: (request: DefinitionSearchRequest) => Promise; + onOpenDefinition?: (candidate: DefinitionCandidate) => void; + onOpenFile?: (file: ChangedFile) => void; + onOpenSelectedFile?: () => void; + onViewedChange?: (viewed: Record) => void; + reloadDeltaPaths?: ReadonlySet; + sidebarFooter?: ReactNode; + sourceMenu?: ReactNode; + viewed?: Readonly>; +}; + +export type ReviewHistoryModel = { + branchSource?: Extract | null; + currentSource: ResolvedReviewSource | ReviewSource; + entries: ReadonlyArray; + hasMore: boolean; + loading: boolean; + onLoadMore: () => void; + onSelectSource: (source: ReviewSource) => void; + pullRequestSource?: Extract | null; +}; + +export type ControlledReviewPreferences = { + diffLayout?: ControlledReviewValue; + outdatedVisibility?: ControlledReviewValue; + pendingCommentPrefix?: ControlledReviewValue; + selectedPath?: ControlledReviewValue; + wordWrap?: ControlledReviewValue; +}; + +export type ReviewSourceNavigation = { + onCancelAutoMerge?: () => Promise | void; + onClosePullRequest?: () => Promise | void; + onMarkPullRequestReady?: () => Promise | void; + onMergePullRequest?: ( + options: PullRequestMergeOptions & { autoMerge: boolean }, + ) => Promise | void; + onUpdateDescription?: (body: string) => Promise | void; + onUpdateTitle?: (title: string) => Promise | void; + onUploadDescriptionAsset?: (file: File) => Promise | string; +}; + +export type ReviewWalkthroughCapabilities = { + commit?: CommitHandler; + commitOutput?: CommitOutputSubscriber; + error?: Pick | null; + generationProgress?: WalkthroughGenerationProgress | null; + onGenerate?: () => Promise | void; + onShare?: () => Promise | void; + progress?: ReactNode; + status?: ReviewWalkthroughStatus; + unread?: boolean; + updateCommitMessage?: CommitMessageHandler; +}; + +type ReviewAnnotationCapabilities = + | { + comments?: never; + localReviewNotes: LocalReviewNoteCapabilities; + } + | { + comments: ReviewCommentCapabilities; + localReviewNotes?: never; + } + | { + comments?: never; + localReviewNotes?: never; + }; + +export type ReviewSurfaceCapabilities = ReviewAnnotationCapabilities & { + content?: ReviewContentCapabilities; + desktop?: ReviewDesktopCapabilities; + history?: ReviewHistoryModel; + preferences?: ControlledReviewPreferences; + sourceNavigation?: ReviewSourceNavigation; + walkthrough?: ReviewWalkthroughCapabilities; +}; + +export const buildSharedReviewSnapshot = ({ + preferences, + state, + title, + walkthrough, +}: { + preferences: SharedWalkthroughSnapshot['preferences']; + state: RepositoryState; + title: string; + walkthrough: NarrativeWalkthrough; +}): SharedWalkthroughSnapshot => ({ + branch: state.branch, + codeQualityFindings: state.codeQualityFindings, + codiffVersion: 'desktop', + commitMetadata: state.commitMetadata, + exportedAt: new Date(state.generatedAt).toISOString(), + files: state.files, + kind: 'codiff-walkthrough-share', + preferences, + repository: { + generalComments: state.generalComments, + root: state.root, + source: state.source, + title, + }, + reviewComments: state.reviewComments, + version: 1, + walkthrough, +}); + +const getSnapshotReviewComments = ( + snapshot: SharedWalkthroughSnapshot, + destination: CommentDestination, +): ReadonlyArray => { + if (!snapshot.reviewComments?.length) { + return emptyReviewComments; + } + + return getReviewCommentsFromState( + { + branch: snapshot.branch, + files: snapshot.files, + generatedAt: Date.parse(snapshot.exportedAt) || Date.now(), + launchPath: snapshot.repository.root, + reviewComments: snapshot.reviewComments as ReadonlyArray, + root: snapshot.repository.root, + source: snapshot.repository.source, + } satisfies RepositoryState, + destination, + ); +}; + +const noop = () => {}; + +const toProviderReviewOutcome = (event: PullRequestReviewEvent): ProviderReviewOutcome => { + switch (event) { + case 'APPROVE': + return 'approve'; + case 'COMMENT': + return 'comment'; + case 'REQUEST_CHANGES': + return 'request-changes'; + } +}; + +const disabledCommit = async (): Promise => ({ + reason: 'Shared walkthroughs are read-only.', + status: 'failed', +}); + +const disabledCommitMessage = async (): Promise => ({ + reason: 'Shared walkthroughs are read-only.', + status: 'unavailable', +}); + +type ReviewSurfaceBaseProps = { + capabilities?: ReviewSurfaceCapabilities; + externalUrl?: string; + gitIdentity?: GitIdentity | null; + keymap?: CodiffKeymap; + onCommandBridgeChange?: (bridge: ReviewSurfaceCommandBridge | null) => void; + onDeleteShare?: () => Promise | void; + providerLabel?: string; + repositoryUrl?: string; + settingsBar?: ReactNode; + sidebarPosition?: 'left' | 'right'; + signInLabel?: string; + snapshot: SharedWalkthroughSnapshot; + sourceDescriptionFooterAside?: ReactNode; + title?: string; +}; + +type ControlledReviewSurfaceProps = ReviewSurfaceBaseProps & { + activeMode: ControlledReviewValue; + initialMode?: never; +}; + +type UncontrolledReviewSurfaceProps = ReviewSurfaceBaseProps & { + activeMode?: never; + initialMode?: ReviewMode; +}; + +export type ReviewSurfaceProps = ControlledReviewSurfaceProps | UncontrolledReviewSurfaceProps; + +export function ReviewSurface({ + activeMode, + capabilities, + externalUrl, + gitIdentity = null, + initialMode, + keymap: keymapProp, + onCommandBridgeChange, + onDeleteShare, + providerLabel = 'provider', + repositoryUrl, + settingsBar, + sidebarPosition = 'left', + signInLabel = 'Sign in to comment', + snapshot, + sourceDescriptionFooterAside, + title, +}: ReviewSurfaceProps) { + const content = capabilities?.content; + const desktop = capabilities?.desktop; + const history = capabilities?.history; + const localReviewNotes = capabilities?.localReviewNotes; + const comments = capabilities?.comments; + const providerComments = comments?.destination === 'provider' ? comments : undefined; + const shareComments = comments?.destination === 'share' ? comments : undefined; + const controlledPreferences = capabilities?.preferences; + const sourceNavigation = capabilities?.sourceNavigation; + const walkthrough = capabilities?.walkthrough; + const reviewedFiles = useMemo( + () => + snapshot.files.flatMap((file) => { + const sections = file.sections.filter((section) => !isReviewCommentRegionSection(section)); + return sections.length > 0 ? [{ ...file, sections }] : []; + }), + [snapshot.files], + ); + const reviewSession = providerComments?.reviewSession; + const canComment = + localReviewNotes?.canCreateInline ?? + comments?.authoring.canCreateInline ?? + comments?.inline.onSubmit != null; + const reviewDrafts = localReviewNotes ?? comments?.authoring; + const copyPendingCommentsLabel = localReviewNotes + ? 'Copy Review Notes' + : 'Copy Pending Review Comments'; + const pendingCommentPrefix = + controlledPreferences?.pendingCommentPrefix?.value ?? + (localReviewNotes + ? '# Address these Review Notes' + : comments + ? '# Address these Pending Review Comments' + : undefined); + const commenting = useMemo( + () => + comments + ? { + canComment, + onDeleteComment: comments.inline.onDelete, + onDeleteGeneralComment: comments.general?.onDelete, + onReplyGeneralComment: comments.general?.onReply, + onResolveDiscussion: comments.general?.onResolve ?? comments.inline.onResolve, + onSignIn: comments.onSignIn, + onSubmitGeneralComment: comments.general?.onCreate, + onUpdateComment: comments.inline.onUpdate, + onUpdateGeneralComment: comments.general?.onUpdate, + } + : undefined, + [canComment, comments], + ); + const deleteShare = useCallback(async () => { + if ( + !onDeleteShare || + !window.confirm('Delete this shared walkthrough? This cannot be undone.') + ) { + return; + } + try { + await onDeleteShare(); + } catch (error: unknown) { + window.alert(error instanceof Error ? error.message : String(error)); + } + }, [onDeleteShare]); + const submitReviewComment = comments?.inline.onSubmit; + const submitGeneralDiscussion = comments?.general?.onCreate; + const updateReviewComment = comments?.inline.onUpdate; + const updateGeneralDiscussion = comments?.general?.onUpdate; + const resolveDiscussion = comments?.inline.onResolve; + const sharedWalkthrough = useMemo( + () => + walkthrough?.commit + ? snapshot.walkthrough + : { + ...snapshot.walkthrough, + commit: undefined, + }, + [snapshot.walkthrough, walkthrough?.commit], + ); + const navigation = useNarrativeNavigation( + sharedWalkthrough, + reviewedFiles, + `${snapshot.repository.root}:${getSourceKey(snapshot.repository.source)}`, + ); + const defaultKeymap = useMemo(() => createDefaultConfig().keymap, []); + const keymap = keymapProp ?? defaultKeymap; + const [uncontrolledWordWrap, setUncontrolledWordWrap] = useState(snapshot.preferences.wordWrap); + const wordWrap = controlledPreferences?.wordWrap?.value ?? uncontrolledWordWrap; + const [fileSearchQuery, setFileSearchQuery] = useState(''); + const [uncontrolledSidebarMode, setUncontrolledSidebarMode] = useState( + () => initialMode ?? (desktop ? 'tree' : 'walkthrough'), + ); + const [sidebarCollapsed, setSidebarCollapsed] = useState(null); + const sidebarCollapsedRef = useRef(null); + const sidebarInteractedRef = useRef(false); + const updateSidebarCollapsed = useCallback((value: boolean, persist: boolean) => { + sidebarCollapsedRef.current = value; + setSidebarCollapsed(value); + if (persist) { + writeStoredSidebarCollapsed(value); + } + }, []); + const toggleSidebar = useCallback(() => { + sidebarInteractedRef.current = true; + updateSidebarCollapsed( + !(sidebarCollapsedRef.current ?? shouldCollapseSidebarInitially()), + true, + ); + }, [updateSidebarCollapsed]); + useEffect(() => { + const timeout = window.setTimeout(() => { + if (!sidebarInteractedRef.current) { + updateSidebarCollapsed(shouldCollapseSidebarInitially(), false); + } + }, 0); + return () => window.clearTimeout(timeout); + }, [updateSidebarCollapsed]); + const sidebarMode = activeMode?.value ?? uncontrolledSidebarMode; + const [treeScrollTarget, setTreeScrollTarget] = useState( + () => content?.initialScrollTarget ?? null, + ); + const { + bumpItemVersion, + collapsed, + expandedGenerated, + itemVersionByKey: uncontrolledItemVersionByKey, + selectedPath: uncontrolledSelectedPath, + setSelectedPath: setUncontrolledSelectedPath, + toggleCollapsed, + toggleViewed, + viewed, + } = useReviewFileState({ + collapsed: desktop?.collapsed, + initialSelectedPath: + controlledPreferences?.selectedPath?.value ?? reviewedFiles[0]?.path ?? null, + onCollapsedChange: desktop?.onCollapsedChange, + onViewedChange: desktop?.onViewedChange, + viewed: desktop?.viewed, + }); + const itemVersionByKey = content?.itemVersionByKey ?? uncontrolledItemVersionByKey; + const selectedPath = controlledPreferences?.selectedPath?.value ?? uncontrolledSelectedPath; + const { resizeSidebar, sidebarWidth } = useResizableSidebar({ + collapseThreshold: SIDEBAR_COLLAPSE_THRESHOLD, + onCollapse: () => { + sidebarInteractedRef.current = true; + updateSidebarCollapsed(true, true); + }, + onWidthCommit: writeSharedSidebarWidth, + position: sidebarPosition, + readWidth: readSharedSidebarWidth, + }); + const snapshotReviewComments = useMemo( + () => getSnapshotReviewComments(snapshot, comments?.destination ?? 'provider'), + [comments?.destination, snapshot], + ); + const reviewCommentScopeKey = `${snapshot.repository.root}:${getSourceKey(snapshot.repository.source)}`; + const showOutdated = controlledPreferences?.outdatedVisibility?.value ?? true; + const [submittedReviewCommentState, setSubmittedReviewCommentState] = useState<{ + comments: ReadonlyArray; + scopeKey: string; + }>(() => ({ comments: [], scopeKey: reviewCommentScopeKey })); + const submittedReviewComments = useMemo( + () => + submittedReviewCommentState.scopeKey === reviewCommentScopeKey + ? submittedReviewCommentState.comments + : [], + [reviewCommentScopeKey, submittedReviewCommentState], + ); + const setSubmittedReviewComments = useCallback< + Dispatch>> + >( + (update) => { + setSubmittedReviewCommentState((current) => { + const scopedCurrent = current.scopeKey === reviewCommentScopeKey ? current.comments : []; + return { + comments: typeof update === 'function' ? update(scopedCurrent) : update, + scopeKey: reviewCommentScopeKey, + }; + }); + }, + [reviewCommentScopeKey], + ); + const [editedReviewCommentBodyState, setEditedReviewCommentBodyState] = useState<{ + bodies: Readonly>; + scopeKey: string; + }>(() => ({ bodies: {}, scopeKey: reviewCommentScopeKey })); + const editedReviewCommentBodies = useMemo( + () => + editedReviewCommentBodyState.scopeKey === reviewCommentScopeKey + ? editedReviewCommentBodyState.bodies + : {}, + [editedReviewCommentBodyState, reviewCommentScopeKey], + ); + const setEditedReviewCommentBodies = useCallback< + Dispatch>>> + >( + (update) => { + setEditedReviewCommentBodyState((current) => { + const scopedCurrent = current.scopeKey === reviewCommentScopeKey ? current.bodies : {}; + return { + bodies: typeof update === 'function' ? update(scopedCurrent) : update, + scopeKey: reviewCommentScopeKey, + }; + }); + }, + [reviewCommentScopeKey], + ); + const visibleSnapshotReviewComments = useMemo(() => { + const snapshotIds = new Set(snapshotReviewComments.map((comment) => comment.id)); + return [ + ...snapshotReviewComments, + ...submittedReviewComments.filter((comment) => !snapshotIds.has(comment.id)), + ] + .filter((comment) => showOutdated || !comment.isOutdated) + .map((comment) => ({ + ...comment, + ...(editedReviewCommentBodies[comment.id] != null && + editedReviewCommentBodies[comment.id] !== comment.body + ? { body: editedReviewCommentBodies[comment.id] } + : {}), + canDelete: comment.canDelete === true && comments?.inline.onDelete != null, + canEdit: comment.canEdit === true && comments?.inline.onUpdate != null, + canReplyThread: comment.canReplyThread !== false && comments?.inline.onSubmit != null, + canResolveThread: comment.canResolveThread === true && comments?.inline.onResolve != null, + })); + }, [ + comments, + editedReviewCommentBodies, + showOutdated, + snapshotReviewComments, + submittedReviewComments, + ]); + const [uncontrolledLocalReviewComments, setUncontrolledLocalReviewComments] = + useState>(emptyReviewDrafts); + const uncontrolledLocalReviewCommentsRef = useRef(uncontrolledLocalReviewComments); + const providerControlledDrafts = reviewSession?.drafts ?? providerComments?.authoring.drafts; + const localReviewComments: ReadonlyArray = + localReviewNotes?.drafts?.value ?? + shareComments?.authoring.drafts?.value ?? + providerControlledDrafts?.value ?? + uncontrolledLocalReviewComments; + const setLocalReviewComments = useCallback< + Dispatch>> + >( + (update) => { + if (localReviewNotes?.drafts) { + localReviewNotes.drafts.onChange((current) => { + const next = typeof update === 'function' ? update(current) : update; + return next.filter(isLocalReviewNote); + }); + return; + } + if (shareComments?.authoring.drafts) { + shareComments.authoring.drafts.onChange((current) => { + const next = typeof update === 'function' ? update(current) : update; + return next.filter(isShareCommentDraft); + }); + return; + } + if (providerControlledDrafts) { + providerControlledDrafts.onChange((current) => { + const next = typeof update === 'function' ? update(current) : update; + return next.filter(isProviderCommentDraft); + }); + return; + } + const nextComments = + typeof update === 'function' ? update(uncontrolledLocalReviewCommentsRef.current) : update; + const nextDrafts = nextComments.filter(isReviewDraft); + uncontrolledLocalReviewCommentsRef.current = nextDrafts; + setUncontrolledLocalReviewComments(nextDrafts); + }, + [localReviewNotes, providerControlledDrafts, shareComments], + ); + const reviewComments = useMemo( + () => mergeReviewComments(visibleSnapshotReviewComments, localReviewComments), + [localReviewComments, visibleSnapshotReviewComments], + ); + const renderableReviewComments = useMemo( + () => + reviewComments.filter( + (comment) => isReviewDraft(comment) || comment.resolvedSectionId != null, + ), + [reviewComments], + ); + const { + activeReviewCommentDraftRef, + activeReviewCommentDraftState, + clearCommentFocus, + createComment: createDraftComment, + deleteComment: deleteLocalComment, + focusComment, + focusCommentId, + focusCommentRequest, + reviewCommentsRef, + updateActiveReviewCommentDraft, + updateComment, + } = useReviewCommentDrafts({ + canCreateComment: canComment, + comments: reviewComments, + draftKind: localReviewNotes ? 'local-note' : shareComments ? 'share-draft' : 'provider-draft', + onCommentFileChange: bumpItemVersion, + setComments: setLocalReviewComments, + }); + const createComment = useCallback( + (comment: ReviewCommentCreation) => { + if (localReviewNotes) { + createDraftComment(comment); + return; + } + if (!comments) { + return; + } + const file = reviewedFiles.find((candidate) => candidate.path === comment.filePath); + const section = file?.sections.find((candidate) => candidate.id === comment.sectionId); + if (!file || !section) { + return; + } + const targetInput = { + anchor: comment.anchor, + file, + lineNumber: comment.lineNumber, + section, + showWhitespace: snapshot.preferences.showWhitespace, + side: comment.side, + startLineNumber: comment.startLineNumber, + startSide: comment.startSide, + }; + const target = + comments.destination === 'share' + ? resolveShareCommentTarget({ ...targetInput, displayedFiles: reviewedFiles }) + : resolveProviderCommentTarget({ ...targetInput, canonicalFiles: reviewedFiles }); + if (target.status !== 'enabled') { + return; + } + createDraftComment({ + ...comment, + ...(target.position ? { position: target.position } : {}), + }); + }, + [ + comments, + createDraftComment, + localReviewNotes, + reviewedFiles, + snapshot.preferences.showWhitespace, + ], + ); + const createMissingReviewReply = useCallback( + (threadId: string, comment: ReviewComment) => { + if (!canComment) { + return; + } + createDraftComment({ + ...(isFileReviewComment(comment) ? { anchor: 'file' as const } : {}), + filePath: comment.filePath, + ...(comment.lineNumber != null ? { lineNumber: comment.lineNumber } : {}), + ...(comment.position ? { position: comment.position } : {}), + sectionId: + getReviewCommentRendererSectionId(comment) ?? `missing-review-thread:${threadId}`, + ...(comment.side ? { side: comment.side } : {}), + ...(comment.startLineNumber != null ? { startLineNumber: comment.startLineNumber } : {}), + ...(comment.startSide ? { startSide: comment.startSide } : {}), + threadId, + }); + }, + [canComment, createDraftComment], + ); + const generalCommentThreads = snapshot.repository.generalComments ?? emptyGeneralCommentThreads; + const generalComments = useMemo( + () => + (snapshot.repository.generalComments ?? emptyGeneralCommentThreads).flatMap( + (thread) => thread.comments, + ), + [snapshot.repository.generalComments], + ); + const generalCommentCount = generalComments.length; + const inlineReviewCommentCount = visibleSnapshotReviewComments.length; + const reviewCommentCount = generalCommentCount + inlineReviewCommentCount; + const showCommentsTab = comments != null || reviewCommentCount > 0; + const [generalCommentDraft, setGeneralCommentDraft] = useState(''); + const [generalCommentEditDraft, setGeneralCommentEditDraft] = useState(''); + const [editingGeneralCommentId, setEditingGeneralCommentId] = useState(null); + const [generalCommentEditError, setGeneralCommentEditError] = useState(null); + const [generalCommentEditSubmitting, setGeneralCommentEditSubmitting] = useState(false); + const [generalCommentError, setGeneralCommentError] = useState(null); + const [focusedGeneralCommentId, setFocusedGeneralCommentId] = useState(null); + const [focusedInlineSidebarCommentId, setFocusedInlineSidebarCommentId] = useState( + null, + ); + const [focusedReviewCommentPath, setFocusedReviewCommentPath] = useState(null); + const [pendingReviewCommentNavigationId, setPendingReviewCommentNavigationId] = useState< + string | null + >(null); + const handledHashTargetRef = useRef(null); + const [generalCommentScrollRequest, setGeneralCommentScrollRequest] = useState(0); + const [generalCommentSubmitting, setGeneralCommentSubmitting] = useState(false); + const [pullRequestReviewSubmitting, setPullRequestReviewSubmitting] = + useState(null); + const [pullRequestCloseSubmitting, setPullRequestCloseSubmitting] = useState(false); + const [pullRequestReadySubmitting, setPullRequestReadySubmitting] = useState(false); + const [pullRequestMergeSubmitting, setPullRequestMergeSubmitting] = useState(false); + const [walkthroughRequestPending, setWalkthroughRequestPending] = useState(false); + const walkthroughRequestPendingRef = useRef(false); + const [walkthroughRequestId, setWalkthroughRequestId] = useState(0); + const walkthroughRef = useRef(walkthrough); + + const orderedFiles = useMemo(() => sortFiles(reviewedFiles), [reviewedFiles]); + const { + activeMatch: activeDiffSearchMatch, + activeMatchIndex: activeDiffSearchMatchIndex, + closeSearch: closeDiffSearch, + filters: diffSearchFilters, + focusRequest: diffSearchFocusRequest, + matches: diffSearchMatches, + matchPathSet: diffSearchMatchPathSet, + moveMatch: moveDiffSearchMatch, + openSearch: openDiffSearch, + query: diffSearchQuery, + updateFilters: updateDiffSearchFilters, + updateQuery: updateDiffSearchQuery, + visible: diffSearchVisible, + visibleFiles, + } = useDiffSearch({ + files: orderedFiles, + fileSearchQuery, + showWhitespace: snapshot.preferences.showWhitespace, + }); + const forceExpandedPaths = useMemo( + () => + new Set([ + ...diffSearchMatchPathSet, + ...(content?.forceExpandedPaths ?? emptyPaths), + ...(focusedReviewCommentPath ? [focusedReviewCommentPath] : []), + ]), + [content?.forceExpandedPaths, diffSearchMatchPathSet, focusedReviewCommentPath], + ); + const totalLineCount = useMemo( + () => + getTotalDiffLineCount( + visibleFiles.map((file) => getDiffLineCount(file, snapshot.preferences.showWhitespace)), + ), + [snapshot.preferences.showWhitespace, visibleFiles], + ); + const showTotalLineCount = + sidebarMode !== 'comments' && sidebarMode !== 'history' && totalLineCount.countable; + const visibleSelectedPath = + selectedPath && visibleFiles.some((file) => file.path === selectedPath) + ? selectedPath + : (visibleFiles[0]?.path ?? null); + const initialMarkdownPreviewSectionIds = useMemo(() => { + const nonGeneratedFiles = reviewedFiles.filter((file) => !isGeneratedWalkthroughFile(file)); + if ( + nonGeneratedFiles.length === 0 || + !nonGeneratedFiles.every((file) => isMarkdownFilePath(file.path)) + ) { + return emptyPaths; + } + + return new Set( + reviewedFiles + .filter((file) => isMarkdownFilePath(file.path)) + .flatMap((file) => file.sections.map((section) => section.id)), + ); + }, [reviewedFiles]); + + useDocumentAppearance({ + codeFontFamily: snapshot.preferences.codeFontFamily, + codeFontSize: snapshot.preferences.codeFontSize, + theme: snapshot.preferences.theme, + }); + + const changeSidebarMode = useCallback( + (mode: ReviewMode) => { + if (activeMode) { + activeMode.onChange(mode); + } else { + setUncontrolledSidebarMode(mode); + } + }, + [activeMode], + ); + const askReviewAssistant = useCallback( + (comment: ReviewComment) => { + if (isLocalReviewNote(comment)) { + localReviewNotes?.onAsk?.(comment); + } else if (isProviderCommentDraft(comment)) { + providerComments?.authoring.onAsk?.(comment); + } else if (isShareCommentDraft(comment)) { + shareComments?.authoring.onAsk?.(comment); + } + }, + [localReviewNotes, providerComments, shareComments], + ); + + const activateGeneralComment = useCallback( + (commentId: string) => { + changeSidebarMode('comments'); + setFocusedReviewCommentPath(null); + setFocusedGeneralCommentId(commentId); + setGeneralCommentScrollRequest((current) => current + 1); + }, + [changeSidebarMode], + ); + const showResolvedReviewComment = useCallback( + (comment: PullRequestExistingReviewComment) => { + setFocusedInlineSidebarCommentId(null); + setFocusedReviewCommentPath(comment.filePath); + setUncontrolledSelectedPath(comment.filePath); + controlledPreferences?.selectedPath?.onChange(comment.filePath); + setTreeScrollTarget((current) => ({ + behavior: 'smooth', + path: comment.filePath, + request: (current?.request ?? 0) + 1, + })); + changeSidebarMode('tree'); + focusComment(comment.id); + }, + [ + changeSidebarMode, + controlledPreferences?.selectedPath, + focusComment, + setUncontrolledSelectedPath, + ], + ); + const activateReviewComment = useCallback( + (commentId: string) => { + const comment = visibleSnapshotReviewComments.find((candidate) => candidate.id === commentId); + if (!comment?.resolvedSectionId) { + changeSidebarMode('comments'); + setFocusedReviewCommentPath(null); + setFocusedInlineSidebarCommentId(commentId); + if (comment && content?.onLoadCommentRegion) { + setPendingReviewCommentNavigationId(commentId); + void Promise.resolve(content.onLoadCommentRegion(comment)).catch(() => { + setPendingReviewCommentNavigationId((current) => + current === commentId ? null : current, + ); + }); + } + return; + } + showResolvedReviewComment(comment); + }, + [changeSidebarMode, content, showResolvedReviewComment, visibleSnapshotReviewComments], + ); + useEffect(() => { + if (!pendingReviewCommentNavigationId) { + return; + } + const comment = visibleSnapshotReviewComments.find( + (candidate) => + candidate.id === pendingReviewCommentNavigationId && candidate.resolvedSectionId, + ); + if (!comment) { + return; + } + const timeout = window.setTimeout(() => { + setPendingReviewCommentNavigationId(null); + showResolvedReviewComment(comment); + }, 0); + return () => window.clearTimeout(timeout); + }, [pendingReviewCommentNavigationId, showResolvedReviewComment, visibleSnapshotReviewComments]); + const activateHashTarget = useCallback(() => { + const target = getLocationHashTarget(); + if (!target || handledHashTargetRef.current === target) { + return; + } + + const reviewComment = visibleSnapshotReviewComments.find((comment) => + commentMatchesHashTarget(comment, target), + ); + if (reviewComment) { + handledHashTargetRef.current = target; + activateReviewComment(reviewComment.id); + return; + } + + for (const thread of generalCommentThreads) { + const generalComment = + thread.comments.find((comment) => commentMatchesHashTarget(comment, target)) ?? + (thread.id === target ? thread.comments[0] : undefined); + if (generalComment) { + handledHashTargetRef.current = target; + activateGeneralComment(generalComment.id); + return; + } + } + }, [ + activateGeneralComment, + activateReviewComment, + generalCommentThreads, + visibleSnapshotReviewComments, + ]); + useEffect(() => { + const timeout = window.setTimeout(activateHashTarget, 0); + return () => window.clearTimeout(timeout); + }, [activateHashTarget]); + useEffect(() => { + const handleHashChange = () => { + handledHashTargetRef.current = null; + activateHashTarget(); + }; + window.addEventListener('hashchange', handleHashChange); + return () => window.removeEventListener('hashchange', handleHashChange); + }, [activateHashTarget]); + const navigateGeneralComment = useCallback( + (direction: 1 | -1) => { + if (generalComments.length === 0) { + return; + } + + const currentIndex = focusedGeneralCommentId + ? generalComments.findIndex((comment) => comment.id === focusedGeneralCommentId) + : -1; + const nextIndex = + currentIndex === -1 + ? direction > 0 + ? 0 + : generalComments.length - 1 + : Math.min(generalComments.length - 1, Math.max(0, currentIndex + direction)); + const nextComment = generalComments[nextIndex]; + + if (nextComment) { + activateGeneralComment(nextComment.id); + } + }, + [activateGeneralComment, focusedGeneralCommentId, generalComments], + ); + const updateExistingReviewComment = useCallback( + async (commentId: string, body: string) => { + if (!updateReviewComment) { + return; + } + const comment = reviewCommentsRef.current.find((candidate) => candidate.id === commentId); + await updateReviewComment(commentId, body); + setEditedReviewCommentBodies((current) => ({ ...current, [commentId]: body })); + if (comment) { + bumpItemVersion(comment.filePath); + } + }, + [bumpItemVersion, reviewCommentsRef, setEditedReviewCommentBodies, updateReviewComment], + ); + const deleteComment = useCallback( + (commentId: string) => { + const comment = reviewCommentsRef.current.find((candidate) => candidate.id === commentId); + if ( + comment && + isSubmittedReviewComment(comment) && + comment.canDelete && + comments?.inline.onDelete + ) { + updateActiveReviewCommentDraft(null); + setSubmittedReviewComments((current) => + current.filter((candidate) => candidate.id !== commentId), + ); + void comments.inline.onDelete(commentId).catch((error: unknown) => { + window.alert(error instanceof Error ? error.message : String(error)); + }); + return; + } + deleteLocalComment(commentId); + }, + [ + comments, + deleteLocalComment, + reviewCommentsRef, + setSubmittedReviewComments, + updateActiveReviewCommentDraft, + ], + ); + const submitComment = useCallback( + (commentId: string) => { + const comment = reviewCommentsRef.current.find((candidate) => candidate.id === commentId); + const isPersistedDraft = + comment != null && (isProviderCommentDraft(comment) || isShareCommentDraft(comment)); + if ( + !comments?.inline.onSubmit || + !comment || + !isPersistedDraft || + !comment.body.trim() || + comment.remoteSubmit?.status === 'submitting' || + comment.remoteSubmit?.status === 'outcome-unknown' + ) { + return; + } + + updateActiveReviewCommentDraft(null); + setLocalReviewComments((current) => + current.map((candidate) => + candidate.id === commentId && + (isProviderCommentDraft(candidate) || isShareCommentDraft(candidate)) + ? { ...candidate, remoteSubmit: { status: 'submitting' as const } } + : candidate, + ), + ); + + let submission: Promise; + try { + if (comments.destination === 'share' && isShareCommentDraft(comment)) { + submission = comments.inline.onSubmit(toShareCommentSubmission(comment)); + } else if (comments.destination === 'provider' && isProviderCommentDraft(comment)) { + submission = comments.inline.onSubmit(toProviderCommentSubmission(comment)); + } else { + return; + } + } catch (error: unknown) { + setLocalReviewComments((current) => + current.map((candidate) => + candidate.id === commentId && + (isProviderCommentDraft(candidate) || isShareCommentDraft(candidate)) + ? { + ...candidate, + remoteSubmit: { + error: error instanceof Error ? error.message : String(error), + status: 'error' as const, + }, + } + : candidate, + ), + ); + return; + } + + void submission + .then((submittedComment) => { + clearCommentFocus(commentId); + const submitted = toRenderedSubmittedReviewComment(submittedComment, comment); + setLocalReviewComments((current) => + current.filter((candidate) => candidate.id !== commentId), + ); + setSubmittedReviewComments((current) => [ + ...current.filter((candidate) => candidate.id !== submitted.id), + submitted, + ]); + bumpItemVersion(comment.filePath); + }) + .catch((error: unknown) => { + setLocalReviewComments((current) => + current.map((candidate) => + candidate.id === commentId && + (isProviderCommentDraft(candidate) || isShareCommentDraft(candidate)) + ? { + ...candidate, + remoteSubmit: { + error: error instanceof Error ? error.message : String(error), + status: 'error' as const, + }, + } + : candidate, + ), + ); + bumpItemVersion(comment.filePath); + }); + }, + [ + bumpItemVersion, + clearCommentFocus, + comments, + reviewCommentsRef, + setLocalReviewComments, + setSubmittedReviewComments, + updateActiveReviewCommentDraft, + ], + ); + const submitReview = useCallback( + (event: PullRequestReviewEvent, body?: string): Promise | void => { + const source = snapshot.repository.source; + if ( + !reviewSession || + pullRequestReviewSubmitting || + (source.type === 'pull-request' && + isPullRequestReviewActionDisabled(source.reviewStatus, event)) + ) { + return; + } + + const pendingComments = getPendingPullRequestReviewComments( + reviewCommentsRef.current.filter(isProviderCommentDraft), + activeReviewCommentDraftRef.current, + ); + if (event === 'COMMENT' && pendingComments.length === 0 && !body?.trim()) { + return; + } + let formattedComments; + try { + formattedComments = pendingComments.map((comment) => toProviderCommentSubmission(comment)); + } catch (error) { + return Promise.reject(error); + } + setPullRequestReviewSubmitting(event); + return Promise.resolve() + .then(() => + reviewSession.submit({ + comments: formattedComments, + outcome: toProviderReviewOutcome(event), + ...(body?.trim() ? { summary: body } : {}), + }), + ) + .then((result) => { + const submittedDraftIds = new Set(result.submittedDraftIds); + const outcomeUnknownDraftIds = new Set( + result.status === 'failed' ? (result.outcomeUnknownDraftIds ?? []) : [], + ); + if ( + activeReviewCommentDraftRef.current && + submittedDraftIds.has(activeReviewCommentDraftRef.current.id) + ) { + updateActiveReviewCommentDraft(null); + } + setLocalReviewComments((current) => + current + .filter((comment) => !submittedDraftIds.has(comment.id)) + .map((comment) => + outcomeUnknownDraftIds.has(comment.id) && isProviderCommentDraft(comment) + ? { + ...comment, + remoteSubmit: { + error: 'Provider outcome is unknown. Refresh and inspect before retrying.', + status: 'outcome-unknown' as const, + }, + } + : comment, + ), + ); + if (result.status === 'failed') { + throw new Error(result.reason); + } + }) + .catch((error: unknown) => { + window.alert(error instanceof Error ? error.message : String(error)); + throw error; + }) + .finally(() => setPullRequestReviewSubmitting(null)); + }, + [ + reviewSession, + pullRequestReviewSubmitting, + snapshot.repository.source, + activeReviewCommentDraftRef, + reviewCommentsRef, + setLocalReviewComments, + updateActiveReviewCommentDraft, + ], + ); + const closePullRequest = useCallback(() => { + const source = snapshot.repository.source; + if ( + !sourceNavigation?.onClosePullRequest || + pullRequestCloseSubmitting || + source.type !== 'pull-request' || + source.reviewStatus?.close?.disabled === true || + !source.reviewStatus?.close + ) { + return; + } + + setPullRequestCloseSubmitting(true); + void Promise.resolve(sourceNavigation.onClosePullRequest()) + .catch((error: unknown) => { + window.alert(error instanceof Error ? error.message : String(error)); + }) + .finally(() => setPullRequestCloseSubmitting(false)); + }, [pullRequestCloseSubmitting, snapshot.repository.source, sourceNavigation]); + const markPullRequestReady = useCallback(() => { + const source = snapshot.repository.source; + if ( + !sourceNavigation?.onMarkPullRequestReady || + pullRequestReadySubmitting || + source.type !== 'pull-request' || + source.reviewStatus?.markReady?.disabled === true || + !source.reviewStatus?.markReady + ) { + return; + } + + setPullRequestReadySubmitting(true); + return Promise.resolve(sourceNavigation.onMarkPullRequestReady()) + .catch((error: unknown) => { + window.alert(error instanceof Error ? error.message : String(error)); + }) + .finally(() => setPullRequestReadySubmitting(false)); + }, [pullRequestReadySubmitting, snapshot.repository.source, sourceNavigation]); + const mergePullRequest = useCallback( + (options: PullRequestMergeOptions & { autoMerge: boolean }) => { + if (!sourceNavigation?.onMergePullRequest || pullRequestMergeSubmitting) { + return; + } + + setPullRequestMergeSubmitting(true); + void Promise.resolve(sourceNavigation.onMergePullRequest(options)) + .catch((error: unknown) => { + window.alert(error instanceof Error ? error.message : String(error)); + }) + .finally(() => setPullRequestMergeSubmitting(false)); + }, + [pullRequestMergeSubmitting, sourceNavigation], + ); + const cancelAutoMerge = useCallback(() => { + if (!sourceNavigation?.onCancelAutoMerge || pullRequestMergeSubmitting) { + return; + } + + setPullRequestMergeSubmitting(true); + void Promise.resolve(sourceNavigation.onCancelAutoMerge()) + .catch((error: unknown) => { + window.alert(error instanceof Error ? error.message : String(error)); + }) + .finally(() => setPullRequestMergeSubmitting(false)); + }, [pullRequestMergeSubmitting, sourceNavigation]); + useEffect(() => { + walkthroughRef.current = walkthrough; + }, [walkthrough]); + + useEffect(() => { + if (!walkthroughRequestPending || walkthroughRequestId === 0) { + return; + } + + let cancelled = false; + void Promise.resolve(walkthroughRef.current?.onGenerate?.()) + .catch(() => {}) + .finally(() => { + if (cancelled) { + return; + } + walkthroughRequestPendingRef.current = false; + setWalkthroughRequestPending(false); + }); + return () => { + cancelled = true; + }; + }, [walkthroughRequestId, walkthroughRequestPending]); + + const startWalkthroughGeneration = useCallback(() => { + if ( + !walkthrough?.onGenerate || + walkthrough.status === 'generating' || + walkthroughRequestPendingRef.current + ) { + return; + } + + walkthroughRequestPendingRef.current = true; + setWalkthroughRequestPending(true); + setWalkthroughRequestId((current) => current + 1); + }, [walkthrough]); + useEffect(() => { + if (sidebarMode === 'walkthrough' && walkthrough?.status === 'idle') { + startWalkthroughGeneration(); + } + }, [sidebarMode, startWalkthroughGeneration, walkthrough?.status]); + useEffect(() => { + if (sidebarMode !== 'comments' || generalComments.length === 0) { + return; + } + + const handleKeyDown = (event: KeyboardEvent) => { + if ( + event.defaultPrevented || + event.altKey || + event.ctrlKey || + event.metaKey || + event.shiftKey || + isNativeInputTarget(event.target) + ) { + return; + } + + const key = event.key.toLowerCase(); + if (key !== 'j' && key !== 'k') { + return; + } + + event.preventDefault(); + navigateGeneralComment(key === 'j' ? 1 : -1); + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [generalComments.length, navigateGeneralComment, sidebarMode]); + const submitGeneralComment = useCallback(() => { + const body = generalCommentDraft.trim(); + if (!submitGeneralDiscussion || !body || generalCommentSubmitting) { + return; + } + + setGeneralCommentError(null); + setGeneralCommentSubmitting(true); + void Promise.resolve(submitGeneralDiscussion(body)) + .then(() => setGeneralCommentDraft('')) + .catch((error: unknown) => { + setGeneralCommentError(error instanceof Error ? error.message : String(error)); + }) + .finally(() => setGeneralCommentSubmitting(false)); + }, [generalCommentDraft, generalCommentSubmitting, submitGeneralDiscussion]); + const startEditGeneralComment = useCallback((comment: PullRequestGeneralComment) => { + if (!comment.canEdit) { + return; + } + + setEditingGeneralCommentId(comment.id); + setGeneralCommentEditDraft(comment.body); + setGeneralCommentEditError(null); + }, []); + const cancelEditGeneralComment = useCallback(() => { + if (generalCommentEditSubmitting) { + return; + } + + setEditingGeneralCommentId(null); + setGeneralCommentEditDraft(''); + setGeneralCommentEditError(null); + }, [generalCommentEditSubmitting]); + const saveGeneralCommentEdit = useCallback(() => { + const commentId = editingGeneralCommentId; + const body = generalCommentEditDraft.trim(); + if (!updateGeneralDiscussion || !commentId || !body || generalCommentEditSubmitting) { + return; + } + + setGeneralCommentEditError(null); + setGeneralCommentEditSubmitting(true); + void Promise.resolve(updateGeneralDiscussion(commentId, body)) + .then(() => { + setEditingGeneralCommentId(null); + setGeneralCommentEditDraft(''); + }) + .catch((error: unknown) => { + setGeneralCommentEditError(error instanceof Error ? error.message : String(error)); + }) + .finally(() => setGeneralCommentEditSubmitting(false)); + }, [ + editingGeneralCommentId, + generalCommentEditDraft, + generalCommentEditSubmitting, + updateGeneralDiscussion, + ]); + const selectPath = useCallback( + (path: string | null) => { + setUncontrolledSelectedPath(path); + controlledPreferences?.selectedPath?.onChange(path); + }, + [controlledPreferences?.selectedPath, setUncontrolledSelectedPath], + ); + const activateTreePath = useCallback( + (path: string) => { + setFocusedReviewCommentPath(null); + selectPath(path); + setTreeScrollTarget((current) => ({ + behavior: 'smooth', + path, + request: (current?.request ?? 0) + 1, + })); + }, + [selectPath], + ); + const updateSelectedPathFromScroll = useCallback( + (viewer: CodeViewInstance) => { + const nextPath = getSelectedPathFromScroll( + viewer, + visibleFiles, + snapshot.preferences.showWhitespace, + ); + + if (nextPath && selectedPath !== nextPath) { + selectPath(nextPath); + } + }, + [selectPath, selectedPath, snapshot.preferences.showWhitespace, visibleFiles], + ); + + const [hunkNavigation, setHunkNavigation] = useState<{ + direction: 1 | -1; + request: number; + } | null>(null); + const navigateHunks = useCallback((direction: 1 | -1) => { + setHunkNavigation((current) => ({ + direction, + request: (current?.request ?? 0) + 1, + })); + }, []); + const focusFileFilter = useCallback(() => { + sidebarInteractedRef.current = true; + updateSidebarCollapsed(false, true); + changeSidebarMode('tree'); + requestAnimationFrame(() => { + const input = document.querySelector('.review-surface .sidebar-search'); + input?.focus(); + input?.select(); + }); + }, [changeSidebarMode, updateSidebarCollapsed]); + const toggleWordWrap = useCallback(() => { + const nextWordWrap = !wordWrap; + setUncontrolledWordWrap(nextWordWrap); + controlledPreferences?.wordWrap?.onChange(nextWordWrap); + }, [controlledPreferences?.wordWrap, wordWrap]); + const { closeCommandBar, commandBarVisible, shortcutsHelpVisible } = useAppKeyboardShortcuts({ + keymap, + navigateHunks, + onFocusFileFilter: focusFileFilter, + onOpenDiffSearch: openDiffSearch, + onOpenSelectedFile: desktop?.onOpenSelectedFile, + onToggleSidebar: toggleSidebar, + onToggleWordWrap: toggleWordWrap, + shouldDeferHunkNavigation: () => sidebarMode === 'walkthrough', + sidebarCollapsed: sidebarCollapsed ?? false, + }); + const commandBarCommands = useMemo( + () => + [ + { + execute: focusFileFilter, + id: 'file-filter', + keymapAction: 'fileFilter', + title: 'Focus File Filter', + }, + { + execute: openDiffSearch, + id: 'diff-search', + keymapAction: 'diffSearch', + title: 'Find in Diffs', + }, + { execute: () => changeSidebarMode('tree'), id: 'sidebar-tree', title: 'Show File Tree' }, + { + execute: () => changeSidebarMode('walkthrough'), + id: 'sidebar-walkthrough', + title: 'Show Walkthrough', + }, + ...(showCommentsTab + ? [ + { + execute: () => changeSidebarMode('comments'), + id: 'sidebar-comments', + title: 'Show Comments', + }, + ] + : []), + ...(history + ? [ + { + execute: () => changeSidebarMode('history'), + id: 'sidebar-history', + title: 'Show History', + }, + ] + : []), + { + execute: toggleSidebar, + id: 'toggle-sidebar', + keymapAction: 'toggleSidebar', + title: 'Toggle Sidebar', + }, + { + execute: toggleWordWrap, + id: 'toggle-word-wrap', + keymapAction: 'toggleWordWrap', + title: 'Toggle Word Wrap', + }, + ...(desktop?.commands ?? []), + ] satisfies ReadonlyArray, + [ + changeSidebarMode, + desktop?.commands, + focusFileFilter, + history, + openDiffSearch, + showCommentsTab, + toggleSidebar, + toggleWordWrap, + ], + ); + const commandBridge = useMemo( + () => ({ + copyPendingComments: () => + buildReviewCommentsMarkdown( + reviewedFiles, + localReviewComments, + snapshot.preferences.showWhitespace, + pendingCommentPrefix, + ), + getPersistenceState: () => ({ mode: sidebarMode, selectedPath: visibleSelectedPath }), + openDiffSearch, + }), + [ + localReviewComments, + openDiffSearch, + pendingCommentPrefix, + sidebarMode, + reviewedFiles, + snapshot.preferences.showWhitespace, + visibleSelectedPath, + ], + ); + useEffect(() => { + onCommandBridgeChange?.(commandBridge); + return () => onCommandBridgeChange?.(null); + }, [commandBridge, onCommandBridgeChange]); + + const diffLineHeight = getCodeFontLineHeight( + normalizeCodeFontSizePreference(snapshot.preferences.codeFontSize), + ); + const source = snapshot.repository.source; + const sourceDescriptionModel = useMemo( + () => + buildSourceDescriptionModel({ + commitMetadata: snapshot.commitMetadata ?? null, + source, + }), + [snapshot.commitMetadata, source], + ); + const [sourceDescriptionCollapsedByIdentity, setSourceDescriptionCollapsedByIdentity] = useState< + Readonly> + >({}); + const sourceDescriptionCollapsed = sourceDescriptionModel + ? (sourceDescriptionCollapsedByIdentity[sourceDescriptionModel.identity] ?? + sourceDescriptionModel.defaultCollapsed) + : false; + const changeSourceDescriptionCollapsed = useCallback( + (collapsed: boolean) => { + if (!sourceDescriptionModel) { + return; + } + setSourceDescriptionCollapsedByIdentity((current) => ({ + ...current, + [sourceDescriptionModel.identity]: collapsed, + })); + }, + [sourceDescriptionModel], + ); + const commonReviewProps = { + activeSearchMatch: activeDiffSearchMatch, + agentId: snapshot.walkthrough.agent, + agentLabel: getAgentLabel(snapshot.walkthrough.agent), + codeQualityFindings: snapshot.codeQualityFindings, + collapsed, + comments: renderableReviewComments, + commitMetadata: snapshot.commitMetadata ?? null, + diffLineHeight, + diffStyle: controlledPreferences?.diffLayout?.value ?? snapshot.preferences.diffStyle, + disableWorkerPool: desktop?.disableCodeViewWorkerPool ?? true, + expandedGenerated, + focusCommentId, + focusCommentRequest, + gitIdentity, + hunkNavigation, + initialMarkdownPreviewSectionIds, + isReadOnly: !canComment, + itemVersionByKey, + keymap, + loadingSectionIds: content?.loadingSectionIds ?? new Set(), + onAskCodex: + localReviewNotes?.onAsk || providerComments?.authoring.onAsk || shareComments?.authoring.onAsk + ? askReviewAssistant + : undefined, + onCommentDraftChange: updateActiveReviewCommentDraft, + onCreateComment: createComment, + onDeleteComment: deleteComment, + onFindDefinitions: desktop?.onFindDefinitions, + onLoadSection: content?.onLoadSection, + onOpenDefinition: desktop?.onOpenDefinition, + onOpenFile: desktop?.onOpenFile, + onRefreshMarkdown: content?.onRefreshMarkdown, + onResolveThread: resolveDiscussion ?? noop, + onSaveCommentEdit: updateExistingReviewComment, + onSelectPathFromScroll: noop, + onSourceDescriptionCollapsedChange: changeSourceDescriptionCollapsed, + onSubmitComment: submitComment, + onToggleCollapsed: toggleCollapsed, + onToggleViewed: toggleViewed, + onUpdateComment: updateComment, + onUpdateSourceDescription: sourceNavigation?.onUpdateDescription, + onUpdateSourceTitle: sourceNavigation?.onUpdateTitle, + onUploadSourceDescriptionAsset: sourceNavigation?.onUploadDescriptionAsset, + resolveImage: content?.resolveImage, + resolveSectionContents: content?.resolveSectionContents, + searchQuery: diffSearchQuery, + showWhitespace: snapshot.preferences.showWhitespace, + source: snapshot.repository.source, + sourceDescriptionCollapsed, + supportsReviewCommentActions: submitReviewComment != null, + theme: snapshot.preferences.theme, + viewed, + wordWrap, + }; + const showDesktopCommitButton = + sidebarMode === 'tree' && + source.type === 'working-tree' && + reviewedFiles.length > 0 && + desktop?.commit != null; + const emptySourceDetail = getEmptySourceDetail(source, snapshot.repository.root); + const hasDiffSearchQuery = diffSearchQuery.trim().length > 0; + const sourceMergeState = source.type === 'pull-request' ? source.mergeState : undefined; + const isTerminalMergeState = sourceMergeState + ? isTerminalPullRequestMergeState(sourceMergeState) + : false; + const sourceMergeStatusBadge = + sourceMergeState && isTerminalMergeState ? ( + + ) : null; + const sourceDescriptionActions = + (reviewSession || + sourceNavigation?.onClosePullRequest || + sourceNavigation?.onMarkPullRequestReady) && + source.type === 'pull-request' ? ( + 0 + } + onClosePullRequest={sourceNavigation?.onClosePullRequest ? closePullRequest : undefined} + onMarkPullRequestReady={ + sourceNavigation?.onMarkPullRequestReady ? markPullRequestReady : undefined + } + onSubmitReview={reviewSession ? submitReview : undefined} + reviewStatus={source.reviewStatus} + > + {sourceMergeStatusBadge} + + ) : sourceMergeStatusBadge ? ( +
+ {sourceMergeStatusBadge} +
+ ) : undefined; + const sourceDescriptionFooterMain = + (sourceNavigation?.onMergePullRequest || sourceNavigation?.onCancelAutoMerge) && + sourceMergeState && + !isTerminalMergeState ? ( + + ) : undefined; + const sourceDescription = + source.type === 'pull-request' ? ( + + ) : null; + + const { commentReviewBlocks, missingRegionComments } = useMemo(() => { + const groups: Array<{ comments: Array; key: string }> = []; + const byThread = new Map; key: string }>(); + for (const comment of reviewComments) { + const key = comment.threadId ? `thread:${comment.threadId}` : `comment:${comment.id}`; + let group = byThread.get(key); + if (!group) { + group = { comments: [], key }; + byThread.set(key, group); + groups.push(group); + } + group.comments.push(comment); + } + + const blocks: Array = []; + const missing: Array = []; + for (const group of groups) { + const root = group.comments[0]!; + const sectionId = getReviewCommentRendererSectionId(root); + const file = snapshot.files.find((candidate) => candidate.path === root.filePath); + const section = file?.sections.find((candidate) => candidate.id === sectionId); + if (!file || !section) { + missing.push(...group.comments); + continue; + } + blocks.push({ + comments: group.comments, + file: { ...file, sections: [section] }, + id: `review-comments:${group.key}`, + itemIdPrefix: `review-comments:${group.key}`, + }); + } + return { commentReviewBlocks: blocks, missingRegionComments: missing }; + }, [reviewComments, snapshot.files]); + + const commentsOverview = ( +
+ +
+ ); + const commentsBlocks: ReadonlyArray = [ + { header: commentsOverview, id: 'review-comments:overview' }, + ...(commentReviewBlocks.length > 0 + ? [ + { + header: ( +
+ Code comments +
+ ), + id: 'review-comments:code-heading', + } satisfies ReviewDiffBlock, + ...commentReviewBlocks, + ] + : []), + ...(missingRegionComments.length > 0 + ? [ + { + header: ( +
+
+ Comments without a code region +
+ +
+ ), + id: 'review-comments:missing', + } satisfies ReviewDiffBlock, + ] + : []), + ]; + + const renderWalkthroughDiffBlocks = ( + blocks: ReadonlyArray, + blockScrollTarget: WalkthroughBlockScrollTarget | null, + onActiveBlockChange: (blockId: string) => void, + ) => { + return ( + + ); + }; + + const sourceLabel = + snapshot.repository.source.type === 'working-tree' + ? null + : getSourceLabel(snapshot.repository.source); + const rootLabel = repositoryUrl + ? snapshot.repository.root + : abbreviateHomePath(snapshot.repository.root); + const sourceExternalUrl = + snapshot.repository.source.type === 'pull-request' + ? (externalUrl ?? snapshot.repository.source.url) + : null; + const repositoryLinkUrl = repositoryUrl ?? sourceExternalUrl; + const walkthroughStatus = + walkthroughRequestPending && walkthrough?.status !== 'ready' + ? 'generating' + : walkthrough?.status; + const [walkthroughProgressRevision, setWalkthroughProgressRevision] = useState(0); + const previousWalkthroughStatusRef = useRef(walkthroughStatus); + useEffect(() => { + if ( + walkthroughStatus === 'generating' && + previousWalkthroughStatusRef.current !== 'generating' + ) { + setWalkthroughProgressRevision((current) => current + 1); + } + previousWalkthroughStatusRef.current = walkthroughStatus; + }, [walkthroughStatus]); + const walkthroughReady = !walkthrough || walkthroughStatus === 'ready'; + const walkthroughFailed = walkthroughStatus === 'failed'; + const walkthroughGenerationProgress = walkthrough?.generationProgress ?? null; + const failedGenerationUnits = + walkthroughGenerationProgress?.units?.filter((unit) => unit.status === 'failed') ?? []; + const agentUnavailable = + walkthroughFailed && + walkthrough?.error?.code != null && + agentUnavailableCodes.has(walkthrough.error.code); + const walkthroughStatusTitle = walkthroughFailed + ? 'Walkthrough unavailable' + : 'Generating walkthrough…'; + const walkthroughStatusDescription = walkthroughFailed + ? agentUnavailable + ? (walkthrough?.error?.reason ?? 'Install the configured agent and try again.') + : (walkthroughGenerationProgress?.summary ?? + walkthrough?.error?.reason ?? + 'Fix the generation issue, then try again.') + : (walkthroughGenerationProgress?.summary ?? null); + const shellTheme = + snapshot.preferences.theme === 'system' ? undefined : snapshot.preferences.theme; + const requestWalkthrough = () => { + startWalkthroughGeneration(); + }; + const reviewModes = [ + { + icon: , + indicator: walkthrough?.unread ? : undefined, + label: 'Walkthrough', + value: 'walkthrough', + }, + { + icon: , + label: 'Tree', + value: 'tree', + }, + ...(history + ? [ + { + icon: , + label: 'History', + value: 'history' as const, + }, + ] + : []), + ...(showCommentsTab + ? [ + { + ariaLabel: reviewCommentCount > 0 ? `Comments (${reviewCommentCount})` : 'Comments', + icon: , + indicator: + reviewCommentCount > 0 ? ( + + {reviewCommentCount} + + ) : undefined, + label: 'Comments', + title: + reviewCommentCount > 0 + ? `${reviewCommentCount} ${reviewCommentCount === 1 ? 'comment' : 'comments'}` + : 'Comments', + value: 'comments' as const, + }, + ] + : []), + ] satisfies ReadonlyArray>; + const topBarActions = + onDeleteShare || settingsBar ? ( + <> + {onDeleteShare ? ( + + ) : null} + {settingsBar ?
{settingsBar}
: null} + + ) : undefined; + + return ( + <> + +
+ + {snapshot.branch ? ( + + {snapshot.branch} + + ) : null} + {sourceLabel ? ( + sourceExternalUrl ? ( + + {sourceLabel} + + + ) : ( + {sourceLabel} + ) + ) : null} + + } + mode={sidebarMode} + modes={reviewModes} + onModeChange={changeSidebarMode} + onToggleSidebar={toggleSidebar} + repository={ + repositoryLinkUrl ? ( + + {rootLabel} + + ) : ( + {rootLabel} + ) + } + repositoryTooltip={snapshot.repository.root} + sidebarCollapsed={sidebarCollapsed ?? false} + sidebarPosition={sidebarPosition} + sourceMenu={desktop?.sourceMenu} + toggleTitle={`${sidebarCollapsed ? 'Expand' : 'Collapse'} sidebar (${getShortcutLabel( + keymap, + 'toggleSidebar', + )})`} + /> + {desktop?.beforeContent} + {reviewDrafts && !desktop?.isSwitchingSource ? ( +
+ +
+ ) : null} + moveDiffSearchMatch(1)} + onPrevious={() => moveDiffSearchMatch(-1)} + query={diffSearchQuery} + visible={diffSearchVisible} + /> + +
+
+ {desktop?.commit?.open ? ( + + ) : sidebarMode === 'comments' ? ( + (block.file ? [block.file.path] : [])), + ) + } + hunkNavigation={null} + isReadOnly + onSelectPathFromScroll={noop} + scrollTarget={null} + searchQuery="" + selectedPath={null} + showSourceDescription={false} + walkthroughNotes={emptyWalkthroughNotes} + /> + ) : sidebarMode === 'tree' || sidebarMode === 'history' ? ( + reviewedFiles.length === 0 ? ( +
+
+ {getEmptySourceTitle(source)} + {emptySourceDetail.kind === 'code' ? ( + + {emptySourceDetail.text} + + ) : ( + {emptySourceDetail.text} + )} +
+
+ ) : visibleFiles.length === 0 ? ( +
+
+ + {hasDiffSearchQuery ? 'No matches in diffs' : 'No matching files'} + + + {diffSearchQuery || + fileSearchQuery || + (snapshot.preferences.showWhitespace + ? snapshot.repository.root + : 'Whitespace-only changes hidden')} + +
+
+ ) : ( + + ) + ) : walkthroughReady ? ( + + ) : walkthroughFailed ? ( +
+
+ {agentUnavailable ? ( + changeSidebarMode('tree')} + reason={walkthroughStatusDescription ?? undefined} + /> + ) : ( + <> + {walkthroughStatusTitle} +

{walkthroughStatusDescription}

+ {failedGenerationUnits.length > 0 ? ( + + ) : null} +
+ +
+ + )} +
+
+ ) : ( +
+ {walkthrough?.progress ?? ( + + )} +
+ )} +
+
+ + + ); +} diff --git a/core/SharedWalkthroughApp.tsx b/core/SharedWalkthroughApp.tsx deleted file mode 100644 index 2b7f763c..00000000 --- a/core/SharedWalkthroughApp.tsx +++ /dev/null @@ -1,1378 +0,0 @@ -import { ArrowSquareOutIcon as ArrowSquareOut } from '@phosphor-icons/react/ArrowSquareOut'; -import { ChatCircleIcon as ChatCircle } from '@phosphor-icons/react/ChatCircle'; -import { PathIcon as Path } from '@phosphor-icons/react/Path'; -import { TreeStructureIcon as TreeStructure } from '@phosphor-icons/react/TreeStructure'; -import { Trash2 } from 'lucide-react'; -import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; -import { Button } from './app/components/Button.tsx'; -import { ReviewFileTree } from './app/components/FileTree.tsx'; -import { - MergeRequestCommentsView, - SidebarGeneralCommentList, - type ReviewCommenting, -} from './app/components/merge-request/GeneralComments.tsx'; -import { - isTerminalPullRequestMergeState, - isPullRequestReviewActionDisabled, - PullRequestMergeControls, - PullRequestMergeStatusBadge, - PullRequestReviewButtons, -} from './app/components/Panels.tsx'; -import { - PullRequestSourceDescription, - ReviewCodeView, - type ReviewDiffBlock, -} from './app/components/ReviewCodeView.tsx'; -import type { ReviewModeItem } from './app/components/ReviewModeControl.tsx'; -import { ReviewTopBar } from './app/components/ReviewTopBar.tsx'; -import { DiffLineCountBadge } from './app/components/Sidebar.tsx'; -import { NarrativeSidebar } from './app/components/walkthrough/NarrativeSidebar.tsx'; -import { - NarrativeWalkthroughView, - type WalkthroughBlockScrollTarget, -} from './app/components/walkthrough/NarrativeWalkthroughView.tsx'; -import { useNarrativeNavigation } from './app/components/walkthrough/useNarrativeNavigation.ts'; -import { WalkthroughDiffSurface } from './app/components/walkthrough/WalkthroughDiffSurface.tsx'; -import { WalkthroughProgress } from './app/components/walkthrough/WalkthroughProgress.tsx'; -import { - getCodeFontLineHeight, - normalizeCodeFontSizePreference, - useDocumentAppearance, -} from './app/hooks/useDocumentAppearance.ts'; -import { useResizableSidebar } from './app/hooks/useResizableSidebar.ts'; -import { useReviewCommentDrafts } from './app/hooks/useReviewCommentDrafts.ts'; -import { useReviewFileState } from './app/hooks/useReviewState.ts'; -import { createDefaultConfig } from './config/defaults.ts'; -import { matchesShortcut } from './config/keymap.ts'; -import { getAgentLabel } from './lib/app-constants.ts'; -import type { CodeViewInstance, ReviewComment, ReviewScrollTarget } from './lib/app-types.ts'; -import { - fileHasVisibleDiff, - getDiffLineCount, - getTotalDiffLineCount, - isMarkdownFilePath, -} from './lib/diff.ts'; -import { abbreviateHomePath, fuzzyMatches, sortFiles } from './lib/files.ts'; -import { isNativeInputTarget } from './lib/keyboard.ts'; -import { isGeneratedWalkthroughFile } from './lib/narrative-walkthrough-diff.js'; -import { - getPendingPullRequestReviewComments, - getReviewCommentsFromState, - mergeReviewComments, - toSubmittedReviewComment, - toPullRequestReviewComment, -} from './lib/review-comments.ts'; -import { getSelectedPathFromScroll } from './lib/review-scroll.ts'; -import { - SIDEBAR_COLLAPSE_THRESHOLD, - SIDEBAR_DEFAULT_WIDTH, - readSidebarWidth, - writeSidebarWidth, -} from './lib/sidebar-width.ts'; -import { getSourceLabel, getSourceKey } from './lib/source.ts'; -import type { - GitIdentity, - PullRequestMergeOptions, - PullRequestGeneralComment, - PullRequestGeneralCommentThread, - PullRequestExistingReviewComment, - PullRequestReviewComment, - PullRequestReviewEvent, - RepositoryState, - SharedWalkthroughSnapshot, - WalkthroughCommitMessageResult, - WalkthroughCommitResult, -} from './types.ts'; - -export { - ReadOnlyGeneralCommentCard, - type ReviewCommenting, -} from './app/components/merge-request/GeneralComments.tsx'; - -const emptyReviewComments: ReadonlyArray = []; -const emptyGeneralCommentThreads: ReadonlyArray = []; -const emptyPaths = new Set(); -const emptyWalkthroughNotes = new Map(); -const reviewSurfacePreferencesKey = 'codiff:web-review-surface-preferences:v1'; -const getLocationHashTarget = () => { - const hash = window.location.hash.slice(1); - if (!hash) { - return null; - } - try { - return decodeURIComponent(hash); - } catch { - return hash; - } -}; -const commentMatchesHashTarget = ( - comment: { id: string; threadId?: string; url?: string }, - target: string, -) => - comment.id === target || - comment.threadId === target || - comment.url?.slice(comment.url.lastIndexOf('#') + 1) === target; -const readSharedSidebarWidth = () => - typeof localStorage === 'undefined' ? SIDEBAR_DEFAULT_WIDTH : readSidebarWidth(); - -const writeSharedSidebarWidth = (width: number) => { - if (typeof localStorage !== 'undefined') { - writeSidebarWidth(width); - } -}; - -const readStoredSidebarCollapsed = (): boolean | null => { - if (typeof localStorage === 'undefined') { - return null; - } - try { - const stored = JSON.parse(localStorage.getItem(reviewSurfacePreferencesKey) ?? '{}') as unknown; - if ( - stored && - typeof stored === 'object' && - 'sidebarCollapsed' in stored && - typeof stored.sidebarCollapsed === 'boolean' - ) { - return stored.sidebarCollapsed; - } - } catch { - // Ignore unavailable or invalid browser storage and use the viewport default. - } - return null; -}; - -const writeStoredSidebarCollapsed = (sidebarCollapsed: boolean) => { - if (typeof localStorage === 'undefined') { - return; - } - try { - localStorage.setItem(reviewSurfacePreferencesKey, JSON.stringify({ sidebarCollapsed })); - } catch { - // Ignore unavailable or full browser storage; the preference remains in memory. - } -}; - -export type ReviewWalkthroughStatus = 'failed' | 'generating' | 'idle' | 'ready'; -export type ReviewMode = 'comments' | 'tree' | 'walkthrough'; - -const getSnapshotReviewComments = ( - snapshot: SharedWalkthroughSnapshot, -): ReadonlyArray => { - if (!snapshot.reviewComments?.length) { - return emptyReviewComments; - } - - return getReviewCommentsFromState({ - branch: snapshot.branch, - files: snapshot.files, - generatedAt: Date.parse(snapshot.exportedAt) || Date.now(), - launchPath: snapshot.repository.root, - reviewComments: snapshot.reviewComments as ReadonlyArray, - root: snapshot.repository.root, - source: snapshot.repository.source, - } satisfies RepositoryState); -}; - -const noop = () => {}; - -const disabledCommit = async (): Promise => ({ - reason: 'Shared walkthroughs are read-only.', - status: 'failed', -}); - -const disabledCommitMessage = async (): Promise => ({ - reason: 'Shared walkthroughs are read-only.', - status: 'unavailable', -}); - -export type ReviewSurfaceProps = { - commenting?: ReviewCommenting; - externalUrl?: string; - gitIdentity?: GitIdentity | null; - initialMode?: ReviewMode; - interactive?: { - onCancelAutoMerge?: () => Promise | void; - onClosePullRequest?: () => Promise | void; - onGenerateWalkthrough: () => Promise | void; - onHome: () => void; - onMarkPullRequestReady?: () => Promise | void; - onMergePullRequest?: ( - options: PullRequestMergeOptions & { autoMerge: boolean }, - ) => Promise | void; - onResolveDiscussion?: (discussionId: string, resolved: boolean) => Promise; - onSubmitComment: ( - comment: PullRequestReviewComment, - ) => Promise; - onSubmitGeneralComment: (body: string) => Promise; - onSubmitReview: ( - event: PullRequestReviewEvent, - comments: ReadonlyArray, - body?: string, - ) => Promise; - onUpdateComment: (commentId: string, body: string) => Promise; - onUpdateDescription?: (body: string) => Promise | void; - onUpdateGeneralComment: (commentId: string, body: string) => Promise; - onUpdateTitle?: (title: string) => Promise | void; - onUploadDescriptionAsset?: (file: File) => Promise | string; - walkthroughError?: string | null; - walkthroughStatus: ReviewWalkthroughStatus; - }; - onDeleteShare?: () => Promise | void; - onModeChange?: (mode: ReviewMode) => void; - providerLabel?: string; - repositoryUrl?: string; - settingsBar?: ReactNode; - sidebarPosition?: 'left' | 'right'; - signInLabel?: string; - snapshot: SharedWalkthroughSnapshot; - sourceDescriptionFooterAside?: ReactNode; - title?: string; -}; - -const mobileSidebarMediaQuery = '(max-width: 720px)'; - -const shouldCollapseSidebarInitially = () => - readStoredSidebarCollapsed() ?? - (typeof window !== 'undefined' && window.matchMedia(mobileSidebarMediaQuery).matches); - -export function ReviewSurface({ - commenting, - externalUrl, - gitIdentity = null, - initialMode, - interactive, - onDeleteShare, - onModeChange, - providerLabel = 'provider', - repositoryUrl, - settingsBar, - sidebarPosition = 'left', - signInLabel = 'Sign in to comment', - snapshot, - sourceDescriptionFooterAside, - title, -}: ReviewSurfaceProps) { - const canComment = commenting?.canComment ?? Boolean(interactive); - const deleteShare = useCallback(async () => { - if ( - !onDeleteShare || - !window.confirm('Delete this shared walkthrough? This cannot be undone.') - ) { - return; - } - try { - await onDeleteShare(); - } catch (error: unknown) { - window.alert(error instanceof Error ? error.message : String(error)); - } - }, [onDeleteShare]); - const submitReviewComment = commenting?.onSubmitComment ?? interactive?.onSubmitComment; - const submitGeneralDiscussion = - commenting?.onSubmitGeneralComment ?? interactive?.onSubmitGeneralComment; - const updateReviewComment = commenting?.onUpdateComment ?? interactive?.onUpdateComment; - const updateGeneralDiscussion = - commenting?.onUpdateGeneralComment ?? interactive?.onUpdateGeneralComment; - const resolveDiscussion = commenting?.onResolveDiscussion ?? interactive?.onResolveDiscussion; - const sharedWalkthrough = useMemo( - () => ({ - ...snapshot.walkthrough, - commit: undefined, - }), - [snapshot.walkthrough], - ); - const navigation = useNarrativeNavigation( - sharedWalkthrough, - snapshot.files, - `${snapshot.repository.root}:${getSourceKey(snapshot.repository.source)}`, - ); - const keymap = useMemo(() => createDefaultConfig().keymap, []); - const [fileSearchQuery, setFileSearchQuery] = useState(''); - const [uncontrolledSidebarMode, setUncontrolledSidebarMode] = useState( - () => initialMode ?? (interactive ? 'tree' : 'walkthrough'), - ); - const [sidebarCollapsed, setSidebarCollapsed] = useState(null); - const sidebarCollapsedRef = useRef(null); - const sidebarInteractedRef = useRef(false); - const updateSidebarCollapsed = useCallback((value: boolean, persist: boolean) => { - sidebarCollapsedRef.current = value; - setSidebarCollapsed(value); - if (persist) { - writeStoredSidebarCollapsed(value); - } - }, []); - const toggleSidebar = useCallback(() => { - sidebarInteractedRef.current = true; - updateSidebarCollapsed( - !(sidebarCollapsedRef.current ?? shouldCollapseSidebarInitially()), - true, - ); - }, [updateSidebarCollapsed]); - useEffect(() => { - const timeout = window.setTimeout(() => { - if (!sidebarInteractedRef.current) { - updateSidebarCollapsed(shouldCollapseSidebarInitially(), false); - } - }, 0); - return () => window.clearTimeout(timeout); - }, [updateSidebarCollapsed]); - useEffect(() => { - const handleKeyDown = (event: KeyboardEvent) => { - if (event.repeat || !matchesShortcut(event, keymap, 'toggleSidebar')) { - return; - } - event.preventDefault(); - toggleSidebar(); - }; - - window.addEventListener('keydown', handleKeyDown); - return () => window.removeEventListener('keydown', handleKeyDown); - }, [keymap, toggleSidebar]); - const isSidebarModeControlled = Boolean(initialMode && onModeChange); - const sidebarMode = - isSidebarModeControlled && initialMode ? initialMode : uncontrolledSidebarMode; - const [treeScrollTarget, setTreeScrollTarget] = useState(null); - const { - bumpItemVersion, - collapsed, - expandedGenerated, - itemVersionByKey, - selectedPath, - setSelectedPath, - toggleCollapsed, - toggleViewed, - viewed, - } = useReviewFileState({ - initialSelectedPath: snapshot.files[0]?.path ?? null, - }); - const { resizeSidebar, sidebarWidth } = useResizableSidebar({ - collapseThreshold: SIDEBAR_COLLAPSE_THRESHOLD, - onCollapse: () => { - sidebarInteractedRef.current = true; - updateSidebarCollapsed(true, true); - }, - onWidthCommit: writeSharedSidebarWidth, - position: sidebarPosition, - readWidth: readSharedSidebarWidth, - }); - const snapshotReviewComments = useMemo(() => getSnapshotReviewComments(snapshot), [snapshot]); - const [editedReviewCommentBodies, setEditedReviewCommentBodies] = useState< - Readonly> - >({}); - const visibleSnapshotReviewComments = useMemo( - () => - snapshotReviewComments.map((comment) => - editedReviewCommentBodies[comment.id] != null && - editedReviewCommentBodies[comment.id] !== comment.body - ? { ...comment, body: editedReviewCommentBodies[comment.id] } - : comment, - ), - [editedReviewCommentBodies, snapshotReviewComments], - ); - const [localReviewComments, setLocalReviewComments] = - useState>(emptyReviewComments); - const reviewComments = useMemo( - () => mergeReviewComments(visibleSnapshotReviewComments, localReviewComments), - [localReviewComments, visibleSnapshotReviewComments], - ); - const { - activeReviewCommentDraftRef, - activeReviewCommentDraftState, - clearCommentFocus, - createComment, - deleteComment: deleteLocalComment, - focusComment, - focusCommentId, - focusCommentRequest, - reviewCommentsRef, - updateActiveReviewCommentDraft, - updateComment, - } = useReviewCommentDrafts({ - canCreateComment: canComment, - comments: reviewComments, - onCommentFileChange: bumpItemVersion, - setComments: setLocalReviewComments, - }); - const generalCommentThreads = snapshot.repository.generalComments ?? emptyGeneralCommentThreads; - const generalComments = useMemo( - () => - (snapshot.repository.generalComments ?? emptyGeneralCommentThreads).flatMap( - (thread) => thread.comments, - ), - [snapshot.repository.generalComments], - ); - const generalCommentCount = generalComments.length; - const showCommentsTab = Boolean(commenting || interactive || generalCommentCount > 0); - const [generalCommentDraft, setGeneralCommentDraft] = useState(''); - const [generalCommentEditDraft, setGeneralCommentEditDraft] = useState(''); - const [editingGeneralCommentId, setEditingGeneralCommentId] = useState(null); - const [generalCommentEditError, setGeneralCommentEditError] = useState(null); - const [generalCommentEditSubmitting, setGeneralCommentEditSubmitting] = useState(false); - const [generalCommentError, setGeneralCommentError] = useState(null); - const [focusedGeneralCommentId, setFocusedGeneralCommentId] = useState(null); - const [generalCommentScrollRequest, setGeneralCommentScrollRequest] = useState(0); - const [generalCommentSubmitting, setGeneralCommentSubmitting] = useState(false); - const [pullRequestReviewSubmitting, setPullRequestReviewSubmitting] = - useState(null); - const [pullRequestCloseSubmitting, setPullRequestCloseSubmitting] = useState(false); - const [pullRequestReadySubmitting, setPullRequestReadySubmitting] = useState(false); - const [pullRequestMergeSubmitting, setPullRequestMergeSubmitting] = useState(false); - const [walkthroughRequestPending, setWalkthroughRequestPending] = useState(false); - const walkthroughRequestPendingRef = useRef(false); - const [walkthroughRequestId, setWalkthroughRequestId] = useState(0); - const interactiveRef = useRef(interactive); - const handledHashTargetRef = useRef(null); - - const visibleFiles = useMemo( - () => - sortFiles(snapshot.files).filter( - (file) => - fuzzyMatches(file.path, fileSearchQuery) && - fileHasVisibleDiff(file, snapshot.preferences.showWhitespace), - ), - [fileSearchQuery, snapshot.files, snapshot.preferences.showWhitespace], - ); - const totalLineCount = useMemo( - () => - getTotalDiffLineCount( - visibleFiles.map((file) => getDiffLineCount(file, snapshot.preferences.showWhitespace)), - ), - [snapshot.preferences.showWhitespace, visibleFiles], - ); - const showTotalLineCount = sidebarMode !== 'comments' && totalLineCount.countable; - const visibleSelectedPath = - selectedPath && visibleFiles.some((file) => file.path === selectedPath) - ? selectedPath - : (visibleFiles[0]?.path ?? null); - const initialMarkdownPreviewSectionIds = useMemo(() => { - const nonGeneratedFiles = snapshot.files.filter((file) => !isGeneratedWalkthroughFile(file)); - if ( - nonGeneratedFiles.length === 0 || - !nonGeneratedFiles.every((file) => isMarkdownFilePath(file.path)) - ) { - return emptyPaths; - } - - return new Set( - snapshot.files - .filter((file) => isMarkdownFilePath(file.path)) - .flatMap((file) => file.sections.map((section) => section.id)), - ); - }, [snapshot.files]); - - useDocumentAppearance({ - codeFontFamily: snapshot.preferences.codeFontFamily, - codeFontSize: snapshot.preferences.codeFontSize, - theme: snapshot.preferences.theme, - }); - - const changeSidebarMode = useCallback( - (mode: ReviewMode) => { - setUncontrolledSidebarMode(mode); - onModeChange?.(mode); - }, - [onModeChange], - ); - - const activateGeneralComment = useCallback( - (commentId: string) => { - changeSidebarMode('comments'); - setFocusedGeneralCommentId(commentId); - setGeneralCommentScrollRequest((current) => current + 1); - }, - [changeSidebarMode], - ); - const navigateGeneralComment = useCallback( - (direction: 1 | -1) => { - if (generalComments.length === 0) { - return; - } - - const currentIndex = focusedGeneralCommentId - ? generalComments.findIndex((comment) => comment.id === focusedGeneralCommentId) - : -1; - const nextIndex = - currentIndex === -1 - ? direction > 0 - ? 0 - : generalComments.length - 1 - : Math.min(generalComments.length - 1, Math.max(0, currentIndex + direction)); - const nextComment = generalComments[nextIndex]; - - if (nextComment) { - activateGeneralComment(nextComment.id); - } - }, - [activateGeneralComment, focusedGeneralCommentId, generalComments], - ); - const updateExistingReviewComment = useCallback( - async (commentId: string, body: string) => { - if (!updateReviewComment) { - return; - } - const comment = reviewCommentsRef.current.find((candidate) => candidate.id === commentId); - await updateReviewComment(commentId, body); - setEditedReviewCommentBodies((current) => ({ ...current, [commentId]: body })); - if (comment) { - bumpItemVersion(comment.filePath); - } - }, - [bumpItemVersion, reviewCommentsRef, updateReviewComment], - ); - const deleteComment = useCallback( - (commentId: string) => { - const comment = reviewCommentsRef.current.find((candidate) => candidate.id === commentId); - if (comment?.isReadOnly && comment.canDelete && commenting?.onDeleteComment) { - updateActiveReviewCommentDraft(null); - setLocalReviewComments((current) => - current.filter((candidate) => candidate.id !== commentId), - ); - void commenting.onDeleteComment(commentId).catch((error: unknown) => { - window.alert(error instanceof Error ? error.message : String(error)); - }); - return; - } - deleteLocalComment(commentId); - }, - [commenting, deleteLocalComment, reviewCommentsRef, updateActiveReviewCommentDraft], - ); - const submitComment = useCallback( - (commentId: string) => { - const comment = reviewCommentsRef.current.find((candidate) => candidate.id === commentId); - if ( - !submitReviewComment || - !comment || - comment.isReadOnly || - !comment.body.trim() || - comment.remoteSubmit?.status === 'submitting' - ) { - return; - } - - updateActiveReviewCommentDraft(null); - setLocalReviewComments((current) => - current.map((candidate) => - candidate.id === commentId - ? { ...candidate, remoteSubmit: { status: 'submitting' } } - : candidate, - ), - ); - void submitReviewComment( - toPullRequestReviewComment(comment, { includeSectionId: commenting != null }), - ) - .then((submittedComment) => { - clearCommentFocus(commentId); - setLocalReviewComments((current) => - current.flatMap((candidate) => { - if (candidate.id !== commentId) { - return [candidate]; - } - return [toSubmittedReviewComment(submittedComment, candidate)]; - }), - ); - bumpItemVersion(comment.filePath); - }) - .catch((error: unknown) => { - setLocalReviewComments((current) => - current.map((candidate) => - candidate.id === commentId - ? { - ...candidate, - remoteSubmit: { - error: error instanceof Error ? error.message : String(error), - status: 'error', - }, - } - : candidate, - ), - ); - bumpItemVersion(comment.filePath); - }); - }, - [ - bumpItemVersion, - clearCommentFocus, - commenting, - reviewCommentsRef, - submitReviewComment, - updateActiveReviewCommentDraft, - ], - ); - const submitReview = useCallback( - (event: PullRequestReviewEvent, body?: string) => { - const source = snapshot.repository.source; - if ( - !interactive || - pullRequestReviewSubmitting || - (source.type === 'pull-request' && - isPullRequestReviewActionDisabled(source.reviewStatus, event)) - ) { - return; - } - - const pendingComments = getPendingPullRequestReviewComments( - reviewCommentsRef.current, - activeReviewCommentDraftRef.current, - ); - if (event === 'COMMENT' && pendingComments.length === 0 && !body?.trim()) { - return; - } - const pendingIds = new Set(pendingComments.map((comment) => comment.id)); - setPullRequestReviewSubmitting(event); - const formattedComments = pendingComments.map((comment) => - toPullRequestReviewComment(comment), - ); - const submission = body - ? interactive.onSubmitReview(event, formattedComments, body) - : interactive.onSubmitReview(event, formattedComments); - return submission - .then(() => { - updateActiveReviewCommentDraft(null); - setLocalReviewComments((current) => - current.filter((comment) => !pendingIds.has(comment.id)), - ); - }) - .catch((error: unknown) => { - window.alert(error instanceof Error ? error.message : String(error)); - throw error; - }) - .finally(() => setPullRequestReviewSubmitting(null)); - }, - [ - interactive, - pullRequestReviewSubmitting, - snapshot.repository.source, - activeReviewCommentDraftRef, - reviewCommentsRef, - updateActiveReviewCommentDraft, - ], - ); - const closePullRequest = useCallback(() => { - const source = snapshot.repository.source; - if ( - !interactive?.onClosePullRequest || - pullRequestCloseSubmitting || - source.type !== 'pull-request' || - source.reviewStatus?.close?.disabled === true || - !source.reviewStatus?.close - ) { - return; - } - - setPullRequestCloseSubmitting(true); - void Promise.resolve(interactive.onClosePullRequest()) - .catch((error: unknown) => { - window.alert(error instanceof Error ? error.message : String(error)); - }) - .finally(() => setPullRequestCloseSubmitting(false)); - }, [interactive, pullRequestCloseSubmitting, snapshot.repository.source]); - const markPullRequestReady = useCallback(() => { - const source = snapshot.repository.source; - if ( - !interactive?.onMarkPullRequestReady || - pullRequestReadySubmitting || - source.type !== 'pull-request' || - source.reviewStatus?.markReady?.disabled === true || - !source.reviewStatus?.markReady - ) { - return; - } - - setPullRequestReadySubmitting(true); - return Promise.resolve(interactive.onMarkPullRequestReady()) - .catch((error: unknown) => { - window.alert(error instanceof Error ? error.message : String(error)); - }) - .finally(() => setPullRequestReadySubmitting(false)); - }, [interactive, pullRequestReadySubmitting, snapshot.repository.source]); - const mergePullRequest = useCallback( - (options: PullRequestMergeOptions & { autoMerge: boolean }) => { - if (!interactive?.onMergePullRequest || pullRequestMergeSubmitting) { - return; - } - - setPullRequestMergeSubmitting(true); - void Promise.resolve(interactive.onMergePullRequest(options)) - .catch((error: unknown) => { - window.alert(error instanceof Error ? error.message : String(error)); - }) - .finally(() => setPullRequestMergeSubmitting(false)); - }, - [interactive, pullRequestMergeSubmitting], - ); - const cancelAutoMerge = useCallback(() => { - if (!interactive?.onCancelAutoMerge || pullRequestMergeSubmitting) { - return; - } - - setPullRequestMergeSubmitting(true); - void Promise.resolve(interactive.onCancelAutoMerge()) - .catch((error: unknown) => { - window.alert(error instanceof Error ? error.message : String(error)); - }) - .finally(() => setPullRequestMergeSubmitting(false)); - }, [interactive, pullRequestMergeSubmitting]); - useEffect(() => { - interactiveRef.current = interactive; - }, [interactive]); - - useEffect(() => { - if (!walkthroughRequestPending || walkthroughRequestId === 0) { - return; - } - - let cancelled = false; - void Promise.resolve(interactiveRef.current?.onGenerateWalkthrough()) - .catch(() => {}) - .finally(() => { - if (cancelled) { - return; - } - walkthroughRequestPendingRef.current = false; - setWalkthroughRequestPending(false); - }); - return () => { - cancelled = true; - }; - }, [walkthroughRequestId, walkthroughRequestPending]); - - const startWalkthroughGeneration = useCallback(() => { - if ( - !interactive || - interactive.walkthroughStatus === 'generating' || - walkthroughRequestPendingRef.current - ) { - return; - } - - walkthroughRequestPendingRef.current = true; - setWalkthroughRequestPending(true); - setWalkthroughRequestId((current) => current + 1); - }, [interactive]); - useEffect(() => { - if (sidebarMode === 'walkthrough' && interactive?.walkthroughStatus === 'idle') { - startWalkthroughGeneration(); - } - }, [interactive?.walkthroughStatus, sidebarMode, startWalkthroughGeneration]); - useEffect(() => { - if (sidebarMode !== 'comments' || generalComments.length === 0) { - return; - } - - const handleKeyDown = (event: KeyboardEvent) => { - if ( - event.defaultPrevented || - event.altKey || - event.ctrlKey || - event.metaKey || - event.shiftKey || - isNativeInputTarget(event.target) - ) { - return; - } - - const key = event.key.toLowerCase(); - if (key !== 'j' && key !== 'k') { - return; - } - - event.preventDefault(); - navigateGeneralComment(key === 'j' ? 1 : -1); - }; - - window.addEventListener('keydown', handleKeyDown); - return () => window.removeEventListener('keydown', handleKeyDown); - }, [generalComments.length, navigateGeneralComment, sidebarMode]); - const submitGeneralComment = useCallback(() => { - const body = generalCommentDraft.trim(); - if (!submitGeneralDiscussion || !body || generalCommentSubmitting) { - return; - } - - setGeneralCommentError(null); - setGeneralCommentSubmitting(true); - void Promise.resolve(submitGeneralDiscussion(body)) - .then(() => setGeneralCommentDraft('')) - .catch((error: unknown) => { - setGeneralCommentError(error instanceof Error ? error.message : String(error)); - }) - .finally(() => setGeneralCommentSubmitting(false)); - }, [generalCommentDraft, generalCommentSubmitting, submitGeneralDiscussion]); - const startEditGeneralComment = useCallback((comment: PullRequestGeneralComment) => { - if (!comment.canEdit) { - return; - } - - setEditingGeneralCommentId(comment.id); - setGeneralCommentEditDraft(comment.body); - setGeneralCommentEditError(null); - }, []); - const cancelEditGeneralComment = useCallback(() => { - if (generalCommentEditSubmitting) { - return; - } - - setEditingGeneralCommentId(null); - setGeneralCommentEditDraft(''); - setGeneralCommentEditError(null); - }, [generalCommentEditSubmitting]); - const saveGeneralCommentEdit = useCallback(() => { - const commentId = editingGeneralCommentId; - const body = generalCommentEditDraft.trim(); - if (!updateGeneralDiscussion || !commentId || !body || generalCommentEditSubmitting) { - return; - } - - setGeneralCommentEditError(null); - setGeneralCommentEditSubmitting(true); - void Promise.resolve(updateGeneralDiscussion(commentId, body)) - .then(() => { - setEditingGeneralCommentId(null); - setGeneralCommentEditDraft(''); - }) - .catch((error: unknown) => { - setGeneralCommentEditError(error instanceof Error ? error.message : String(error)); - }) - .finally(() => setGeneralCommentEditSubmitting(false)); - }, [ - editingGeneralCommentId, - generalCommentEditDraft, - generalCommentEditSubmitting, - updateGeneralDiscussion, - ]); - const activateTreePath = useCallback( - (path: string) => { - setSelectedPath(path); - setTreeScrollTarget((current) => ({ - behavior: 'smooth', - path, - request: (current?.request ?? 0) + 1, - })); - }, - [setSelectedPath], - ); - const activateReviewComment = useCallback( - (comment: ReviewComment) => { - changeSidebarMode('tree'); - setSelectedPath(comment.filePath); - focusComment(comment.id); - setTreeScrollTarget((current) => ({ - behavior: 'smooth', - commentId: comment.id, - path: comment.filePath, - request: (current?.request ?? 0) + 1, - })); - }, - [changeSidebarMode, focusComment, setSelectedPath], - ); - const activateHashTarget = useCallback(() => { - const target = getLocationHashTarget(); - if (!target || handledHashTargetRef.current === target) { - return; - } - - const reviewComment = reviewComments.find((comment) => - commentMatchesHashTarget(comment, target), - ); - if (reviewComment) { - handledHashTargetRef.current = target; - activateReviewComment(reviewComment); - return; - } - - for (const thread of generalCommentThreads) { - const generalComment = - thread.comments.find((comment) => commentMatchesHashTarget(comment, target)) ?? - (thread.id === target ? thread.comments[0] : undefined); - if (generalComment) { - handledHashTargetRef.current = target; - activateGeneralComment(generalComment.id); - return; - } - } - }, [activateGeneralComment, activateReviewComment, generalCommentThreads, reviewComments]); - useEffect(() => { - const timeout = window.setTimeout(activateHashTarget, 0); - return () => window.clearTimeout(timeout); - }, [activateHashTarget]); - useEffect(() => { - const handleHashChange = () => { - handledHashTargetRef.current = null; - activateHashTarget(); - }; - window.addEventListener('hashchange', handleHashChange); - return () => window.removeEventListener('hashchange', handleHashChange); - }, [activateHashTarget]); - const updateSelectedPathFromScroll = useCallback( - (viewer: CodeViewInstance) => { - const nextPath = getSelectedPathFromScroll( - viewer, - visibleFiles, - snapshot.preferences.showWhitespace, - ); - - if (nextPath) { - setSelectedPath((current) => (current === nextPath ? current : nextPath)); - } - }, - [setSelectedPath, snapshot.preferences.showWhitespace, visibleFiles], - ); - - const diffLineHeight = getCodeFontLineHeight( - normalizeCodeFontSizePreference(snapshot.preferences.codeFontSize), - ); - const commonReviewProps = { - activeSearchMatch: null, - agentId: snapshot.walkthrough.agent, - agentLabel: getAgentLabel(snapshot.walkthrough.agent), - codeQualityFindings: snapshot.codeQualityFindings, - collapsed, - comments: reviewComments, - commitMetadata: null, - diffLineHeight, - diffStyle: snapshot.preferences.diffStyle, - disableWorkerPool: true, - expandedGenerated, - focusCommentId, - focusCommentRequest, - gitIdentity, - hunkNavigation: null, - initialMarkdownPreviewSectionIds, - isReadOnly: !canComment, - itemVersionByKey, - keymap, - loadingSectionIds: new Set(), - onCommentDraftChange: updateActiveReviewCommentDraft, - onCreateComment: createComment, - onDeleteComment: deleteComment, - onLoadSection: noop, - onResolveThread: resolveDiscussion ?? noop, - onSaveCommentEdit: updateExistingReviewComment, - onSelectPathFromScroll: noop, - onSubmitComment: submitComment, - onToggleCollapsed: toggleCollapsed, - onToggleViewed: toggleViewed, - onUpdateComment: updateComment, - onUpdateSourceDescription: interactive?.onUpdateDescription, - onUpdateSourceTitle: interactive?.onUpdateTitle, - onUploadSourceDescriptionAsset: interactive?.onUploadDescriptionAsset, - searchQuery: '', - showWhitespace: snapshot.preferences.showWhitespace, - source: snapshot.repository.source, - supportsReviewCommentActions: submitReviewComment != null, - theme: snapshot.preferences.theme, - viewed, - wordWrap: snapshot.preferences.wordWrap, - }; - const source = snapshot.repository.source; - const sourceMergeState = source.type === 'pull-request' ? source.mergeState : undefined; - const isTerminalMergeState = sourceMergeState - ? isTerminalPullRequestMergeState(sourceMergeState) - : false; - const sourceMergeStatusBadge = - sourceMergeState && isTerminalMergeState ? ( - - ) : null; - const sourceDescriptionActions = - interactive && source.type === 'pull-request' ? ( - 0 - } - onClosePullRequest={closePullRequest} - onMarkPullRequestReady={markPullRequestReady} - onSubmitReview={submitReview} - reviewStatus={source.reviewStatus} - showCommentReview={source.provider === 'github' || source.host === 'github.com'} - > - {sourceMergeStatusBadge} - - ) : sourceMergeStatusBadge ? ( -
- {sourceMergeStatusBadge} -
- ) : undefined; - const sourceDescriptionFooterMain = - interactive && sourceMergeState && !isTerminalMergeState ? ( - - ) : undefined; - const sourceDescriptionFooter = - sourceDescriptionFooterMain && sourceDescriptionFooterAside ? ( -
-
{sourceDescriptionFooterMain}
-
{sourceDescriptionFooterAside}
-
- ) : ( - (sourceDescriptionFooterMain ?? sourceDescriptionFooterAside) - ); - const sourceDescription = - source.type === 'pull-request' ? ( - - ) : null; - - const renderWalkthroughDiffBlocks = ( - blocks: ReadonlyArray, - blockScrollTarget: WalkthroughBlockScrollTarget | null, - onActiveBlockChange: (blockId: string) => void, - ) => { - return ( - - ); - }; - - const sourceLabel = - snapshot.repository.source.type === 'working-tree' - ? null - : getSourceLabel(snapshot.repository.source); - const rootLabel = repositoryUrl - ? snapshot.repository.root - : abbreviateHomePath(snapshot.repository.root); - const sourceExternalUrl = - snapshot.repository.source.type === 'pull-request' - ? (externalUrl ?? snapshot.repository.source.url) - : null; - const repositoryLinkUrl = repositoryUrl ?? sourceExternalUrl; - const walkthroughStatus = - walkthroughRequestPending && interactive?.walkthroughStatus !== 'ready' - ? 'generating' - : interactive?.walkthroughStatus; - const [walkthroughProgressRevision, setWalkthroughProgressRevision] = useState(0); - const previousWalkthroughStatusRef = useRef(walkthroughStatus); - useEffect(() => { - if ( - walkthroughStatus === 'generating' && - previousWalkthroughStatusRef.current !== 'generating' - ) { - setWalkthroughProgressRevision((current) => current + 1); - } - previousWalkthroughStatusRef.current = walkthroughStatus; - }, [walkthroughStatus]); - const walkthroughReady = !interactive || walkthroughStatus === 'ready'; - const walkthroughFailed = walkthroughStatus === 'failed'; - const walkthroughStatusTitle = walkthroughFailed - ? 'Walkthrough unavailable' - : 'Generating walkthrough…'; - const walkthroughStatusDescription = walkthroughFailed - ? (interactive?.walkthroughError ?? 'Fix the generation issue, then try again.') - : null; - const shellTheme = - snapshot.preferences.theme === 'system' ? undefined : snapshot.preferences.theme; - const requestWalkthrough = () => { - startWalkthroughGeneration(); - }; - const reviewModes = [ - { - icon: , - label: 'Walkthrough', - value: 'walkthrough', - }, - { - icon: , - label: 'Tree', - value: 'tree', - }, - ...(showCommentsTab - ? [ - { - ariaLabel: generalCommentCount > 0 ? `Comments (${generalCommentCount})` : 'Comments', - icon: , - indicator: - generalCommentCount > 0 ? ( - - {generalCommentCount} - - ) : undefined, - label: 'Comments', - title: - generalCommentCount > 0 - ? `${generalCommentCount} ${generalCommentCount === 1 ? 'comment' : 'comments'}` - : 'Comments', - value: 'comments' as const, - }, - ] - : []), - ] satisfies ReadonlyArray>; - const topBarActions = - onDeleteShare || settingsBar ? ( - <> - {onDeleteShare ? ( - - ) : null} - {settingsBar ?
{settingsBar}
: null} - - ) : undefined; - - return ( -
- - {snapshot.branch ? ( - - {snapshot.branch} - - ) : null} - {sourceLabel ? ( - sourceExternalUrl ? ( - - {sourceLabel} - - - ) : ( - {sourceLabel} - ) - ) : null} - - } - leading={ - interactive ? ( - - ) : undefined - } - mode={sidebarMode} - modes={reviewModes} - onModeChange={changeSidebarMode} - onToggleSidebar={toggleSidebar} - repository={ - repositoryLinkUrl ? ( - - {rootLabel} - - ) : ( - {rootLabel} - ) - } - repositoryTooltip={snapshot.repository.root} - sidebarCollapsed={sidebarCollapsed ?? false} - sidebarPosition={sidebarPosition} - toggleTitle={`${sidebarCollapsed ? 'Expand' : 'Collapse'} sidebar`} - /> - -
-
- {sidebarMode === 'comments' ? ( - - ) : sidebarMode === 'tree' ? ( - visibleFiles.length === 0 ? ( -
-
- No matching files - {fileSearchQuery} -
-
- ) : ( - - ) - ) : walkthroughReady ? ( - - ) : walkthroughFailed ? ( -
-
- {walkthroughStatusTitle} -

{walkthroughStatusDescription}

-
- -
-
-
- ) : ( -
- -
- )} -
-
- ); -} diff --git a/core/__tests__/App-first-run.test.tsx b/core/__tests__/App-first-run.test.tsx new file mode 100644 index 00000000..d77d4bee --- /dev/null +++ b/core/__tests__/App-first-run.test.tsx @@ -0,0 +1,101 @@ +/** + * @vitest-environment jsdom + */ + +import { act } from 'react'; +import { expect, test, vi } from 'vite-plus/test'; +import App from '../App.tsx'; +import { createDefaultConfig } from '../config/defaults.ts'; +import { renderReact, waitFor } from './helpers/react.tsx'; + +const installWindowApi = ({ + installAgentSkill = vi.fn(async () => ({ installed: true, path: '/skill' })), + installTerminalHelper = vi.fn(async () => ({ + command: 'codiff', + installed: true, + path: '/usr/local/bin/codiff', + })), + repositoryPathProvided = false, +}: { + installAgentSkill?: ReturnType; + installTerminalHelper?: ReturnType; + repositoryPathProvided?: boolean; +} = {}) => { + const api = { + getAgentSkillStatus: vi.fn(async () => ({ installed: false, path: '/skill' })), + getConfig: vi.fn(async () => createDefaultConfig()), + getFeatureFlags: vi.fn(async () => ({ planSharing: false, walkthroughSharing: false })), + getGitIdentity: vi.fn(async () => null), + getLaunchOptions: vi.fn(async () => ({ repositoryPathProvided, walkthrough: false })), + getRepositoryState: vi.fn(async () => { + throw new Error('fatal: not a git repository'); + }), + getTerminalHelperStatus: vi.fn(async () => ({ + command: 'codiff', + installed: false, + path: '/usr/local/bin/codiff', + })), + installAgentSkill, + installTerminalHelper, + onConfigChanged: vi.fn(() => () => {}), + }; + window.codiff = api as unknown as Window['codiff']; + return api; +}; + +test('a non-repository launch renders first-run guidance and install actions', async () => { + const api = installWindowApi(); + await using view = await renderReact(); + + await waitFor(() => expect(view.container.textContent).toContain('Open a Git repository')); + expect(view.container.textContent).toContain('Install Terminal Helper'); + expect(view.container.textContent).toContain('Install Codex Skill'); + + await act(async () => + Array.from(view.container.querySelectorAll('button')) + .find((button) => button.textContent === 'Install Codex Skill') + ?.click(), + ); + await waitFor(() => expect(api.installAgentSkill).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(view.container.textContent).not.toContain('Install Codex Skill')); +}); + +test('an explicit invalid repository path renders the repository error instead of first-run', async () => { + installWindowApi({ repositoryPathProvided: true }); + await using view = await renderReact(); + + await waitFor(() => expect(view.container.textContent).toContain('No Git repository found')); + expect(view.container.textContent).not.toContain('Install Terminal Helper'); +}); + +test('terminal-helper install success exits first-run and failure restores the action', async () => { + const successfulInstall = vi.fn(async () => ({ + command: 'codiff', + installed: true, + path: '/usr/local/bin/codiff', + })); + installWindowApi({ installTerminalHelper: successfulInstall }); + await using success = await renderReact(); + await waitFor(() => expect(success.container.textContent).toContain('Install Terminal Helper')); + await act(async () => + Array.from(success.container.querySelectorAll('button')) + .find((button) => button.textContent === 'Install Terminal Helper') + ?.click(), + ); + await waitFor(() => expect(successfulInstall).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(success.container.textContent).toContain('No Git repository found')); + + const failedInstall = vi.fn(async () => { + throw new Error('Install failed.'); + }); + installWindowApi({ installTerminalHelper: failedInstall }); + await using failure = await renderReact(); + await waitFor(() => expect(failure.container.textContent).toContain('Install Terminal Helper')); + await act(async () => + Array.from(failure.container.querySelectorAll('button')) + .find((button) => button.textContent === 'Install Terminal Helper') + ?.click(), + ); + await waitFor(() => expect(failedInstall).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(failure.container.textContent).toContain('Install Terminal Helper')); +}); diff --git a/core/__tests__/App-plan.test.tsx b/core/__tests__/App-plan.test.tsx new file mode 100644 index 00000000..63afa319 --- /dev/null +++ b/core/__tests__/App-plan.test.tsx @@ -0,0 +1,487 @@ +/** + * @vitest-environment jsdom + */ + +import { act } from 'react'; +import { beforeEach, expect, test, vi } from 'vite-plus/test'; +import App from '../App.tsx'; +import { createDefaultConfig } from '../config/defaults.ts'; +import type { GitSha, PlanCommentThread, PlanReview, RepositoryState } from '../types.ts'; +import { renderReact, waitFor } from './helpers/react.tsx'; + +const reactActEnvironment = globalThis as typeof globalThis & { + ResizeObserver?: typeof ResizeObserver; + Worker?: typeof Worker; +}; +reactActEnvironment.ResizeObserver ??= class ResizeObserver { + disconnect() {} + observe() {} + unobserve() {} +}; +HTMLElement.prototype.scrollBy ??= function scrollBy() {}; +HTMLElement.prototype.scrollIntoView ??= function scrollIntoView() {}; +HTMLElement.prototype.scrollTo ??= function scrollTo() {}; +class StubWorker extends EventTarget { + constructor(_scriptURL: string | URL, _options?: WorkerOptions) { + super(); + } + onerror = null; + onmessage = null; + postMessage() {} + terminate() {} +} +reactActEnvironment.Worker ??= StubWorker as unknown as typeof Worker; + +const createMemoryStorage = (): Storage => { + const values = new Map(); + return { + clear: () => values.clear(), + getItem: (key) => values.get(key) ?? null, + key: (index) => Array.from(values.keys())[index] ?? null, + get length() { + return values.size; + }, + removeItem: (key) => values.delete(key), + setItem: (key, value) => values.set(key, value), + }; +}; + +Object.defineProperty(globalThis, 'localStorage', { + configurable: true, + value: createMemoryStorage(), +}); +Object.defineProperty(globalThis, 'sessionStorage', { + configurable: true, + value: createMemoryStorage(), +}); + +beforeEach(() => { + window.localStorage.clear(); + window.sessionStorage.clear(); +}); + +const repositoryState = { + branch: 'main', + files: [], + generatedAt: 1, + launchPath: '/repo', + root: '/repo', + source: { type: 'working-tree' }, +} satisfies RepositoryState; + +const createCodiffMock = (overrides: Partial = {}): Window['codiff'] => ({ + applyUpdate: vi.fn(async () => ({ currentVersion: '0.0.0', phase: 'idle' as const })), + askReviewAssistant: vi.fn(async () => ({ + reason: 'Unavailable in tests.', + status: 'unavailable' as const, + })), + cancelDiffContentRequest: vi.fn(), + cancelNarrativeWalkthrough: vi.fn(async () => {}), + completePlan: vi.fn(async () => {}), + createWalkthroughCommit: vi.fn(async () => ({ + sha: '0'.repeat(40) as GitSha, + status: 'committed' as const, + })), + decreaseCodeFontSize: vi.fn(async () => {}), + dismissUpdate: vi.fn(async () => ({ currentVersion: '0.0.0', phase: 'idle' as const })), + findDefinitions: vi.fn(async () => ({ + candidates: [], + identifier: '', + status: 'ready' as const, + })), + getAgentSkillStatus: vi.fn(async () => ({ installed: true, path: '/skill' })), + getConfig: vi.fn(async () => createDefaultConfig()), + getFeatureFlags: vi.fn(async () => ({ planSharing: false, walkthroughSharing: false })), + getGitIdentity: vi.fn(async () => ({ email: 'reviewer@example.com', name: 'Reviewer' })), + getKeyboardLayout: vi.fn(async () => null), + getLaunchOptions: vi.fn(async () => ({ + planFile: '/tmp/plan.md', + planResultFile: '/tmp/result.json', + repositoryPathProvided: true, + walkthrough: false, + })), + getMarkdownDocument: vi.fn(async () => ({ + content: '# Execute this plan\n', + id: 'plan:/tmp/plan.md', + kind: 'plan' as const, + path: '/tmp/plan.md', + version: 'plan-version', + })), + getNarrativeWalkthrough: vi.fn(async () => ({ + reason: 'Not used.', + status: 'unavailable' as const, + })), + getPlanReview: vi.fn(async () => null), + getPreferences: vi.fn(async () => createDefaultConfig().settings), + getRepositoryHistory: vi.fn(async () => ({ entries: [], root: '/repo' })), + getRepositoryState: vi.fn(async () => repositoryState), + getReviewComments: vi.fn(async () => ({ generalComments: [], reviewComments: [] })), + getTerminalHelperStatus: vi.fn(async () => ({ + command: 'codiff', + installed: true, + path: '/usr/local/bin/codiff', + })), + getUpdateStatus: vi.fn(async () => ({ currentVersion: '0.0.0', phase: 'idle' as const })), + increaseCodeFontSize: vi.fn(async () => {}), + installAgentSkill: vi.fn(async () => ({ installed: true, path: '/skill' })), + installTerminalHelper: vi.fn(async () => ({ + command: 'codiff', + installed: true, + path: '/usr/local/bin/codiff', + })), + isWindowFullScreen: vi.fn(async () => false), + markPlanReady: vi.fn(async () => {}), + onConfigChanged: vi.fn(() => () => {}), + onCopyPendingCommentsRequest: vi.fn(() => () => {}), + onFindInDiffs: vi.fn(() => () => {}), + onKeyboardLayoutChanged: vi.fn(() => () => {}), + onMarkdownDocumentChanged: vi.fn(() => () => {}), + onOpenReviewSource: vi.fn(() => () => {}), + onPlanCloseRequested: vi.fn(() => () => {}), + onRefreshRequest: vi.fn(() => () => {}), + onRepositoryChanged: vi.fn(() => () => {}), + onUpdateStatusChanged: vi.fn(() => () => {}), + onWalkthroughCommitOutput: vi.fn(() => () => {}), + onWalkthroughProgress: vi.fn(() => () => {}), + onWindowFullScreenChanged: vi.fn(() => () => {}), + openConfigFile: vi.fn(async () => {}), + openFile: vi.fn(async () => {}), + openReleasePage: vi.fn(async () => {}), + openRepositoryFolder: vi.fn(async () => {}), + readRevisionContent: vi.fn(async () => ({ results: [] })), + reportInitialLoadMilestone: vi.fn(), + resetCodeFontSize: vi.fn(async () => {}), + resolvePullRequestUrl: vi.fn(async (value) => value), + saveMarkdownDocument: vi.fn(async (request) => ({ + document: { + content: request.content, + id: `${request.kind}:${request.path}`, + kind: request.kind, + path: request.path, + version: 'next-version', + }, + status: 'saved' as const, + })), + savePlanReview: vi.fn(async (review) => review), + setDiffStyle: vi.fn(async () => {}), + setShowOutdated: vi.fn(async () => {}), + setWordWrap: vi.fn(async () => {}), + sharePlan: vi.fn(async () => ({ + status: 'uploaded' as const, + url: 'https://codiff.dev/p/test', + })), + shareWalkthrough: vi.fn(async () => ({ + status: 'uploaded' as const, + url: 'https://codiff.dev/w/test', + })), + showInFolder: vi.fn(async () => {}), + submitPullRequestComment: vi.fn(async () => { + throw new Error('Unexpected provider comment submission.'); + }), + submitPullRequestReview: vi.fn(async () => ({ + status: 'submitted' as const, + submittedDraftIds: [], + })), + updateWalkthroughCommitMessage: vi.fn(async () => ({ + reason: 'Not used.', + status: 'unavailable' as const, + })), + ...overrides, +}); + +const author = { + email: 'reviewer@example.com', + id: 'reviewer@example.com', + name: 'Reviewer', +}; + +const createThread = ({ + body, + id, + path = [0], + text = 'Execute this plan', +}: { + body: string; + id: string; + path?: Array; + text?: string; +}): PlanCommentThread => ({ + anchor: { + block: { + fingerprint: `${id}-fingerprint`, + path, + text, + type: 'heading', + }, + kind: 'block', + version: 1, + }, + createdAt: '2026-06-24T00:00:00.000Z', + createdBy: author, + id, + messages: [ + { + author, + body, + createdAt: '2026-06-24T00:00:00.000Z', + id: `${id}-message`, + updatedAt: '2026-06-24T00:00:00.000Z', + }, + ], + status: 'open', + updatedAt: '2026-06-24T00:00:00.000Z', +}); + +const createReview = (threads: ReadonlyArray): PlanReview => ({ + document: { + id: 'plan:/tmp/plan.md', + path: '/tmp/plan.md', + version: 'plan-version', + }, + threads, + version: 1, +}); + +test('plan mode opens the Markdown editor without loading repository state', async () => { + const getRepositoryState = vi.fn(async () => repositoryState); + const completePlan = vi.fn(async (_review: PlanReview, _status: 'closed' | 'done') => {}); + const markPlanReady = vi.fn(async () => {}); + const sharePlan = vi.fn(async (_review: PlanReview) => ({ + status: 'uploaded' as const, + url: 'https://codiff.dev/p/shared-plan', + })); + const storedReview = createReview([ + createThread({ body: 'Keep the rollout steps explicit.', id: 'thread-1' }), + createThread({ body: ' ', id: 'empty-thread' }), + ]); + window.codiff = createCodiffMock({ + completePlan, + getFeatureFlags: vi.fn(async () => ({ planSharing: true, walkthroughSharing: false })), + getMarkdownDocument: vi.fn(async () => ({ + content: '# Execute this plan\n\n- First\n- Second\n', + id: 'plan:/tmp/plan.md', + kind: 'plan' as const, + path: '/tmp/plan.md', + version: 'plan-version', + })), + getPlanReview: vi.fn(async () => storedReview), + getRepositoryState, + markPlanReady, + sharePlan, + }); + + await using view = await renderReact(); + await waitFor(() => expect(view.container.querySelector('.plan-shell')).not.toBeNull()); + expect(getRepositoryState).not.toHaveBeenCalled(); + expect(markPlanReady).toHaveBeenCalledTimes(1); + expect(view.container.querySelector('.plan-title')?.textContent).toContain('plan.md'); + await waitFor(() => + expect(view.container.querySelector('.plan-comment-thread')?.textContent).toContain( + 'Keep the rollout steps explicit.', + ), + ); + + await act(async () => + view.container.querySelector('.plan-share-button')?.click(), + ); + await waitFor(() => expect(sharePlan).toHaveBeenCalledTimes(1)); + expect(sharePlan.mock.calls[0]?.[0].threads).toHaveLength(1); + + await act(async () => + view.container.querySelector('.plan-done-button')?.click(), + ); + await waitFor(() => expect(completePlan).toHaveBeenCalledTimes(1)); + expect(completePlan).toHaveBeenCalledWith( + expect.objectContaining({ + document: { + id: 'plan:/tmp/plan.md', + path: '/tmp/plan.md', + version: 'plan-version', + }, + threads: [expect.objectContaining({ id: 'thread-1' })], + version: 1, + }), + 'done', + ); +}); + +test('plan mode resolves stored comments whose anchors were already removed', async () => { + const savePlanReview = vi.fn(async (review: PlanReview) => review); + window.codiff = createCodiffMock({ + getMarkdownDocument: vi.fn(async () => ({ + content: '# Current plan\n', + id: 'plan:/tmp/plan.md', + kind: 'plan' as const, + path: '/tmp/plan.md', + version: 'plan-version', + })), + getPlanReview: vi.fn(async () => + createReview([ + createThread({ + body: 'Keep this comment as history.', + id: 'detached-thread', + path: [99], + text: 'Removed heading', + }), + ]), + ), + savePlanReview, + }); + + await using view = await renderReact(); + await waitFor(() => + expect(savePlanReview).toHaveBeenCalledWith( + expect.objectContaining({ + threads: [ + expect.objectContaining({ + id: 'detached-thread', + resolution: expect.objectContaining({ reason: 'anchor-removed' }), + status: 'resolved', + }), + ], + }), + ), + ); + const resolvedSection = + view.container.querySelector('.plan-resolved-comments'); + expect(resolvedSection?.querySelector('summary')?.textContent).toBe('Resolved comments (1)'); + expect(view.container.querySelector('.plan-comment-thread.resolved')?.textContent).toContain( + 'Resolved after target removal', + ); +}); + +test('plan mode keeps comments open when their anchors are removed during the current review', async () => { + let publishMarkdownChange: Parameters[0] | null = + null; + const savePlanReview = vi.fn(async (review: PlanReview) => review); + window.codiff = createCodiffMock({ + getMarkdownDocument: vi.fn(async () => ({ + content: '# Current plan\n', + id: 'plan:/tmp/plan.md', + kind: 'plan' as const, + path: '/tmp/plan.md', + version: 'plan-version', + })), + getPlanReview: vi.fn(async () => + createReview([ + createThread({ + body: 'The agent still needs to process this.', + id: 'live-thread', + text: 'Current plan', + }), + ]), + ), + onMarkdownDocumentChanged: vi.fn((callback) => { + publishMarkdownChange = callback; + return () => { + publishMarkdownChange = null; + }; + }), + savePlanReview, + }); + + await using view = await renderReact(); + await waitFor(() => { + expect( + view.container.querySelector('[data-mdx-annotation-block~="live-thread"]'), + ).not.toBeNull(); + expect(publishMarkdownChange).not.toBeNull(); + }); + savePlanReview.mockClear(); + await act(async () => + publishMarkdownChange?.({ + deleted: false, + document: { + content: '# Replacement plan\n', + id: 'plan:/tmp/plan.md', + kind: 'plan', + path: '/tmp/plan.md', + version: 'next-plan-version', + }, + id: 'plan:/tmp/plan.md', + }), + ); + await waitFor(() => + expect(view.container.querySelector('[data-mdx-annotation-block~="live-thread"]')).toBeNull(), + ); + expect(view.container.querySelector('.plan-resolved-comments')).toBeNull(); + expect( + view.container.querySelector('.plan-comment-position .plan-comment-thread'), + ).not.toBeNull(); + expect( + savePlanReview.mock.calls.some( + ([review]) => + review.threads.find((thread) => thread.id === 'live-thread')?.status === 'resolved', + ), + ).toBe(false); +}); + +test('closing plan mode flushes and returns a closed handoff', async () => { + const completePlan = vi.fn(async (_review: PlanReview, _status: 'closed' | 'done') => {}); + let blockPlanReviewSave = false; + let resolvePlanReviewSave: (() => void) | null = null; + const savePlanReview = vi.fn((review: PlanReview) => { + if (!blockPlanReviewSave) { + return Promise.resolve(review); + } + return new Promise((resolveSave) => { + resolvePlanReviewSave = () => resolveSave(review); + }); + }); + let requestClose: (() => void) | null = null; + window.codiff = createCodiffMock({ + completePlan, + getPlanReview: vi.fn(async () => + createReview([createThread({ body: 'Keep this requirement.', id: 'thread-1' })]), + ), + onPlanCloseRequested: vi.fn((callback) => { + requestClose = callback; + return () => { + requestClose = null; + }; + }), + savePlanReview, + }); + + await using view = await renderReact(); + await waitFor(() => { + expect(view.container.querySelector('.plan-shell')).not.toBeNull(); + expect(requestClose).not.toBeNull(); + expect(view.container.querySelector('.plan-comment-thread')).not.toBeNull(); + }); + await act(async () => { + await new Promise((resolveWait) => setTimeout(resolveWait, 75)); + }); + savePlanReview.mockClear(); + blockPlanReviewSave = true; + await act(async () => requestClose?.()); + await waitFor(() => expect(savePlanReview).toHaveBeenCalledTimes(1)); + expect(completePlan).not.toHaveBeenCalled(); + expect(view.container.querySelector('.review-comment-delete')?.disabled).toBe( + true, + ); + await act(async () => resolvePlanReviewSave?.()); + await waitFor(() => expect(completePlan).toHaveBeenCalledTimes(1)); + expect(completePlan).toHaveBeenCalledWith( + expect.objectContaining({ threads: [expect.objectContaining({ id: 'thread-1' })] }), + 'closed', + ); +}); + +test('plan mode recovers from an unreadable review sidecar', async () => { + const markPlanReady = vi.fn(async () => {}); + window.codiff = createCodiffMock({ + getPlanReview: vi.fn(async () => { + throw new Error('Invalid plan review.'); + }), + markPlanReady, + }); + + await using view = await renderReact(); + await waitFor(() => expect(view.container.querySelector('.plan-shell')).not.toBeNull()); + expect(view.container.querySelector('[role="alert"]')?.textContent).toContain( + 'Invalid plan review.', + ); + expect(view.container.querySelector('[contenteditable="true"]')).not.toBeNull(); + expect(markPlanReady).toHaveBeenCalledTimes(1); +}); diff --git a/core/__tests__/App-render.test.tsx b/core/__tests__/App-render.test.tsx deleted file mode 100644 index c1894bc8..00000000 --- a/core/__tests__/App-render.test.tsx +++ /dev/null @@ -1,3762 +0,0 @@ -/** - * @vitest-environment jsdom - */ - -import { readFileSync } from 'node:fs'; -import { resolve } from 'node:path'; -import { act } from 'react'; -import { createRoot, type Root } from 'react-dom/client'; -import { beforeEach, expect, test, vi } from 'vite-plus/test'; -import App from '../App.tsx'; -import { createDefaultConfig, defaultSettings } from '../config/defaults.ts'; -import { - consumeReloadSelection, - getReloadSelectionPath, - writeReloadSelection, -} from '../lib/reload-selection.ts'; -import type { - ChangedFile, - CommitMetadata, - NarrativeWalkthrough, - PlanReview, - RepositoryState, - ReviewSource, - WalkthroughProgressEvent, -} from '../types.ts'; -import { createChangedFile } from './helpers/fixtures.ts'; -import { renderReact, setInputValue, waitFor } from './helpers/react.tsx'; - -const reactActEnvironment = globalThis as typeof globalThis & { - ResizeObserver?: typeof ResizeObserver; - Worker?: typeof Worker; -}; -reactActEnvironment.ResizeObserver ??= class ResizeObserver { - disconnect() {} - observe() {} - unobserve() {} -}; -HTMLElement.prototype.scrollBy ??= function scrollBy() {}; -HTMLElement.prototype.scrollTo ??= function scrollTo() {}; -class StubWorker extends EventTarget { - constructor(_scriptURL: string | URL, _options?: WorkerOptions) { - super(); - } - onerror = null; - onmessage = null; - postMessage() {} - terminate() {} -} -reactActEnvironment.Worker ??= StubWorker as unknown as typeof Worker; - -beforeEach(() => { - window.localStorage.clear(); - window.sessionStorage.clear(); - document.documentElement.style.removeProperty('--font-diff-line-height'); - document.documentElement.style.removeProperty('--font-diff-mono'); - document.documentElement.style.removeProperty('--font-diff-size'); -}); - -const repositoryState = { - branch: 'main', - files: [], - generatedAt: 1, - launchPath: '/repo', - root: '/repo', - source: { type: 'working-tree' }, -} satisfies RepositoryState; - -const createCommitMetadataFixture = (body: string): CommitMetadata => ({ - author: { - date: '2026-01-01T12:00:00Z', - email: 'author@example.com', - gravatarUrl: 'https://www.gravatar.com/avatar/fallback', - name: 'Author', - }, - body, - committer: { - date: '2026-01-01T13:00:00Z', - email: 'committer@example.com', - name: 'Committer', - }, - files: [ - { - additions: 1, - binary: false, - deletions: 1, - path: 'src/app.ts', - status: 'modified', - }, - ], - parents: ['parent-sha'], - ref: 'abc1234', - refs: ['main'], - shortRef: 'abc1234', - signature: { - key: 'SHA256:abcdefghijklmnopqrstuvwxyz0123456789', - signer: 'signer@example.test', - status: 'G', - }, - stats: { - additions: 1, - binaryFiles: 0, - deletions: 1, - files: 1, - renamedFiles: 0, - }, - subject: 'Commit subject', - trailers: [ - { - key: 'Co-authored-by', - value: 'Second Author ', - }, - ], -}); - -const createCodiffMock = (overrides: Partial = {}): Window['codiff'] => ({ - applyUpdate: vi.fn(async () => ({ - currentVersion: '1.9.2', - phase: 'idle' as const, - })), - askReviewAssistant: vi.fn(async () => ({ - reason: 'Unavailable in tests.', - status: 'unavailable' as const, - })), - completePlan: vi.fn(async () => {}), - createWalkthroughCommit: vi.fn(async () => ({ - hash: '0000000000000000000000000000000000000000', - status: 'committed' as const, - })), - decreaseCodeFontSize: vi.fn(async () => {}), - dismissUpdate: vi.fn(async () => ({ - currentVersion: '1.9.2', - phase: 'idle' as const, - })), - findDefinitions: vi.fn(async (request) => ({ - candidates: [], - identifier: request.identifier, - status: 'ready' as const, - })), - getAgentSkillStatus: vi.fn(async () => ({ - installed: true, - path: '/Users/reviewer/.codex/skills/codiff', - })), - getConfig: vi.fn(async () => createDefaultConfig()), - getDiffImageContent: vi.fn(async () => ({ - reason: 'Unavailable in tests.', - status: 'unavailable' as const, - })), - getDiffSectionContent: vi.fn(async () => { - throw new Error('Unexpected diff section load.'); - }), - getFeatureFlags: vi.fn(async () => ({ - planSharing: false, - walkthroughSharing: false, - })), - getGitIdentity: vi.fn(async () => ({ - email: 'reviewer@example.com', - name: 'Reviewer', - })), - getKeyboardLayout: vi.fn(async () => null), - getLaunchOptions: vi.fn(async () => ({ - repositoryPathProvided: true, - walkthrough: false, - })), - getMarkdownDocument: vi.fn(async ({ kind, path }) => ({ - content: '# Plan\n', - id: `${kind}:${path}`, - kind, - path, - version: 'version', - })), - getNarrativeWalkthrough: vi.fn(async () => ({ - reason: 'Unavailable in tests.', - status: 'unavailable' as const, - })), - getPlanReview: vi.fn(async () => null), - getPreferences: vi.fn(async () => ({ - agentBackend: 'codex' as const, - claudeModel: defaultSettings.claudeModel, - codeFontFamily: defaultSettings.codeFontFamily, - codeFontSize: defaultSettings.codeFontSize, - copyCommentsOnClose: true, - diffStyle: 'split' as const, - editorCommand: '', - lastRepositoryPath: '/repo', - openAIModel: defaultSettings.openAIModel, - opencodeModel: defaultSettings.opencodeModel, - piModel: defaultSettings.piModel, - reviewCommentsPrefix: defaultSettings.reviewCommentsPrefix, - showOutdated: false, - showWhitespace: false, - sidebarPosition: defaultSettings.sidebarPosition, - theme: 'system' as const, - walkthroughPrompt: defaultSettings.walkthroughPrompt, - wordWrap: false, - })), - getRepositoryHistory: vi.fn(async () => ({ - entries: [], - root: '/repo', - })), - getRepositoryState: vi.fn(async () => repositoryState), - getTerminalHelperStatus: vi.fn(async () => ({ - command: 'codiff', - installed: true, - path: '/usr/local/bin/codiff', - })), - getUpdateStatus: vi.fn(async () => ({ - currentVersion: '1.9.2', - phase: 'idle' as const, - })), - increaseCodeFontSize: vi.fn(async () => {}), - installAgentSkill: vi.fn(async () => ({ - installed: true, - path: '/Users/reviewer/.codex/skills/codiff', - })), - installTerminalHelper: vi.fn(async () => ({ - command: 'codiff', - installed: true, - path: '/usr/local/bin/codiff', - })), - isWindowFullScreen: vi.fn(async () => false), - markPlanReady: vi.fn(async () => {}), - onConfigChanged: vi.fn(() => () => {}), - onCopyPendingCommentsRequest: vi.fn(() => () => {}), - onFindInDiffs: vi.fn(() => () => {}), - onKeyboardLayoutChanged: vi.fn(() => () => {}), - onMarkdownDocumentChanged: vi.fn(() => () => {}), - onOpenReviewSource: vi.fn(() => () => {}), - onPlanCloseRequested: vi.fn(() => () => {}), - onRefreshRequest: vi.fn(() => () => {}), - onRepositoryChanged: vi.fn(() => () => {}), - onUpdateStatusChanged: vi.fn(() => () => {}), - onWalkthroughCommitOutput: vi.fn(() => () => {}), - onWalkthroughProgress: vi.fn(() => () => {}), - onWindowFullScreenChanged: vi.fn(() => () => {}), - openConfigFile: vi.fn(async () => {}), - openFile: vi.fn(async () => {}), - openReleasePage: vi.fn(async () => {}), - openRepositoryFolder: vi.fn(async () => {}), - resetCodeFontSize: vi.fn(async () => {}), - resolvePullRequestUrl: vi.fn(async () => 'https://github.com/owner/repo/pull/1'), - saveMarkdownDocument: vi.fn(async (request) => ({ - document: { - content: request.content, - id: `${request.kind}:${request.path}`, - kind: request.kind, - path: request.path, - version: 'next-version', - }, - status: 'saved' as const, - })), - savePlanReview: vi.fn(async (review) => review), - setDiffStyle: vi.fn(async () => {}), - setShowOutdated: vi.fn(async () => {}), - setWordWrap: vi.fn(async () => {}), - sharePlan: vi.fn(async () => ({ - status: 'uploaded' as const, - url: 'https://codiff.dev/p/test', - })), - shareWalkthrough: vi.fn(async () => ({ - status: 'uploaded' as const, - url: 'https://codiff.dev/w/test', - })), - showInFolder: vi.fn(async () => {}), - submitPullRequestComment: vi.fn(async () => { - throw new Error('Unexpected pull request comment submit.'); - }), - submitPullRequestReview: vi.fn(async () => {}), - updateWalkthroughCommitMessage: vi.fn(async () => ({ - reason: 'Unavailable in tests.', - status: 'unavailable' as const, - })), - ...overrides, -}); - -const createNarrativeWalkthroughFixture = ( - hunks: ReadonlyArray<{ - added: number; - path: string; - status: ChangedFile['status']; - }>, -) => - ({ - agent: 'codex', - chapters: [ - { - blurb: 'Review the implementation.', - icon: 'gear', - id: 'impl', - stops: [ - { - added: hunks.reduce((sum, hunk) => sum + hunk.added, 0), - deleted: 1, - hunkIds: hunks.map((hunk) => `${hunk.path}:unstaged:h1`), - hunks: hunks.map((hunk) => ({ - added: hunk.added, - anchor: { - display: hunk.path, - sectionId: `${hunk.path}:unstaged`, - side: 'both', - }, - deleted: hunk.path === 'src/app.ts' ? 1 : 0, - id: `${hunk.path}:unstaged:h1`, - path: hunk.path, - status: hunk.status, - })), - id: 'implementation-path', - importance: 'critical', - prose: 'Review these changes.', - title: 'Implementation path', - }, - ], - title: 'Implementation', - }, - ], - focus: 'Focus.', - generatedAt: '2026-06-07T00:00:00.000Z', - kind: 'narrative', - repo: { branch: 'main', root: '/repo' }, - source: { type: 'working-tree' }, - support: [], - title: 'Narrative', - version: 4, - }) satisfies NarrativeWalkthrough; - -const dispatchModK = () => { - const isMac = navigator.platform.toLowerCase().includes('mac'); - window.dispatchEvent(new KeyboardEvent('keydown', { ctrlKey: !isMac, key: 'k', metaKey: isMac })); -}; - -const renderAppForOpenFileShortcut = async (file: ChangedFile) => { - const openFile = vi.fn(async () => {}); - - window.codiff = createCodiffMock({ - getRepositoryState: vi.fn(async () => ({ - ...repositoryState, - files: [file], - })), - openFile, - }); - - const container = document.createElement('div'); - document.body.append(container); - const root = createRoot(container); - - await act(async () => { - root.render(); - }); - - await waitFor(() => { - expect(container.querySelector('.loading')).toBeNull(); - expect(container.querySelector('.codiff-file-header')).not.toBeNull(); - }); - - return { - openFile, - async [Symbol.asyncDispose]() { - await act(async () => root.unmount()); - container.remove(); - }, - }; -}; - -test('code font preferences update root CSS variables', async () => { - const nextConfig = createDefaultConfig(); - nextConfig.settings.codeFontFamily = 'JetBrains Mono'; - nextConfig.settings.codeFontSize = 14; - window.codiff = createCodiffMock({ - getConfig: vi.fn(async () => nextConfig), - }); - - const container = document.createElement('div'); - document.body.append(container); - const root = createRoot(container); - - await act(async () => { - root.render(); - }); - - await waitFor(() => { - expect(document.documentElement.style.getPropertyValue('--font-diff-mono')).toBe( - '"JetBrains Mono", monospace', - ); - expect(document.documentElement.style.getPropertyValue('--font-diff-size')).toBe('14px'); - expect(document.documentElement.style.getPropertyValue('--font-diff-line-height')).toBe('22px'); - }); - - await act(async () => root.unmount()); - container.remove(); -}); - -test('desktop app places the sidebar on the configured side', async () => { - const nextConfig = createDefaultConfig(); - nextConfig.settings.sidebarPosition = 'right'; - window.codiff = createCodiffMock({ - getConfig: vi.fn(async () => nextConfig), - }); - - await using app = await renderReact(); - - await waitFor(() => { - const shell = app.container.querySelector('.app-shell'); - expect(shell?.dataset.sidebarPosition).toBe('right'); - expect(shell?.style.gridTemplateColumns).toBe('minmax(0, 1fr) 0 292px'); - }); -}); - -test('empty code font family removes the root CSS variable', async () => { - document.documentElement.style.setProperty('--font-diff-mono', 'stale'); - window.codiff = createCodiffMock(); - - const container = document.createElement('div'); - document.body.append(container); - const root = createRoot(container); - - await act(async () => { - root.render(); - }); - - await waitFor(() => { - expect(document.documentElement.style.getPropertyValue('--font-diff-mono')).toBe(''); - expect(document.documentElement.style.getPropertyValue('--font-diff-size')).toBe('13px'); - expect(document.documentElement.style.getPropertyValue('--font-diff-line-height')).toBe('20px'); - }); - - await act(async () => root.unmount()); - container.remove(); -}); - -test('stale persisted collapsed sidebar state does not hide the sidebar on launch', async () => { - window.localStorage.setItem('codiff:sidebar-collapsed', 'true'); - window.codiff = createCodiffMock(); - - await using app = await renderReact(); - - await waitFor(() => { - expect(app.container.querySelector('.app-shell')?.classList.contains('sidebar-collapsed')).toBe( - false, - ); - expect(app.container.querySelector('.sidebar')).not.toBeNull(); - expect(app.container.querySelector('.review-top-bar [role="tablist"]')).not.toBeNull(); - }); - - const topBar = app.container.querySelector('.review-top-bar'); - expect(app.container.querySelector('.sidebar [role="tablist"]')).toBeNull(); - const modeControl = topBar?.querySelector('.review-mode-control'); - const modes = modeControl?.querySelectorAll('[role="tab"]') ?? []; - expect([...modes].map((mode) => mode.textContent)).toEqual(['Walkthrough', 'Tree', 'History']); - const sidebarToggle = topBar?.querySelector('.sidebar-toggle-button'); - await act(async () => sidebarToggle?.click()); - expect(app.container.querySelector('.app-shell')?.classList.contains('sidebar-collapsed')).toBe( - true, - ); - expect(app.container.querySelector('.review-top-bar')).toBe(topBar); -}); - -test('keeps source-loading errors in the open-source dialog', async () => { - const openReviewSourceListeners: Array[0]> = - []; - const getRepositoryState = vi.fn(async (source?: ReviewSource) => { - if (source?.type === 'branch-working-tree') { - throw new Error(`Branch "${source.ref}" does not exist in this repository.`); - } - return repositoryState; - }); - window.codiff = createCodiffMock({ - getRepositoryState, - onOpenReviewSource: vi.fn((callback) => { - openReviewSourceListeners.push(callback); - return () => {}; - }), - }); - - await using app = await renderReact(); - await waitFor(() => expect(app.container.querySelector('.app-shell')).not.toBeNull()); - - await act(async () => openReviewSourceListeners[0]?.('branch')); - const input = app.container.querySelector('#open-review-source-input'); - const form = app.container.querySelector('form.open-review-source-dialog'); - if (!input || !form) { - throw new Error('Expected the open branch dialog.'); - } - - await setInputValue(input, 'missing-branch'); - await act(async () => { - form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); - }); - - await waitFor(() => { - expect(app.container.querySelector('[role="alert"]')?.textContent).toBe( - 'Branch "missing-branch" does not exist in this repository.', - ); - }); - expect(app.container.querySelector('.open-review-source-dialog')).not.toBeNull(); - expect(app.container.querySelector('.app-shell')).not.toBeNull(); - expect(app.container.textContent).not.toContain('Unable to read repository'); -}); - -test('top bar source menu opens the review source dialog', async () => { - window.codiff = createCodiffMock(); - - await using app = await renderReact(); - await waitFor(() => expect(app.container.querySelector('.app-shell')).not.toBeNull()); - - const trigger = app.container.querySelector( - '.review-top-bar .open-review-source-trigger', - ); - if (!trigger) { - throw new Error('Expected the open review source trigger in the top bar.'); - } - await act(async () => { - trigger.dispatchEvent(new MouseEvent('click', { bubbles: true })); - }); - - const pullRequestItem = [ - ...document.querySelectorAll('[role="menuitem"]'), - ].find((item) => item.textContent === 'Open PR'); - if (!pullRequestItem) { - throw new Error('Expected an Open PR menu item.'); - } - await act(async () => { - pullRequestItem.dispatchEvent(new MouseEvent('click', { bubbles: true })); - }); - - const input = app.container.querySelector('#open-review-source-input'); - expect(input?.placeholder).toBe('#123 or https://github.com/owner/repo/pull/123'); - expect(document.querySelector('[role="menu"]')).toBeNull(); -}); - -test('repository label is plain text and clicking it does nothing', async () => { - const openRepositoryFolder = vi.fn(async () => {}); - window.codiff = createCodiffMock({ openRepositoryFolder }); - - await using app = await renderReact(); - await waitFor(() => expect(app.container.querySelector('.app-shell')).not.toBeNull()); - - const label = app.container.querySelector('.review-top-bar-repository'); - if (!label) { - throw new Error('Expected the repository label in the top bar.'); - } - expect(label.tagName).toBe('SPAN'); - expect(label.textContent).toBe('/repo'); - expect(label.closest('.review-top-bar-repository-slot')?.getAttribute('title')).toBe('/repo'); - expect(app.container.querySelector('a.review-top-bar-repository')).toBeNull(); - expect(app.container.querySelector('button.review-top-bar-repository')).toBeNull(); - - await act(async () => { - label.dispatchEvent(new MouseEvent('click', { bubbles: true })); - }); - - expect(openRepositoryFolder).not.toHaveBeenCalled(); -}); - -test('the branch name renders in the top bar context', async () => { - window.codiff = createCodiffMock(); - - await using app = await renderReact(); - await waitFor(() => expect(app.container.querySelector('.app-shell')).not.toBeNull()); - - const branch = app.container.querySelector('.review-top-bar-branch'); - expect(branch?.textContent).toBe('main'); - expect(branch?.getAttribute('title')).toBe('main'); - expect(branch?.parentElement?.className).toBe('review-top-bar-context'); -}); - -test('empty repository state fills the review pane for centered layout', async () => { - window.codiff = createCodiffMock(); - - const container = document.createElement('div'); - document.body.append(container); - const root = createRoot(container); - - await act(async () => { - root.render(); - }); - - await waitFor(() => { - const emptyState = container.querySelector('.review > .empty-state'); - expect(emptyState?.textContent).toContain('No local changes'); - }); - const css = readFileSync(resolve('core/App.css'), 'utf8'); - const emptyStateRule = css.match(/\.review > \.empty-state \{([^}]*)\}/)?.[1]; - expect(emptyStateRule).toContain('flex: 1;'); - expect(emptyStateRule).toContain('min-width: 0;'); - expect(emptyStateRule).toContain('width: 100%;'); - - await act(async () => root.unmount()); - container.remove(); -}); - -test('showWhitespace config changes reload the current repository state', async () => { - let configListener: ((config: ReturnType) => void) | null = null; - const nextConfig = createDefaultConfig(); - nextConfig.settings.showWhitespace = true; - const initialState = { - ...repositoryState, - files: [createChangedFile('src/initial.ts')], - }; - const reloadedState = { - ...repositoryState, - files: [createChangedFile('src/reloaded.ts')], - }; - const getRepositoryState = vi - .fn() - .mockResolvedValueOnce(initialState) - .mockResolvedValueOnce(reloadedState); - window.codiff = createCodiffMock({ - getRepositoryState, - onConfigChanged: vi.fn((callback) => { - configListener = callback; - return () => {}; - }), - }); - - const container = document.createElement('div'); - document.body.append(container); - const root = createRoot(container); - - await act(async () => { - root.render(); - }); - - await waitFor(() => { - expect(container.textContent).toContain('src/initial.ts'); - }); - - await act(async () => { - configListener?.(nextConfig); - await new Promise((resolve) => setTimeout(resolve, 0)); - }); - - await waitFor(() => { - expect(container.textContent).toContain('src/reloaded.ts'); - }); - expect(getRepositoryState).toHaveBeenLastCalledWith({ type: 'working-tree' }); - - await act(async () => root.unmount()); - container.remove(); -}); - -test('sidebar commit button toggles back to tree when commit view is open', async () => { - window.codiff = createCodiffMock({ - getRepositoryState: vi.fn(async () => ({ - ...repositoryState, - files: [createChangedFile('src/change.ts')], - })), - }); - - const container = document.createElement('div'); - document.body.append(container); - const root = createRoot(container); - - await act(async () => { - root.render(); - }); - - await waitFor(() => { - expect(container.querySelector('.sidebar-commit-button')?.textContent).toBe('Commit'); - }); - - await act(async () => { - container.querySelector('.sidebar-commit-button')?.click(); - }); - - await waitFor(() => { - expect(container.querySelector('.wt-commit')).not.toBeNull(); - expect(container.querySelector('.sidebar-commit-button')?.textContent).toBe('Tree'); - }); - - await act(async () => { - container.querySelector('.sidebar-commit-button')?.click(); - }); - - await waitFor(() => { - expect(container.querySelector('.wt-commit')).toBeNull(); - expect(container.querySelector('.sidebar-commit-button')?.textContent).toBe('Commit'); - }); - - await act(async () => root.unmount()); - container.remove(); -}); - -test('repository reload restores the selected file when it still exists', async () => { - const firstFile = createChangedFile('src/first.ts'); - const secondFile = createChangedFile('src/second.ts'); - const nextState = { - ...repositoryState, - files: [firstFile, secondFile], - } satisfies RepositoryState; - - writeReloadSelection(nextState, secondFile.path); - const openFile = vi.fn(async () => {}); - - window.codiff = createCodiffMock({ - getRepositoryState: vi.fn(async () => nextState), - openFile, - }); - - const container = document.createElement('div'); - document.body.append(container); - let root: Root | null = null; - - await using _resource = { - async [Symbol.asyncDispose]() { - if (root) { - await act(async () => root?.unmount()); - } - container.remove(); - window.sessionStorage.clear(); - }, - }; - await act(async () => { - root = createRoot(container); - root.render(); - }); - await waitFor(() => { - expect(container.querySelector('.loading')).toBeNull(); - expect(container.querySelector('.codiff-file-header')).not.toBeNull(); - }); - await act(async () => { - dispatchModK(); - await new Promise((resolve) => setTimeout(resolve, 0)); - }); - expect(openFile).toHaveBeenCalledWith(secondFile.path); -}); - -test('repository reload restores the selected file from the previous source', async () => { - const firstFile = createChangedFile('src/first.ts'); - const secondFile = createChangedFile('src/second.ts'); - const source = { ref: 'abc1234', type: 'commit' } satisfies ReviewSource; - const nextState = { - ...repositoryState, - files: [firstFile, secondFile], - source, - } satisfies RepositoryState; - const getRepositoryState = vi.fn(async (requestedSource?: ReviewSource) => - requestedSource?.type === 'commit' ? nextState : repositoryState, - ); - - writeReloadSelection(nextState, secondFile.path); - const openFile = vi.fn(async () => {}); - - window.codiff = createCodiffMock({ - getRepositoryState, - openFile, - }); - - const container = document.createElement('div'); - document.body.append(container); - let root: Root | null = null; - - await using _resource = { - async [Symbol.asyncDispose]() { - if (root) { - await act(async () => root?.unmount()); - } - container.remove(); - }, - }; - await act(async () => { - root = createRoot(container); - root.render(); - }); - await waitFor(() => { - expect(container.querySelector('.loading')).toBeNull(); - expect(container.querySelector('.codiff-file-header')).not.toBeNull(); - }); - await act(async () => { - dispatchModK(); - await new Promise((resolve) => setTimeout(resolve, 0)); - }); - expect(openFile).toHaveBeenCalledWith(secondFile.path); - expect(getRepositoryState).toHaveBeenCalledWith(source); -}); - -test('repository reload preserves a branch diff source even without a selected file', async () => { - const source = { - baseRef: 'base123', - headRef: 'head123', - ref: 'main', - type: 'branch-diff', - } satisfies ReviewSource; - const nextState = { - ...repositoryState, - files: [], - source, - } satisfies RepositoryState; - const getRepositoryState = vi.fn(async (requestedSource?: ReviewSource) => - requestedSource?.type === 'branch-diff' ? nextState : repositoryState, - ); - - writeReloadSelection(nextState, null); - - window.codiff = createCodiffMock({ - getRepositoryState, - }); - - const container = document.createElement('div'); - document.body.append(container); - let root: Root | null = null; - - await using _resource = { - async [Symbol.asyncDispose]() { - if (root) { - await act(async () => root?.unmount()); - } - container.remove(); - }, - }; - await act(async () => { - root = createRoot(container); - root.render(); - }); - await waitFor(() => { - expect(container.querySelector('.loading')).toBeNull(); - }); - expect(getRepositoryState).toHaveBeenCalledWith(source); -}); - -test('branch history keeps branch diff available after selecting uncommitted changes', async () => { - const branchSource = { - baseRef: 'base123', - headRef: 'head123', - ref: 'main', - type: 'branch-diff', - } satisfies ReviewSource; - const branchState = { - ...repositoryState, - branch: 'fork', - source: branchSource, - } satisfies RepositoryState; - const workingTreeState = { - ...repositoryState, - branch: 'fork', - source: { type: 'working-tree' }, - } satisfies RepositoryState; - const getRepositoryState = vi.fn(async (requestedSource?: ReviewSource) => - requestedSource?.type === 'working-tree' ? workingTreeState : branchState, - ); - - window.codiff = createCodiffMock({ - getRepositoryHistory: vi.fn(async () => ({ - entries: [ - { - author: 'Reviewer', - committedAt: Date.now(), - parents: [], - ref: '99e7b27', - subject: 'Add branch diff review mode', - }, - ], - root: '/repo', - })), - getRepositoryState, - }); - - const container = document.createElement('div'); - document.body.append(container); - let root: Root | null = null; - - const findButton = (label: string) => - Array.from(container.querySelectorAll('button')).find((button) => - button.textContent?.includes(label), - ); - - await using _resource = { - async [Symbol.asyncDispose]() { - if (root) { - await act(async () => root?.unmount()); - } - container.remove(); - }, - }; - await act(async () => { - root = createRoot(container); - root.render(); - }); - await waitFor(() => { - expect(container.querySelector('.loading')).toBeNull(); - expect(findButton('Committed only vs main')).toBeTruthy(); - expect(findButton('All changes vs main')).toBeTruthy(); - }); - await act(async () => { - findButton('Uncommitted')?.click(); - }); - await waitFor(() => { - expect(getRepositoryState).toHaveBeenCalledWith({ type: 'working-tree' }); - expect(findButton('Committed only vs main')).toBeTruthy(); - }); - await act(async () => { - findButton('Committed only vs main')?.click(); - }); - await waitFor(() => { - expect(getRepositoryState).toHaveBeenCalledWith(branchSource); - }); -}); - -test('repository reload restores branch diff scope after selecting uncommitted changes', async () => { - const branchSource = { - baseRef: 'base123', - headRef: 'head123', - ref: 'main', - type: 'branch-diff', - } satisfies ReviewSource; - const workingTreeState = { - ...repositoryState, - branch: 'fork', - source: { type: 'working-tree' }, - } satisfies RepositoryState; - const getRepositoryHistory = vi.fn(async () => ({ - entries: [ - { - author: 'Reviewer', - committedAt: Date.now(), - parents: [], - ref: '99e7b27', - subject: 'Add branch diff review mode', - }, - ], - root: '/repo', - })); - const getRepositoryState = vi.fn(async () => workingTreeState); - - writeReloadSelection(workingTreeState, null, branchSource); - - window.codiff = createCodiffMock({ - getRepositoryHistory, - getRepositoryState, - }); - - const container = document.createElement('div'); - document.body.append(container); - let root: Root | null = null; - - const findButton = (label: string) => - Array.from(container.querySelectorAll('button')).find((button) => - button.textContent?.includes(label), - ); - - await using _resource = { - async [Symbol.asyncDispose]() { - if (root) { - await act(async () => root?.unmount()); - } - container.remove(); - }, - }; - await act(async () => { - root = createRoot(container); - root.render(); - }); - await waitFor(() => { - expect(container.querySelector('.loading')).toBeNull(); - expect(findButton('Committed only vs main')).toBeTruthy(); - }); - expect(getRepositoryState).toHaveBeenCalledWith({ type: 'working-tree' }); - expect(getRepositoryHistory).toHaveBeenCalledWith(expect.any(Number), branchSource); -}); - -test('repository reload does not let stale selection override launch source', async () => { - const launchSource = { - ref: 'main', - type: 'branch-working-tree', - } satisfies ReviewSource; - const staleState = { - ...repositoryState, - source: { type: 'working-tree' }, - } satisfies RepositoryState; - const branchState = { - ...repositoryState, - source: launchSource, - } satisfies RepositoryState; - const getRepositoryState = vi.fn(async (requestedSource?: ReviewSource) => - requestedSource?.type === 'working-tree' ? staleState : branchState, - ); - - writeReloadSelection(staleState, null); - - window.codiff = createCodiffMock({ - getLaunchOptions: vi.fn(async () => ({ - repositoryPathProvided: true, - source: launchSource, - walkthrough: false, - })), - getRepositoryState, - }); - - const container = document.createElement('div'); - document.body.append(container); - let root: Root | null = null; - - await using _resource = { - async [Symbol.asyncDispose]() { - if (root) { - await act(async () => root?.unmount()); - } - container.remove(); - }, - }; - await act(async () => { - root = createRoot(container); - root.render(); - }); - await waitFor(() => { - expect(container.querySelector('.loading')).toBeNull(); - }); - expect(getRepositoryState).toHaveBeenCalledWith(undefined); - expect(getRepositoryState).not.toHaveBeenCalledWith(staleState.source); -}); - -test('repository reload colors only git status glyphs for files changed after reload', async () => { - const unchangedFile = createChangedFile('src/unchanged.ts', { - fingerprint: 'same', - }); - const changedFileBeforeReload = createChangedFile('src/changed.ts', { - fingerprint: 'before', - }); - const changedFileAfterReload = createChangedFile('src/changed.ts', { - fingerprint: 'after', - }); - const previousState = { - ...repositoryState, - files: [unchangedFile, changedFileBeforeReload], - } satisfies RepositoryState; - const nextState = { - ...repositoryState, - files: [unchangedFile, changedFileAfterReload], - } satisfies RepositoryState; - - writeReloadSelection(previousState, changedFileBeforeReload.path); - - window.codiff = createCodiffMock({ - getRepositoryState: vi.fn(async () => nextState), - }); - - const container = document.createElement('div'); - document.body.append(container); - let root: Root | null = null; - - await using _resource = { - async [Symbol.asyncDispose]() { - if (root) { - await act(async () => root?.unmount()); - } - container.remove(); - }, - }; - await act(async () => { - root = createRoot(container); - root.render(); - }); - await waitFor(() => { - const shadowRoot = container.querySelector('file-tree-container')?.shadowRoot; - const styleText = - shadowRoot?.querySelector('style[data-codiff-reload-delta-git-status]')?.textContent ?? ''; - expect(styleText).toContain('[data-item-path="src/changed.ts"][data-item-git-status]'); - expect(styleText).not.toContain('[data-item-path="src/unchanged.ts"][data-item-git-status]'); - expect(styleText).toContain("> [data-item-section='git']"); - expect( - shadowRoot?.querySelector( - '[data-item-path="src/changed.ts"][data-item-git-status] > [data-item-section="git"]', - ), - ).toBeTruthy(); - expect( - shadowRoot?.querySelector( - '[data-item-path="src/unchanged.ts"][data-item-git-status] > [data-item-section="git"]', - ), - ).toBeTruthy(); - }); -}); - -test('walkthrough file reload keeps uncovered files in support', async () => { - const unchangedFile = createChangedFile('src/unchanged.ts', { - fingerprint: 'same', - }); - const changedFileBeforeReload = createChangedFile('src/changed.ts', { - fingerprint: 'before', - }); - const changedFileAfterReload = createChangedFile('src/changed.ts', { - fingerprint: 'after', - }); - const addedFile = createChangedFile('src/added.ts', { - fingerprint: 'added', - status: 'added', - }); - const previousState = { - ...repositoryState, - files: [unchangedFile, changedFileBeforeReload], - } satisfies RepositoryState; - const nextState = { - ...repositoryState, - files: [unchangedFile, changedFileAfterReload, addedFile], - } satisfies RepositoryState; - const narrativeWalkthrough = createNarrativeWalkthroughFixture([ - { added: 1, path: unchangedFile.path, status: 'modified' }, - ]); - - writeReloadSelection(previousState, changedFileBeforeReload.path); - - window.codiff = createCodiffMock({ - getLaunchOptions: vi.fn(async () => ({ - repositoryPathProvided: true, - walkthrough: false, - walkthroughFile: '/tmp/walkthrough.json', - })), - getNarrativeWalkthrough: vi.fn(async () => ({ - status: 'ready' as const, - walkthrough: narrativeWalkthrough, - })), - getRepositoryState: vi.fn(async () => nextState), - }); - - const container = document.createElement('div'); - document.body.append(container); - let root: Root | null = null; - - await using _resource = { - async [Symbol.asyncDispose]() { - if (root) { - await act(async () => root?.unmount()); - } - container.remove(); - }, - }; - await act(async () => { - root = createRoot(container); - root.render(); - }); - await waitFor(() => { - expect(container.querySelector('.wt-stop-block')).not.toBeNull(); - }); - await act(async () => { - container.querySelector('.wt-upnext')?.click(); - await new Promise((resolve) => setTimeout(resolve, 0)); - }); - await waitFor(() => { - expect(container.textContent).toContain('Support'); - expect(container.textContent).toContain('changed.ts'); - expect(container.textContent).toContain('added.ts'); - expect(container.textContent).not.toContain('Regenerate walkthrough'); - expect(container.textContent).not.toContain('Changed after the walkthrough was generated.'); - expect(container.textContent).not.toContain('unchanged.tsChanged'); - }); -}); - -test('reload forces a new generated walkthrough when local files changed', async () => { - const previousFile = createChangedFile('src/app.ts', { - fingerprint: 'before', - }); - const refreshedFile = createChangedFile('src/app.ts', { - fingerprint: 'after', - }); - const previousState = { - ...repositoryState, - files: [previousFile], - } satisfies RepositoryState; - const refreshedState = { - ...repositoryState, - files: [refreshedFile], - } satisfies RepositoryState; - const getNarrativeWalkthrough = vi.fn(async () => ({ - status: 'ready' as const, - walkthrough: createNarrativeWalkthroughFixture([ - { added: 1, path: refreshedFile.path, status: 'modified' }, - ]), - })); - - writeReloadSelection(previousState, previousFile.path); - window.codiff = createCodiffMock({ - getLaunchOptions: vi.fn(async () => ({ - repositoryPathProvided: true, - walkthrough: true, - })), - getNarrativeWalkthrough, - getRepositoryState: vi.fn(async () => refreshedState), - }); - - await using app = await renderReact(); - - await waitFor(() => { - expect(getNarrativeWalkthrough).toHaveBeenCalledWith(refreshedState.source, { - force: true, - }); - expect(app.container.querySelector('.wt-stop-block')).not.toBeNull(); - }); -}); - -test('tree sidebar subtly mutes files currently marked viewed', async () => { - const viewedFile = createChangedFile('src/viewed.ts', { - fingerprint: 'viewed-current', - }); - const staleViewedFile = createChangedFile('src/stale.ts', { - fingerprint: 'stale-current', - }); - const nextState = { - ...repositoryState, - files: [viewedFile, staleViewedFile], - } satisfies RepositoryState; - - window.localStorage.setItem( - 'codiff:viewed:/repo', - JSON.stringify({ - [staleViewedFile.path]: 'stale-previous', - [viewedFile.path]: viewedFile.fingerprint, - }), - ); - window.codiff = createCodiffMock({ - getRepositoryState: vi.fn(async () => nextState), - }); - - const container = document.createElement('div'); - document.body.append(container); - let root: Root | null = null; - - await using _resource = { - async [Symbol.asyncDispose]() { - if (root) { - await act(async () => root?.unmount()); - } - container.remove(); - }, - }; - await act(async () => { - root = createRoot(container); - root.render(); - }); - await waitFor(() => { - const shadowRoot = container.querySelector('file-tree-container')?.shadowRoot; - const styleText = - shadowRoot?.querySelector('style[data-codiff-viewed-rows]')?.textContent ?? ''; - expect(styleText).toContain('[data-item-path="src/viewed.ts"]'); - expect(styleText).not.toContain('[data-item-path="src/stale.ts"]'); - expect(styleText).not.toContain('color-mix(in srgb, var(--viewed)'); - expect(styleText).toContain( - "[data-item-path=\"src/viewed.ts\"] > [data-item-section='icon'] > :where(:not([data-icon-name='file-tree-icon-chevron']))", - ); - expect(styleText).toContain("> [data-item-section='content']"); - expect(styleText).toContain('color: var(--muted)'); - }); -}); - -test('before unload saves the current source and selected file for any reload trigger', async () => { - const changedFile = createChangedFile('src/app.ts'); - const source = { ref: 'abc1234', type: 'commit' } satisfies ReviewSource; - const nextState = { - ...repositoryState, - files: [changedFile], - source, - } satisfies RepositoryState; - - window.codiff = createCodiffMock({ - getRepositoryState: vi.fn(async () => nextState), - }); - - const container = document.createElement('div'); - document.body.append(container); - let root: Root | null = null; - - await using _resource = { - async [Symbol.asyncDispose]() { - if (root) { - await act(async () => root?.unmount()); - } - container.remove(); - }, - }; - await act(async () => { - root = createRoot(container); - root.render(); - }); - await waitFor(() => { - expect(container.querySelector('.loading')).toBeNull(); - }); - window.dispatchEvent(new Event('beforeunload')); - const selection = consumeReloadSelection(); - expect(selection?.source).toEqual(source); - expect(getReloadSelectionPath(selection, nextState)).toBe(changedFile.path); -}); - -test('Mod+K opens the selected file in the editor', async () => { - const changedFile = createChangedFile('src/app.ts'); - await using app = await renderAppForOpenFileShortcut(changedFile); - - await act(async () => { - dispatchModK(); - await new Promise((resolve) => setTimeout(resolve, 0)); - }); - expect(app.openFile).toHaveBeenCalledWith(changedFile.path); -}); - -test('Mod+K does not open deleted files', async () => { - const deletedFile = { - ...createChangedFile('src/removed.ts'), - status: 'deleted', - } satisfies ChangedFile; - await using app = await renderAppForOpenFileShortcut(deletedFile); - - await act(async () => { - dispatchModK(); - await new Promise((resolve) => setTimeout(resolve, 0)); - }); - expect(app.openFile).not.toHaveBeenCalled(); -}); - -test('commit messages use the shared source description presentation', async () => { - const changedFile = createChangedFile('src/app.ts'); - const source = { ref: 'abc1234', type: 'commit' } satisfies ReviewSource; - const commitMetadata = createCommitMetadataFixture('## Details\n\nDetailed **commit** body.'); - const historyAvatarUrl = 'https://avatars.githubusercontent.com/u/1?v=4'; - - window.codiff = createCodiffMock({ - getRepositoryHistory: vi.fn(async () => ({ - entries: [ - { - author: commitMetadata.author.name, - committedAt: Date.parse(commitMetadata.author.date), - gravatarUrl: historyAvatarUrl, - parents: commitMetadata.parents, - ref: commitMetadata.ref, - subject: commitMetadata.subject, - }, - ], - root: '/repo', - })), - getRepositoryState: vi.fn(async () => ({ - ...repositoryState, - commitMetadata, - files: [changedFile], - source, - })), - }); - - const container = document.createElement('div'); - document.body.append(container); - let root: Root | null = null; - - await using _resource = { - async [Symbol.asyncDispose]() { - if (root) { - await act(async () => root?.unmount()); - } - container.remove(); - }, - }; - await act(async () => { - root = createRoot(container); - root.render(); - }); - await waitFor(() => { - expect(container.querySelector('.loading')).toBeNull(); - expect(container.querySelector('.codiff-source-description-header')).not.toBeNull(); - }); - await waitFor(() => { - expect(container.querySelector('.source-description-markdown')?.textContent).toContain( - 'Detailed commit body.', - ); - }); - const header = container.querySelector('.codiff-source-description-header'); - expect(header?.querySelector('.source-description-title')?.textContent).toBe( - commitMetadata.subject, - ); - expect(container.querySelector('.source-description-author-header')?.textContent).toContain( - commitMetadata.author.name, - ); - expect( - container.querySelector('.source-description-comment > .avatar.medium')?.src, - ).toBe(historyAvatarUrl); - expect(container.querySelector('[aria-label="Preview commit message"]')).not.toBeNull(); - expect(container.querySelector('.codiff-commit-details-header')).toBeNull(); - expect(container.querySelector('.commit-details-panel')).toBeNull(); - expect(container.textContent).not.toContain('Verified signature'); - expect(container.textContent).not.toContain('Co-authored-by'); -}); - -test('bodyless commits still render the author and profile image', async () => { - const commitMetadata = createCommitMetadataFixture(''); - const source = { - ref: commitMetadata.ref, - type: 'commit', - } satisfies ReviewSource; - - window.codiff = createCodiffMock({ - getRepositoryState: vi.fn(async () => ({ - ...repositoryState, - commitMetadata, - files: [createChangedFile('src/app.ts')], - source, - })), - }); - - await using app = await renderReact(); - - await waitFor(() => { - expect(app.container.querySelector('.source-description-author-header')?.textContent).toContain( - commitMetadata.author.name, - ); - }); - expect( - app.container.querySelector('.source-description-comment > .avatar.medium') - ?.src, - ).toBe(commitMetadata.author.gravatarUrl); - expect( - app.container.querySelector('.source-description-author-header.without-description'), - ).not.toBeNull(); - expect(app.container.querySelector('.source-description-markdown')).toBeNull(); - expect( - app.container - .querySelector('.codiff-source-description-header') - ?.querySelector('.source-description-title')?.textContent, - ).toBe(commitMetadata.subject); -}); - -test('pull request descriptions render as provider-aware Markdown source context', async () => { - const changedFile = createChangedFile('src/app.ts'); - const cases: ReadonlyArray<{ - label: string; - source: Extract; - }> = [ - { - label: 'PR description', - source: { - author: { - avatarUrl: 'https://avatars.githubusercontent.com/u/1?v=4', - login: 'octocat', - url: 'https://github.com/octocat', - }, - description: '## Intent\n\nShip **review** context.', - number: 12, - provider: 'github', - type: 'pull-request', - url: 'https://github.com/nkzw-tech/codiff/pull/12', - }, - }, - { - label: 'MR description', - source: { - description: '## Intent\n\nShip **review** context.', - number: 13, - provider: 'gitlab', - type: 'pull-request', - url: 'https://gitlab.example.com/group/project/-/merge_requests/13', - }, - }, - { - label: 'Description', - source: { - description: '## Intent\n\nShip **review** context.', - number: 14, - type: 'pull-request', - url: 'https://example.com/reviews/14', - }, - }, - ]; - - for (const { label, source } of cases) { - window.codiff = createCodiffMock({ - getRepositoryState: vi.fn(async () => ({ - ...repositoryState, - files: [changedFile], - source, - })), - }); - await using app = await renderReact(); - - await waitFor(() => { - expect(app.container.querySelector('.codiff-source-description-header')).not.toBeNull(); - }); - const header = app.container.querySelector('.codiff-source-description-header'); - const body = app.container.querySelector('.source-description-markdown'); - expect(body?.textContent).toContain('Intent'); - expect(body?.textContent).toContain('Ship review context.'); - expect(header?.querySelector('.codiff-file-path')?.textContent).toBe(label); - expect(header?.querySelector('.source-description-title')).toBeNull(); - if (source.author) { - expect(header?.querySelector('.source-description-author')).toBeNull(); - expect( - app.container.querySelector('.source-description-author-header')?.textContent, - ).toContain(source.author.name ?? `@${source.author.login}`); - } else { - expect(header?.querySelector('.source-description-author')).toBeNull(); - } - const toggle = header?.querySelector('button.codiff-header-toggle'); - expect(toggle).not.toBeNull(); - expect(toggle?.getAttribute('aria-expanded')).toBe('true'); - expect(toggle?.type).toBe('button'); - } -}); - -test('pull request description collapse button toggles the markdown body', async () => { - const source = { - description: '## Intent\n\nShip **review** context.', - number: 12, - provider: 'github', - type: 'pull-request', - url: 'https://github.com/nkzw-tech/codiff/pull/12', - } satisfies ReviewSource; - - window.codiff = createCodiffMock({ - getRepositoryState: vi.fn(async () => ({ - ...repositoryState, - files: [createChangedFile('src/app.ts')], - source, - })), - }); - - await using app = await renderReact(); - - await waitFor(() => { - expect(app.container.querySelector('.source-description-markdown')).not.toBeNull(); - }); - const repositoryLabel = app.container.querySelector('.review-top-bar-repository'); - const sourceLink = app.container.querySelector('.review-top-bar-source'); - expect(repositoryLabel).not.toBeNull(); - expect(sourceLink?.href).toBe(source.url); - expect(sourceLink?.textContent).toBe('PR #12'); - expect(sourceLink?.querySelector('svg')).not.toBeNull(); - let toggle = app.container.querySelector('button.codiff-header-toggle'); - expect(toggle?.getAttribute('aria-expanded')).toBe('true'); - expect(app.container.querySelector('.source-description-markdown')?.textContent).toContain( - 'Ship review context.', - ); - await act(async () => { - toggle?.click(); - }); - await waitFor(() => { - toggle = app.container.querySelector('button.codiff-header-toggle'); - expect(toggle?.getAttribute('aria-expanded')).toBe('false'); - expect(app.container.querySelector('.source-description-markdown')).toBeNull(); - }); - await act(async () => { - toggle?.click(); - }); - await waitFor(() => { - expect( - app.container - .querySelector('button.codiff-header-toggle') - ?.getAttribute('aria-expanded'), - ).toBe('true'); - expect(app.container.querySelector('.source-description-markdown')?.textContent).toContain( - 'Ship review context.', - ); - }); -}); - -test('missing pull request descriptions do not render placeholder source context', async () => { - window.codiff = createCodiffMock({ - getRepositoryState: vi.fn(async () => ({ - ...repositoryState, - files: [createChangedFile('src/app.ts')], - source: { - description: ' ', - number: 12, - provider: 'github', - type: 'pull-request', - url: 'https://github.com/nkzw-tech/codiff/pull/12', - } satisfies ReviewSource, - })), - }); - - await using app = await renderReact(); - - await waitFor(() => { - expect(app.container.querySelector('.codiff-file-header')).not.toBeNull(); - }); - expect(app.container.querySelector('.codiff-source-description-header')).toBeNull(); - expect(app.container.textContent).not.toContain('PR description'); -}); - -test('title-only pull request source context renders as a collapsed static header', async () => { - const cases: ReadonlyArray> = [ - { - description: ' ', - number: 12, - provider: 'github', - title: 'Title without a body', - type: 'pull-request', - url: 'https://github.com/nkzw-tech/codiff/pull/12', - }, - { - number: 13, - provider: 'gitlab', - title: 'Merge request title only', - type: 'pull-request', - url: 'https://gitlab.example.com/group/project/-/merge_requests/13', - }, - ]; - - for (const source of cases) { - window.codiff = createCodiffMock({ - getRepositoryState: vi.fn(async () => ({ - ...repositoryState, - files: [createChangedFile('src/app.ts')], - source, - })), - }); - await using app = await renderReact(); - - await waitFor(() => { - expect(app.container.querySelector('.codiff-source-description-header')).not.toBeNull(); - }); - const header = app.container.querySelector('.codiff-source-description-header'); - expect(header?.classList.contains('collapsed')).toBe(true); - expect(header?.querySelector('.source-description-title')?.textContent).toBe(source.title); - expect(header?.querySelector('.codiff-header-toggle-static')).not.toBeNull(); - expect(header?.querySelector('button.codiff-header-toggle')).toBeNull(); - expect(header?.querySelector('.codiff-chevron-box')).toBeNull(); - expect(app.container.querySelector('.source-description-markdown')).toBeNull(); - } -}); - -test('narrative walkthrough stops show pull request descriptions once', async () => { - const changedFile = createChangedFile('src/app.ts'); - const source = { - description: '## Summary\n\nKeep the PR context visible.', - number: 12, - provider: 'github', - title: 'Keep context visible in walkthrough', - type: 'pull-request', - url: 'https://github.com/nkzw-tech/codiff/pull/12', - } satisfies ReviewSource; - const narrativeWalkthrough = { - agent: 'codex', - chapters: [ - { - blurb: 'Review the implementation.', - icon: 'gear', - id: 'impl', - stops: [ - { - added: 1, - deleted: 1, - hunkIds: ['src/app.ts:unstaged:h1'], - hunks: [ - { - added: 1, - anchor: { - display: 'src/app.ts', - sectionId: 'src/app.ts:unstaged', - side: 'both', - }, - deleted: 1, - id: 'src/app.ts:unstaged:h1', - path: 'src/app.ts', - status: 'modified', - }, - ], - id: 's1', - importance: 'critical', - prose: 'Review this file.', - title: 'Implementation path', - }, - ], - title: 'Implementation', - }, - ], - focus: 'Focus.', - generatedAt: '2026-06-07T00:00:00.000Z', - kind: 'narrative', - repo: { branch: 'main', root: '/repo' }, - source, - support: [], - title: 'Narrative', - version: 4, - } satisfies NarrativeWalkthrough; - - window.codiff = createCodiffMock({ - getLaunchOptions: vi.fn(async () => ({ - repositoryPathProvided: true, - source, - walkthrough: true, - walkthroughFile: '/tmp/walkthrough.json', - })), - getNarrativeWalkthrough: vi.fn(async () => ({ - status: 'ready' as const, - walkthrough: narrativeWalkthrough, - })), - getRepositoryState: vi.fn(async () => ({ - ...repositoryState, - files: [changedFile], - source, - })), - }); - - await using app = await renderReact(); - - await waitFor(() => { - expect(app.container.querySelector('.wt-stop-block')).not.toBeNull(); - }); - expect(app.container.querySelectorAll('.codiff-source-description-header')).toHaveLength(1); - expect(app.container.querySelectorAll('.source-description-markdown')).toHaveLength(1); - expect(app.container.textContent).toContain('Keep context visible in walkthrough'); - expect(app.container.textContent).toContain('Keep the PR context visible.'); -}); - -test('narrative walkthrough stops do not repeat commit details', async () => { - const changedFile = createChangedFile('src/app.ts'); - const source = { ref: 'abc1234', type: 'commit' } satisfies ReviewSource; - const commitMetadata = { - author: { - date: '2026-01-01T12:00:00Z', - email: 'author@example.com', - name: 'Author', - }, - body: 'Detailed commit body.', - committer: { - date: '2026-01-01T13:00:00Z', - email: 'committer@example.com', - name: 'Committer', - }, - files: [ - { - additions: 1, - binary: false, - deletions: 1, - path: 'src/app.ts', - status: 'modified' as const, - }, - ], - parents: ['parent-sha'], - ref: 'abc1234', - refs: ['main'], - shortRef: 'abc1234', - signature: { - status: 'N', - }, - stats: { - additions: 1, - binaryFiles: 0, - deletions: 1, - files: 1, - renamedFiles: 0, - }, - subject: 'Commit subject', - trailers: [], - } satisfies CommitMetadata; - const narrativeWalkthrough = { - agent: 'codex', - chapters: [ - { - blurb: 'Review the implementation.', - icon: 'gear', - id: 'impl', - stops: [ - { - added: 1, - deleted: 1, - hunkIds: ['src/app.ts:unstaged:h1'], - hunks: [ - { - added: 1, - anchor: { - display: 'src/app.ts', - sectionId: 'src/app.ts:unstaged', - side: 'both', - }, - deleted: 1, - id: 'src/app.ts:unstaged:h1', - path: 'src/app.ts', - status: 'modified', - }, - ], - id: 's1', - importance: 'critical', - prose: 'Review this file without repeating the commit header.', - title: 'Implementation path', - }, - ], - title: 'Implementation', - }, - ], - focus: 'Focus.', - generatedAt: '2026-06-07T00:00:00.000Z', - kind: 'narrative', - repo: { branch: 'main', root: '/repo' }, - source, - support: [], - title: 'Narrative', - version: 4, - } satisfies NarrativeWalkthrough; - - window.codiff = createCodiffMock({ - getLaunchOptions: vi.fn(async () => ({ - repositoryPathProvided: true, - source, - walkthrough: true, - walkthroughFile: '/tmp/walkthrough.json', - })), - getNarrativeWalkthrough: vi.fn(async () => ({ - status: 'ready' as const, - walkthrough: narrativeWalkthrough, - })), - getRepositoryState: vi.fn(async () => ({ - ...repositoryState, - commitMetadata, - files: [changedFile], - source, - })), - }); - - const container = document.createElement('div'); - document.body.append(container); - let root: Root | null = null; - - await using _resource = { - async [Symbol.asyncDispose]() { - if (root) { - await act(async () => root?.unmount()); - } - container.remove(); - }, - }; - await act(async () => { - root = createRoot(container); - root.render(); - }); - await waitFor(() => { - expect(container.querySelector('.loading')).toBeNull(); - expect(container.querySelector('.wt-stop-block')).not.toBeNull(); - }); - expect(container.querySelector('.codiff-source-description-header')).toBeNull(); - expect(container.querySelector('.source-description-markdown')).toBeNull(); - expect(container.querySelector('.wt-stage-title')?.textContent).toContain('Implementation path'); -}); - -test('a walkthrough file loads even without the walkthrough launch flag', async () => { - const source = { ref: 'abc1234', type: 'commit' } satisfies ReviewSource; - const narrativeWalkthrough = { - agent: 'claude', - chapters: [ - { - blurb: 'Review the implementation.', - icon: 'gear', - id: 'impl', - stops: [ - { - added: 1, - deleted: 1, - hunkIds: ['src/app.ts:unstaged:h1'], - hunks: [ - { - added: 1, - anchor: { - display: 'src/app.ts', - sectionId: 'src/app.ts:unstaged', - side: 'both', - }, - deleted: 1, - id: 'src/app.ts:unstaged:h1', - path: 'src/app.ts', - status: 'modified', - }, - ], - id: 'implementation-path', - importance: 'critical', - prose: 'Review this file.', - summary: 'The implementation path.', - title: 'Implementation path', - }, - ], - title: 'Implementation', - }, - ], - focus: 'Focus.', - generatedAt: '2026-06-07T00:00:00.000Z', - kind: 'narrative', - repo: { branch: 'main', root: '/repo' }, - source, - support: [], - title: 'Narrative', - version: 4, - } satisfies NarrativeWalkthrough; - - const getNarrativeWalkthrough = vi.fn(async () => ({ - status: 'ready' as const, - walkthrough: narrativeWalkthrough, - })); - window.codiff = createCodiffMock({ - getLaunchOptions: vi.fn(async () => ({ - repositoryPathProvided: true, - source, - walkthrough: false, - walkthroughFile: '/tmp/walkthrough.json', - })), - getNarrativeWalkthrough, - getRepositoryState: vi.fn(async () => ({ - ...repositoryState, - files: [createChangedFile('src/app.ts')], - source, - })), - }); - - const container = document.createElement('div'); - document.body.append(container); - let root: Root | null = null; - - await using _resource = { - async [Symbol.asyncDispose]() { - if (root) { - await act(async () => root?.unmount()); - } - container.remove(); - }, - }; - await act(async () => { - root = createRoot(container); - root.render(); - }); - await waitFor(() => { - expect(container.querySelector('.wt-stop-block')).not.toBeNull(); - }); - expect(getNarrativeWalkthrough).toHaveBeenCalledTimes(1); - expect(container.querySelector('.walkthrough-error')).toBeNull(); -}); - -test('plan mode opens the Markdown editor without loading repository state', async () => { - const getRepositoryState = vi.fn(async () => repositoryState); - const completePlan = vi.fn(async (_review: PlanReview, _status: 'closed' | 'done') => {}); - const markPlanReady = vi.fn(async () => {}); - const sharePlan = vi.fn(async (_review: PlanReview) => ({ - status: 'uploaded' as const, - url: 'https://codiff.dev/p/shared-plan', - })); - const originalScrollIntoView = HTMLElement.prototype.scrollIntoView; - const scrollIntoView = vi.fn(); - HTMLElement.prototype.scrollIntoView = scrollIntoView; - const storedReview = { - document: { - id: 'stale-plan-id', - path: '/tmp/old-plan.md', - version: 'stale-version', - }, - threads: [ - { - anchor: { - block: { - fingerprint: 'heading-fingerprint', - path: [1], - text: 'Execute this plan', - type: 'heading', - }, - kind: 'block', - version: 1, - }, - createdAt: '2026-06-24T00:00:00.000Z', - createdBy: { - email: 'reviewer@example.com', - id: 'reviewer@example.com', - name: 'Reviewer', - }, - id: 'thread-1', - messages: [ - { - author: { - email: 'reviewer@example.com', - id: 'reviewer@example.com', - name: 'Reviewer', - }, - body: 'Keep the rollout steps explicit.', - createdAt: '2026-06-24T00:00:00.000Z', - id: 'message-1', - updatedAt: '2026-06-24T00:00:00.000Z', - }, - ], - status: 'open', - updatedAt: '2026-06-24T00:00:00.000Z', - }, - { - anchor: { - block: { - fingerprint: 'list-item-fingerprint', - path: [2, 0], - text: 'First', - type: 'listitem', - }, - kind: 'block', - version: 1, - }, - createdAt: '2026-06-24T00:01:00.000Z', - createdBy: { - email: 'reviewer@example.com', - id: 'reviewer@example.com', - name: 'Reviewer', - }, - id: 'empty-thread', - messages: [ - { - author: { - email: 'reviewer@example.com', - id: 'reviewer@example.com', - name: 'Reviewer', - }, - body: ' ', - createdAt: '2026-06-24T00:01:00.000Z', - id: 'empty-message', - updatedAt: '2026-06-24T00:01:00.000Z', - }, - ], - status: 'open', - updatedAt: '2026-06-24T00:01:00.000Z', - }, - ], - version: 1, - } satisfies PlanReview; - window.codiff = createCodiffMock({ - completePlan, - getFeatureFlags: vi.fn(async () => ({ - planSharing: true, - walkthroughSharing: false, - })), - getGitIdentity: vi.fn(async () => ({ - email: 'current-user@example.com', - gravatarUrl: 'https://example.com/current-user.png', - name: 'Current User', - })), - getLaunchOptions: vi.fn(async () => ({ - planFile: '/tmp/plan.md', - planResultFile: '/tmp/result.json', - repositoryPathProvided: true, - walkthrough: false, - })), - getMarkdownDocument: vi.fn(async () => ({ - content: - '---\ntitle: Execute this plan\ndraft: true\n---\n\n# Execute this plan\n\n- First\n- Second\n\n```sh\nvp test\n```\n', - id: 'plan:/tmp/plan.md', - kind: 'plan' as const, - path: '/tmp/plan.md', - version: 'plan-version', - })), - getPlanReview: vi.fn(async () => storedReview), - getRepositoryState, - markPlanReady, - sharePlan, - }); - - const container = document.createElement('div'); - document.body.append(container); - let root: Root | null = null; - - await using _resource = { - async [Symbol.asyncDispose]() { - if (root) { - await act(async () => root?.unmount()); - } - HTMLElement.prototype.scrollIntoView = originalScrollIntoView; - container.remove(); - }, - }; - await act(async () => { - root = createRoot(container); - root.render(); - }); - await waitFor(() => { - expect(container.querySelector('.plan-shell')).not.toBeNull(); - }); - expect(getRepositoryState).not.toHaveBeenCalled(); - expect(markPlanReady).toHaveBeenCalledTimes(1); - expect(container.querySelector('.plan-title')?.textContent).toContain('plan.md'); - await waitFor(() => { - expect(container.querySelector('[data-editor-type="frontmatter"]')).not.toBeNull(); - }); - expect(container.querySelector('.mdx-editor-content h2')).toBeNull(); - expect(container.querySelectorAll('.mdx-editor-content ul > li')).toHaveLength(2); - await waitFor(() => { - expect(container.querySelector('.cm-content')).not.toBeNull(); - }); - expect(container.querySelector('.cm-gutters')).toBeNull(); - expect(container.querySelector('.cm-content')?.textContent).toContain('vp test'); - await waitFor(() => { - expect(container.querySelector('.plan-comment-thread')?.textContent).toContain( - 'Keep the rollout steps explicit.', - ); - }); - const planCommentAvatar = container.querySelector('.plan-comment-thread .avatar.medium'); - expect(planCommentAvatar?.tagName).toBe('SPAN'); - expect(planCommentAvatar?.textContent).toBe('RE'); - const commentTargetButton = container.querySelector('.plan-comment-target'); - expect(commentTargetButton?.textContent).toBe('Heading · Execute this plan'); - expect(commentTargetButton?.disabled).toBe(false); - await act(async () => { - commentTargetButton?.click(); - }); - expect(scrollIntoView).toHaveBeenCalledWith({ - behavior: 'smooth', - block: 'center', - }); - scrollIntoView.mockClear(); - expect(container.querySelector('[data-mdx-annotation-block~="thread-1"]')).not.toBeNull(); - expect(container.querySelectorAll('li[data-mdx-comment-block-type="listitem"]')).toHaveLength(2); - const planWorkspace = container.querySelector('.plan-workspace'); - const editorContent = container.querySelector('.mdx-editor-content'); - const commentBlock = container.querySelectorAll( - 'li[data-mdx-comment-block-type="listitem"]', - )[1]; - expect(planWorkspace).not.toBeNull(); - expect(editorContent).not.toBeNull(); - expect(commentBlock).toBeDefined(); - planWorkspace!.getBoundingClientRect = () => ({ - bottom: 700, - height: 650, - left: 100, - right: 1100, - toJSON: () => {}, - top: 50, - width: 1000, - x: 100, - y: 50, - }); - editorContent!.style.paddingRight = '24px'; - editorContent!.getBoundingClientRect = () => ({ - bottom: 650, - height: 550, - left: 120, - right: 1020, - toJSON: () => {}, - top: 100, - width: 900, - x: 120, - y: 100, - }); - commentBlock!.getBoundingClientRect = () => ({ - bottom: 224, - height: 24, - left: 144, - right: 996, - toJSON: () => {}, - top: 200, - width: 852, - x: 144, - y: 200, - }); - await act(async () => { - commentBlock!.dispatchEvent(new MouseEvent('pointermove', { bubbles: true })); - }); - await waitFor(() => { - expect(container.querySelector('.plan-comment-affordance')).not.toBeNull(); - }); - const commentAffordance = container.querySelector('.plan-comment-affordance')!; - expect(commentAffordance.dataset.mdxCommentButton).toBe(''); - expect(commentAffordance.style.getPropertyValue('--plan-comment-left')).toBe('896px'); - expect(commentAffordance.style.getPropertyValue('--plan-comment-width')).toBe('48px'); - expect(commentAffordance.querySelector(':scope > .plan-comment-add')).not.toBeNull(); - await act(async () => { - editorContent!.dispatchEvent( - new MouseEvent('pointerleave', { relatedTarget: commentAffordance }), - ); - }); - expect(container.querySelector('.plan-comment-affordance')).not.toBeNull(); - await act(async () => { - commentAffordance.dispatchEvent( - new MouseEvent('pointerout', { - bubbles: true, - relatedTarget: editorContent, - }), - ); - }); - expect(container.querySelector('.plan-comment-affordance')).not.toBeNull(); - await act(async () => { - commentAffordance.dispatchEvent( - new MouseEvent('pointerout', { - bubbles: true, - relatedTarget: planWorkspace, - }), - ); - }); - expect(container.querySelector('.plan-comment-affordance')).toBeNull(); - await act(async () => { - container - .querySelectorAll('li[data-mdx-comment-block-type="listitem"]')[0]! - .dispatchEvent(new MouseEvent('pointermove', { bubbles: true })); - commentBlock!.dispatchEvent(new MouseEvent('pointermove', { bubbles: true })); - }); - const addCommentButton = container.querySelector('.plan-comment-add'); - expect(addCommentButton).not.toBeNull(); - await act(async () => { - addCommentButton!.click(); - }); - const activeComment = container.querySelector('.plan-comment-thread.active'); - expect(activeComment).not.toBeNull(); - expect(activeComment!.closest('.plan-comment-position')?.style.top).not.toBe('0px'); - const activeCommentDelete = - activeComment!.querySelector('.review-comment-delete'); - expect(activeCommentDelete).not.toBeNull(); - await act(async () => { - activeCommentDelete!.click(); - }); - const commentRail = container.querySelector('.plan-comment-rail-scroll'); - const commentPosition = container.querySelector('.plan-comment-position'); - const annotatedBlock = container.querySelector( - '[data-mdx-annotation-block~="thread-1"]', - ); - expect(commentRail).not.toBeNull(); - expect(commentPosition).not.toBeNull(); - expect(annotatedBlock).not.toBeNull(); - commentRail!.style.overflowY = 'auto'; - commentRail!.getBoundingClientRect = () => ({ - bottom: 300, - height: 200, - left: 0, - right: 400, - toJSON: () => {}, - top: 100, - width: 400, - x: 0, - y: 100, - }); - commentPosition!.getBoundingClientRect = () => ({ - bottom: 360, - height: 100, - left: 0, - right: 400, - toJSON: () => {}, - top: 260, - width: 400, - x: 0, - y: 260, - }); - const scrollCommentRail = vi.fn(); - commentRail!.scrollTo = scrollCommentRail; - await act(async () => { - annotatedBlock!.click(); - }); - await waitFor(() => { - expect(scrollCommentRail).toHaveBeenCalledWith({ - behavior: 'smooth', - top: 68, - }); - }); - expect(scrollIntoView).not.toHaveBeenCalled(); - const deleteButtons = container.querySelectorAll('.review-comment-delete'); - expect(deleteButtons).toHaveLength(2); - await act(async () => { - deleteButtons[1]!.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true })); - deleteButtons[1]!.click(); - }); - expect(scrollIntoView).not.toHaveBeenCalled(); - const shareButton = container.querySelector('.plan-share-button'); - expect(shareButton?.textContent).toContain('Share'); - await act(async () => { - shareButton?.click(); - }); - await waitFor(() => { - expect(sharePlan).toHaveBeenCalledTimes(1); - }); - expect(sharePlan.mock.calls[0]?.[0].threads).toHaveLength(1); - expect(shareButton?.textContent).toContain('Copied'); - await act(async () => { - container.querySelector('.plan-done-button')?.click(); - }); - await waitFor(() => { - expect(completePlan).toHaveBeenCalledTimes(1); - }); - expect(completePlan).toHaveBeenCalledWith( - expect.objectContaining({ - document: { - id: 'plan:/tmp/plan.md', - path: '/tmp/plan.md', - version: 'plan-version', - }, - threads: [ - expect.objectContaining({ - id: 'thread-1', - messages: [ - expect.objectContaining({ - body: 'Keep the rollout steps explicit.', - }), - ], - }), - ], - version: 1, - }), - 'done', - ); - expect(completePlan.mock.calls[0]?.[0].threads).toHaveLength(1); -}); - -test('plan mode resolves stored comments whose anchors were already removed', async () => { - const savePlanReview = vi.fn(async (review: PlanReview) => review); - const storedReview = { - document: { - id: 'plan:/tmp/plan.md', - path: '/tmp/plan.md', - version: 'old-plan-version', - }, - threads: [ - { - anchor: { - block: { - fingerprint: 'removed-heading-fingerprint', - path: [99], - text: 'Removed heading', - type: 'heading', - }, - kind: 'block', - version: 1, - }, - createdAt: '2026-06-24T00:00:00.000Z', - createdBy: { - email: 'reviewer@example.com', - id: 'reviewer@example.com', - name: 'Reviewer', - }, - id: 'detached-thread', - messages: [ - { - author: { - email: 'reviewer@example.com', - id: 'reviewer@example.com', - name: 'Reviewer', - }, - body: 'Keep this comment as history.', - createdAt: '2026-06-24T00:00:00.000Z', - id: 'detached-message', - updatedAt: '2026-06-24T00:00:00.000Z', - }, - ], - status: 'open', - updatedAt: '2026-06-24T00:00:00.000Z', - }, - ], - version: 1, - } satisfies PlanReview; - window.codiff = createCodiffMock({ - getLaunchOptions: vi.fn(async () => ({ - planFile: '/tmp/plan.md', - planResultFile: '/tmp/result.json', - repositoryPathProvided: true, - walkthrough: false, - })), - getMarkdownDocument: vi.fn(async () => ({ - content: '# Current plan\n', - id: 'plan:/tmp/plan.md', - kind: 'plan' as const, - path: '/tmp/plan.md', - version: 'plan-version', - })), - getPlanReview: vi.fn(async () => storedReview), - savePlanReview, - }); - const container = document.createElement('div'); - document.body.append(container); - const root = createRoot(container); - - await using _resource = { - async [Symbol.asyncDispose]() { - await act(async () => root.unmount()); - container.remove(); - }, - }; - await act(async () => { - root.render(); - }); - await waitFor(() => { - expect(savePlanReview).toHaveBeenCalledWith( - expect.objectContaining({ - threads: [ - expect.objectContaining({ - id: 'detached-thread', - resolution: expect.objectContaining({ - reason: 'anchor-removed', - resolvedAt: expect.any(String), - }), - status: 'resolved', - }), - ], - }), - ); - }); - const resolvedSection = container.querySelector('.plan-resolved-comments'); - expect(resolvedSection?.open).toBe(false); - expect(resolvedSection?.querySelector('summary')?.textContent).toBe('Resolved comments (1)'); - expect(container.querySelector('.plan-comment-thread.resolved')?.textContent).toContain( - 'Resolved after target removal', - ); - expect(container.querySelector('[data-mdx-annotation-block~="detached-thread"]')).toBeNull(); - await act(async () => { - resolvedSection!.open = true; - resolvedSection!.dispatchEvent(new Event('toggle')); - }); - await act(async () => { - resolvedSection?.querySelector('.review-comment-delete')?.click(); - }); - await waitFor(() => { - expect(container.querySelector('.plan-resolved-comments')).toBeNull(); - expect(savePlanReview).toHaveBeenLastCalledWith( - expect.objectContaining({ - threads: [], - }), - ); - }); -}); - -test('plan mode keeps comments open when their anchors are removed during the current review', async () => { - let publishMarkdownChange: - | ((change: { - deleted: boolean; - document: { - content: string; - id: string; - kind: 'plan'; - path: string; - version: string; - }; - id: string; - }) => void) - | null = null; - const savePlanReview = vi.fn(async (review: PlanReview) => review); - const storedReview = { - document: { - id: 'plan:/tmp/plan.md', - path: '/tmp/plan.md', - version: 'plan-version', - }, - threads: [ - { - anchor: { - block: { - fingerprint: 'heading-fingerprint', - path: [0], - text: 'Current plan', - type: 'heading', - }, - kind: 'block', - version: 1, - }, - createdAt: '2026-06-24T00:00:00.000Z', - createdBy: { - email: 'reviewer@example.com', - id: 'reviewer@example.com', - name: 'Reviewer', - }, - id: 'live-thread', - messages: [ - { - author: { - email: 'reviewer@example.com', - id: 'reviewer@example.com', - name: 'Reviewer', - }, - body: 'The agent still needs to process this.', - createdAt: '2026-06-24T00:00:00.000Z', - id: 'live-message', - updatedAt: '2026-06-24T00:00:00.000Z', - }, - ], - status: 'open', - updatedAt: '2026-06-24T00:00:00.000Z', - }, - ], - version: 1, - } satisfies PlanReview; - window.codiff = createCodiffMock({ - getLaunchOptions: vi.fn(async () => ({ - planFile: '/tmp/plan.md', - planResultFile: '/tmp/result.json', - repositoryPathProvided: true, - walkthrough: false, - })), - getMarkdownDocument: vi.fn(async () => ({ - content: '# Current plan\n', - id: 'plan:/tmp/plan.md', - kind: 'plan' as const, - path: '/tmp/plan.md', - version: 'plan-version', - })), - getPlanReview: vi.fn(async () => storedReview), - onMarkdownDocumentChanged: vi.fn((callback) => { - publishMarkdownChange = callback; - return () => { - publishMarkdownChange = null; - }; - }), - savePlanReview, - }); - const container = document.createElement('div'); - document.body.append(container); - const root = createRoot(container); - - await using _resource = { - async [Symbol.asyncDispose]() { - await act(async () => root.unmount()); - container.remove(); - }, - }; - await act(async () => { - root.render(); - }); - await waitFor(() => { - expect(container.querySelector('[data-mdx-annotation-block~="live-thread"]')).not.toBeNull(); - expect(publishMarkdownChange).not.toBeNull(); - }); - savePlanReview.mockClear(); - await act(async () => { - publishMarkdownChange?.({ - deleted: false, - document: { - content: '# Replacement plan\n', - id: 'plan:/tmp/plan.md', - kind: 'plan', - path: '/tmp/plan.md', - version: 'next-plan-version', - }, - id: 'plan:/tmp/plan.md', - }); - }); - await waitFor(() => { - expect(container.querySelector('[data-mdx-annotation-block~="live-thread"]')).toBeNull(); - }); - expect(container.querySelector('.plan-resolved-comments')).toBeNull(); - expect(container.querySelector('.plan-comment-position .plan-comment-thread')).not.toBeNull(); - expect( - savePlanReview.mock.calls.some( - ([review]) => - review.threads.find((thread) => thread.id === 'live-thread')?.status === 'resolved', - ), - ).toBe(false); -}); - -test('closing plan mode flushes and returns a closed handoff', async () => { - const completePlan = vi.fn(async (_review: PlanReview, _status: 'closed' | 'done') => {}); - let blockPlanReviewSave = false; - let resolvePlanReviewSave: (() => void) | null = null; - const savePlanReview = vi.fn((review: PlanReview) => { - if (!blockPlanReviewSave) { - return Promise.resolve(review); - } - return new Promise((resolveSave) => { - resolvePlanReviewSave = () => resolveSave(review); - }); - }); - let requestClose: (() => void) | null = null; - window.codiff = createCodiffMock({ - completePlan, - getLaunchOptions: vi.fn(async () => ({ - planFile: '/tmp/plan.md', - planResultFile: '/tmp/result.json', - repositoryPathProvided: true, - walkthrough: false, - })), - getMarkdownDocument: vi.fn(async () => ({ - content: '# Execute this plan\n', - id: 'plan:/tmp/plan.md', - kind: 'plan' as const, - path: '/tmp/plan.md', - version: 'plan-version', - })), - getPlanReview: vi.fn(async (): Promise => ({ - document: { - id: 'plan:/tmp/plan.md', - path: '/tmp/plan.md', - version: 'plan-version', - }, - threads: [ - { - anchor: { - block: { - fingerprint: 'heading-fingerprint', - path: [0], - text: 'Execute this plan', - type: 'heading', - }, - kind: 'block', - version: 1, - }, - createdAt: '2026-06-24T00:00:00.000Z', - createdBy: { - email: 'reviewer@example.com', - id: 'reviewer@example.com', - name: 'Reviewer', - }, - id: 'thread-1', - messages: [ - { - author: { - email: 'reviewer@example.com', - id: 'reviewer@example.com', - name: 'Reviewer', - }, - body: 'Keep this requirement.', - createdAt: '2026-06-24T00:00:00.000Z', - id: 'message-1', - updatedAt: '2026-06-24T00:00:00.000Z', - }, - ], - status: 'open', - updatedAt: '2026-06-24T00:00:00.000Z', - }, - ], - version: 1, - })), - onPlanCloseRequested: vi.fn((callback) => { - requestClose = callback; - return () => { - requestClose = null; - }; - }), - savePlanReview, - }); - - const container = document.createElement('div'); - document.body.append(container); - let root: Root | null = null; - - await using _resource = { - async [Symbol.asyncDispose]() { - if (root) { - await act(async () => root?.unmount()); - } - container.remove(); - }, - }; - await act(async () => { - root = createRoot(container); - root.render(); - }); - await waitFor(() => { - expect(container.querySelector('.plan-shell')).not.toBeNull(); - expect(requestClose).not.toBeNull(); - expect(container.querySelector('.plan-comment-thread')).not.toBeNull(); - }); - await act(async () => { - await new Promise((resolveWait) => setTimeout(resolveWait, 75)); - }); - savePlanReview.mockClear(); - blockPlanReviewSave = true; - await act(async () => { - requestClose?.(); - }); - await waitFor(() => { - expect(savePlanReview).toHaveBeenCalledTimes(1); - }); - expect(completePlan).not.toHaveBeenCalled(); - expect( - [...container.querySelectorAll('[contenteditable]')].map((element) => - element.getAttribute('contenteditable'), - ), - ).toEqual(['false', 'false']); - expect(container.querySelector('.review-comment-delete')?.disabled).toBe(true); - await act(async () => { - resolvePlanReviewSave?.(); - }); - await waitFor(() => { - expect(completePlan).toHaveBeenCalledTimes(1); - }); - expect(completePlan).toHaveBeenCalledWith( - expect.objectContaining({ - document: { - id: 'plan:/tmp/plan.md', - path: '/tmp/plan.md', - version: 'plan-version', - }, - threads: [ - expect.objectContaining({ - id: 'thread-1', - }), - ], - version: 1, - }), - 'closed', - ); -}); - -test('plan mode recovers from an unreadable review sidecar', async () => { - const markPlanReady = vi.fn(async () => {}); - window.codiff = createCodiffMock({ - getLaunchOptions: vi.fn(async () => ({ - planFile: '/tmp/plan.md', - planResultFile: '/tmp/result.json', - repositoryPathProvided: true, - walkthrough: false, - })), - getMarkdownDocument: vi.fn(async () => ({ - content: '# Execute this plan\n', - id: 'plan:/tmp/plan.md', - kind: 'plan' as const, - path: '/tmp/plan.md', - version: 'plan-version', - })), - getPlanReview: vi.fn(async () => { - throw new Error('Invalid plan review.'); - }), - markPlanReady, - }); - - const container = document.createElement('div'); - document.body.append(container); - let root: Root | null = null; - - await using _resource = { - async [Symbol.asyncDispose]() { - if (root) { - await act(async () => root?.unmount()); - } - container.remove(); - }, - }; - await act(async () => { - root = createRoot(container); - root.render(); - }); - await waitFor(() => { - expect(container.querySelector('.plan-shell')).not.toBeNull(); - }); - expect(container.querySelector('[role="alert"]')?.textContent).toContain('Invalid plan review.'); - expect(container.querySelector('[contenteditable="true"]')).not.toBeNull(); - expect(markPlanReady).toHaveBeenCalledTimes(1); -}); - -test('a walkthrough file that no longer anchors surfaces a dismissible banner', async () => { - const source = { ref: 'abc1234', type: 'commit' } satisfies ReviewSource; - window.codiff = createCodiffMock({ - getLaunchOptions: vi.fn(async () => ({ - repositoryPathProvided: true, - source, - walkthrough: false, - walkthroughFile: '/tmp/broken-walkthrough.json', - })), - getNarrativeWalkthrough: vi.fn(async () => ({ - reason: 'These changes were committed since the walkthrough was authored.', - status: 'unavailable' as const, - })), - getRepositoryState: vi.fn(async () => ({ - ...repositoryState, - files: [createChangedFile('src/app.ts')], - source, - })), - }); - - const container = document.createElement('div'); - document.body.append(container); - let root: Root | null = null; - - await using _resource = { - async [Symbol.asyncDispose]() { - if (root) { - await act(async () => root?.unmount()); - } - container.remove(); - }, - }; - await act(async () => { - root = createRoot(container); - root.render(); - }); - await waitFor(() => { - expect(container.querySelector('.walkthrough-outdated-banner.visible')).not.toBeNull(); - }); - const banner = container.querySelector('.walkthrough-outdated-banner'); - expect(banner?.textContent).toContain('committed since the walkthrough was authored'); - expect(banner?.textContent).toContain('Showing history instead.'); - await act(async () => { - banner?.querySelector('.repository-change-dismiss')?.click(); - }); - expect(container.querySelector('.walkthrough-outdated-banner.visible')).toBeNull(); -}); - -test('repository changes show the update banner without refreshing the working tree', async () => { - let onRepositoryChanged: ((change: { root: string }) => void) | null = null; - const getRepositoryState = vi.fn(async () => repositoryState); - - window.codiff = createCodiffMock({ - getRepositoryState, - onRepositoryChanged: vi.fn((callback) => { - onRepositoryChanged = callback; - return () => { - onRepositoryChanged = null; - }; - }), - }); - - const container = document.createElement('div'); - document.body.append(container); - let root: Root | null = null; - - await using _resource = { - async [Symbol.asyncDispose]() { - if (root) { - await act(async () => root?.unmount()); - } - container.remove(); - }, - }; - await act(async () => { - root = createRoot(container); - root.render(); - }); - await waitFor(() => { - expect(container.querySelector('.loading')).toBeNull(); - expect(onRepositoryChanged).not.toBeNull(); - }); - expect(container.querySelector('.repository-change-banner.visible')).toBeNull(); - expect(getRepositoryState).toHaveBeenCalledTimes(1); - await act(async () => { - onRepositoryChanged?.({ root: '/repo' }); - await new Promise((resolve) => setTimeout(resolve, 0)); - }); - expect(container.querySelector('.repository-change-banner.visible')).not.toBeNull(); - expect(getRepositoryState).toHaveBeenCalledTimes(1); -}); - -test('clicking the change banner refreshes the repository in place', async () => { - const initialFile = { - fingerprint: 'src/app.ts:1', - path: 'src/app.ts', - sections: [ - { - binary: false, - id: 'src/app.ts:unstaged', - kind: 'unstaged', - patch: 'diff --git a/src/app.ts b/src/app.ts\n@@ -1 +1 @@\n-old\n+new\n', - }, - ], - status: 'modified', - } satisfies ChangedFile; - const addedFile = { - fingerprint: 'src/added.ts:1', - path: 'src/added.ts', - sections: [ - { - binary: false, - id: 'src/added.ts:unstaged', - kind: 'unstaged', - patch: 'diff --git a/src/added.ts b/src/added.ts\n@@ -0,0 +1 @@\n+created\n', - }, - ], - status: 'added', - } satisfies ChangedFile; - - let onRepositoryChanged: ((change: { root: string }) => void) | null = null; - let stateRequests = 0; - const getRepositoryState = vi.fn(async () => { - stateRequests += 1; - return stateRequests === 1 - ? { ...repositoryState, files: [initialFile] } - : { - ...repositoryState, - files: [ - addedFile, - { - ...initialFile, - fingerprint: 'src/app.ts:2', - sections: [ - { - binary: false, - id: 'src/app.ts:unstaged', - kind: 'unstaged', - patch: 'diff --git a/src/app.ts b/src/app.ts\n@@ -1 +1 @@\n-old\n+newer\n', - }, - ], - }, - ], - }; - }); - - window.codiff = createCodiffMock({ - getRepositoryState, - onRepositoryChanged: vi.fn((callback) => { - onRepositoryChanged = callback; - return () => { - onRepositoryChanged = null; - }; - }), - }); - - const container = document.createElement('div'); - document.body.append(container); - let root: Root | null = null; - - await using _resource = { - async [Symbol.asyncDispose]() { - if (root) { - await act(async () => root?.unmount()); - } - container.remove(); - }, - }; - await act(async () => { - root = createRoot(container); - root.render(); - }); - await waitFor(() => { - expect(container.querySelector('.loading')).toBeNull(); - expect(onRepositoryChanged).not.toBeNull(); - }); - expect(container.textContent).not.toContain('added.ts'); - await act(async () => { - onRepositoryChanged?.({ root: '/repo' }); - await new Promise((resolve) => setTimeout(resolve, 0)); - }); - const refreshButton = container.querySelector('.repository-change-reload'); - expect(refreshButton).not.toBeNull(); - await act(async () => { - refreshButton?.click(); - await new Promise((resolve) => setTimeout(resolve, 0)); - }); - // New file appears without a window reload, and the banner clears. - expect(getRepositoryState).toHaveBeenCalledTimes(2); - expect(container.textContent).toContain('added.ts'); - expect(container.querySelector('.repository-change-banner.visible')).toBeNull(); - // The changed file must not appear twice (old + new version). - const appOccurrences = container.textContent?.split('app.ts').length ?? 1; - expect(appOccurrences - 1).toBeLessThanOrEqual(2); - expect(container.textContent).not.toContain('-old\n+new\n'); -}); - -test('refreshing all changes re-resolves the branch snapshot', async () => { - const initialSource = { - baseRef: 'base123', - headRef: 'head123', - ref: 'main', - type: 'branch-working-tree', - } satisfies ReviewSource; - const refreshedSource = { - baseRef: 'base123', - headRef: 'head456', - ref: 'main', - type: 'branch-working-tree', - } satisfies ReviewSource; - const initialFile = createChangedFile('src/initial.ts', { kind: 'commit' }); - const addedFile = createChangedFile('src/added.ts', { kind: 'commit' }); - const initialState = { - ...repositoryState, - branch: 'feature', - files: [initialFile], - source: initialSource, - } satisfies RepositoryState; - const refreshedState = { - ...initialState, - files: [initialFile, addedFile], - source: refreshedSource, - } satisfies RepositoryState; - let onRepositoryChanged: ((change: { root: string }) => void) | null = null; - let branchWorkingTreeRequests = 0; - const getRepositoryState = vi.fn( - async (requestedSource) => { - if (requestedSource?.type === 'branch-diff') { - return { - ...refreshedState, - source: requestedSource, - }; - } - branchWorkingTreeRequests += 1; - return branchWorkingTreeRequests === 1 ? initialState : refreshedState; - }, - ); - const getRepositoryHistory = vi.fn(async () => ({ - entries: [], - root: '/repo', - })); - - window.codiff = createCodiffMock({ - getLaunchOptions: vi.fn(async () => ({ - repositoryPathProvided: true, - source: { ref: 'main', type: 'branch-working-tree' as const }, - walkthrough: false, - })), - getRepositoryHistory, - getRepositoryState, - onRepositoryChanged: vi.fn((callback) => { - onRepositoryChanged = callback; - return () => { - onRepositoryChanged = null; - }; - }), - }); - - const container = document.createElement('div'); - document.body.append(container); - let root: Root | null = null; - const findButton = (label: string) => - Array.from(container.querySelectorAll('button')).find((button) => - button.textContent?.includes(label), - ); - - await using _resource = { - async [Symbol.asyncDispose]() { - if (root) { - await act(async () => root?.unmount()); - } - container.remove(); - }, - }; - await act(async () => { - root = createRoot(container); - root.render(); - }); - await waitFor(() => { - expect(container.querySelector('.loading')).toBeNull(); - expect(onRepositoryChanged).not.toBeNull(); - }); - await act(async () => { - onRepositoryChanged?.({ root: '/repo' }); - await new Promise((resolve) => setTimeout(resolve, 0)); - }); - await act(async () => { - container.querySelector('.repository-change-reload')?.click(); - await new Promise((resolve) => setTimeout(resolve, 0)); - }); - await waitFor(() => { - expect(container.textContent).toContain('added.ts'); - }); - expect(getRepositoryState).toHaveBeenNthCalledWith(2, { - ref: 'main', - type: 'branch-working-tree', - }); - expect(getRepositoryHistory).toHaveBeenLastCalledWith(expect.any(Number), { - ref: 'main', - type: 'branch-working-tree', - }); - await act(async () => { - findButton('History')?.click(); - }); - await waitFor(() => { - expect(findButton('Committed only vs main')).toBeTruthy(); - }); - await act(async () => { - findButton('Committed only vs main')?.click(); - await new Promise((resolve) => setTimeout(resolve, 0)); - }); - expect(getRepositoryState).toHaveBeenLastCalledWith({ - baseRef: refreshedSource.baseRef, - headRef: refreshedSource.headRef, - ref: refreshedSource.ref, - type: 'branch-diff', - }); -}); - -test('refreshing changed viewed files expands them in place', async () => { - const initialFile = createChangedFile('src/viewed.ts', { - fingerprint: 'viewed:1', - }); - const refreshedFile = createChangedFile('src/viewed.ts', { - fingerprint: 'viewed:2', - patch: 'diff --git a/src/viewed.ts b/src/viewed.ts\n@@ -1 +1 @@\n-old\n+newer\n', - }); - window.localStorage.setItem( - 'codiff:viewed:/repo', - JSON.stringify({ [initialFile.path]: initialFile.fingerprint }), - ); - - let onRepositoryChanged: ((change: { root: string }) => void) | null = null; - let stateRequests = 0; - const getRepositoryState = vi.fn(async () => { - stateRequests += 1; - return { - ...repositoryState, - files: [stateRequests === 1 ? initialFile : refreshedFile], - }; - }); - - window.codiff = createCodiffMock({ - getRepositoryState, - onRepositoryChanged: vi.fn((callback) => { - onRepositoryChanged = callback; - return () => { - onRepositoryChanged = null; - }; - }), - }); - - const container = document.createElement('div'); - document.body.append(container); - let root: Root | null = null; - - await using _resource = { - async [Symbol.asyncDispose]() { - if (root) { - await act(async () => root?.unmount()); - } - container.remove(); - }, - }; - await act(async () => { - root = createRoot(container); - root.render(); - }); - await waitFor(() => { - const header = container.querySelector('.codiff-file-header'); - expect(header).not.toBeNull(); - expect(header?.classList.contains('collapsed')).toBe(true); - }); - await act(async () => { - onRepositoryChanged?.({ root: '/repo' }); - await new Promise((resolve) => setTimeout(resolve, 0)); - }); - const refreshButton = container.querySelector('.repository-change-reload'); - expect(refreshButton).not.toBeNull(); - await act(async () => { - refreshButton?.click(); - await new Promise((resolve) => setTimeout(resolve, 0)); - }); - await waitFor(() => { - const header = container.querySelector('.codiff-file-header'); - expect(header).not.toBeNull(); - expect(header?.classList.contains('collapsed')).toBe(false); - expect(header?.querySelector('.codiff-viewed-button')?.getAttribute('aria-pressed')).toBe( - 'false', - ); - }); -}); - -test('refreshing changed files automatically regenerates the walkthrough', async () => { - const appFile = createChangedFile('src/app.ts'); - const addedFile = createChangedFile('src/added.ts', { - fingerprint: 'src/added.ts:1', - patch: 'diff --git a/src/added.ts b/src/added.ts\n@@ -0,0 +1 @@\n+created\n', - status: 'added', - }); - const laterFile = createChangedFile('src/later.ts', { - fingerprint: 'src/later.ts:1', - patch: 'diff --git a/src/later.ts b/src/later.ts\n@@ -0,0 +1 @@\n+later\n', - status: 'added', - }); - const initialWalkthrough = createNarrativeWalkthroughFixture([ - { added: 1, path: appFile.path, status: 'modified' }, - ]); - const addedWalkthrough = { - ...createNarrativeWalkthroughFixture([ - { added: 1, path: appFile.path, status: 'modified' }, - { added: 1, path: addedFile.path, status: 'added' }, - ]), - focus: 'Review the added file.', - }; - const laterWalkthrough = { - ...createNarrativeWalkthroughFixture([ - { added: 1, path: appFile.path, status: 'modified' }, - { added: 1, path: addedFile.path, status: 'added' }, - { added: 1, path: laterFile.path, status: 'added' }, - ]), - focus: 'Review the later file.', - }; - - let onRepositoryChanged: ((change: { root: string }) => void) | null = null; - let stateRequests = 0; - const getRepositoryState = vi.fn(async () => { - stateRequests += 1; - return { - ...repositoryState, - files: - stateRequests === 1 - ? [appFile] - : stateRequests === 2 - ? [appFile, addedFile] - : [appFile, addedFile, laterFile], - }; - }); - let walkthroughRequests = 0; - const getNarrativeWalkthrough = vi.fn(async () => { - walkthroughRequests += 1; - return { - status: 'ready' as const, - walkthrough: - walkthroughRequests === 1 - ? initialWalkthrough - : walkthroughRequests === 2 - ? addedWalkthrough - : laterWalkthrough, - }; - }); - - window.codiff = createCodiffMock({ - getLaunchOptions: vi.fn(async () => ({ - repositoryPathProvided: true, - walkthrough: true, - })), - getNarrativeWalkthrough, - getRepositoryState, - onRepositoryChanged: vi.fn((callback) => { - onRepositoryChanged = callback; - return () => { - onRepositoryChanged = null; - }; - }), - }); - - const container = document.createElement('div'); - document.body.append(container); - let root: Root | null = null; - - await using _resource = { - async [Symbol.asyncDispose]() { - if (root) { - await act(async () => root?.unmount()); - } - container.remove(); - }, - }; - await act(async () => { - root = createRoot(container); - root.render(); - }); - await waitFor(() => { - expect(container.querySelector('.wt-stop-block')).not.toBeNull(); - expect(getNarrativeWalkthrough).toHaveBeenCalledTimes(1); - }); - await act(async () => { - onRepositoryChanged?.({ root: '/repo' }); - await new Promise((resolve) => setTimeout(resolve, 0)); - }); - await act(async () => { - container.querySelector('.repository-change-reload')?.click(); - await new Promise((resolve) => setTimeout(resolve, 0)); - }); - await waitFor(() => { - expect(getNarrativeWalkthrough).toHaveBeenCalledTimes(2); - expect(container.textContent).toContain('Review the added file.'); - expect(container.textContent).toContain('added.ts'); - expect(container.textContent).not.toContain('Regenerate walkthrough'); - expect(container.textContent).not.toContain('Changed after the walkthrough was generated.'); - }); - expect(getNarrativeWalkthrough).toHaveBeenLastCalledWith(repositoryState.source, { - force: true, - previousWalkthrough: initialWalkthrough, - }); - await act(async () => { - onRepositoryChanged?.({ root: '/repo' }); - await new Promise((resolve) => setTimeout(resolve, 0)); - }); - await act(async () => { - container.querySelector('.repository-change-reload')?.click(); - await new Promise((resolve) => setTimeout(resolve, 0)); - }); - await waitFor(() => { - expect(getNarrativeWalkthrough).toHaveBeenCalledTimes(3); - expect(container.textContent).toContain('Review the later file.'); - expect(container.textContent).toContain('added.ts'); - expect(container.textContent).toContain('later.ts'); - }); - expect(getNarrativeWalkthrough).toHaveBeenLastCalledWith(repositoryState.source, { - force: true, - previousWalkthrough: addedWalkthrough, - }); -}); - -test('drops automatic walkthrough regeneration when the same source refreshes again', async () => { - const appFile = createChangedFile('src/app.ts', { - fingerprint: 'src/app.ts:1', - patch: 'diff --git a/src/app.ts b/src/app.ts\n@@ -1 +1 @@\n-old\n+new\n', - }); - const addedFile = createChangedFile('src/added.ts', { - fingerprint: 'src/added.ts:1', - patch: 'diff --git a/src/added.ts b/src/added.ts\n@@ -0,0 +1 @@\n+added\n', - status: 'added', - }); - const laterFile = createChangedFile('src/later.ts', { - fingerprint: 'src/later.ts:1', - patch: 'diff --git a/src/later.ts b/src/later.ts\n@@ -0,0 +1 @@\n+later\n', - status: 'added', - }); - const initialWalkthrough = createNarrativeWalkthroughFixture([ - { added: 1, path: appFile.path, status: 'modified' }, - ]); - const staleRegeneration = { - ...createNarrativeWalkthroughFixture([ - { added: 1, path: appFile.path, status: 'modified' }, - { added: 1, path: addedFile.path, status: 'added' }, - ]), - focus: 'Stale regeneration.', - }; - const latestRegeneration = { - ...createNarrativeWalkthroughFixture([ - { added: 1, path: appFile.path, status: 'modified' }, - { added: 1, path: addedFile.path, status: 'added' }, - { added: 1, path: laterFile.path, status: 'added' }, - ]), - focus: 'Latest regeneration.', - }; - - let onRepositoryChanged: ((change: { root: string }) => void) | null = null; - let stateRequests = 0; - const getRepositoryState = vi.fn(async () => { - stateRequests += 1; - return { - ...repositoryState, - files: - stateRequests === 1 - ? [appFile] - : stateRequests === 2 - ? [appFile, addedFile] - : [appFile, addedFile, laterFile], - }; - }); - let walkthroughRequests = 0; - let resolveFirstRegeneration: - | ((result: { status: 'ready'; walkthrough: NarrativeWalkthrough }) => void) - | null = null; - let resolveSecondRegeneration: - | ((result: { status: 'ready'; walkthrough: NarrativeWalkthrough }) => void) - | null = null; - const getNarrativeWalkthrough = vi.fn(() => { - walkthroughRequests += 1; - if (walkthroughRequests === 1) { - return Promise.resolve({ - status: 'ready' as const, - walkthrough: initialWalkthrough, - }); - } - return new Promise<{ status: 'ready'; walkthrough: NarrativeWalkthrough }>((resolve) => { - if (walkthroughRequests === 2) { - resolveFirstRegeneration = resolve; - } else { - resolveSecondRegeneration = resolve; - } - }); - }); - - window.codiff = createCodiffMock({ - getLaunchOptions: vi.fn(async () => ({ - repositoryPathProvided: true, - walkthrough: true, - })), - getNarrativeWalkthrough, - getRepositoryState, - onRepositoryChanged: vi.fn((callback) => { - onRepositoryChanged = callback; - return () => { - onRepositoryChanged = null; - }; - }), - }); - - const container = document.createElement('div'); - document.body.append(container); - let root: Root | null = null; - - const refresh = async () => { - await act(async () => { - onRepositoryChanged?.({ root: '/repo' }); - await new Promise((resolve) => setTimeout(resolve, 0)); - }); - await act(async () => { - container.querySelector('.repository-change-reload')?.click(); - await new Promise((resolve) => setTimeout(resolve, 0)); - }); - }; - - await using _resource = { - async [Symbol.asyncDispose]() { - if (root) { - await act(async () => root?.unmount()); - } - container.remove(); - }, - }; - await act(async () => { - root = createRoot(container); - root.render(); - }); - await waitFor(() => { - expect(getNarrativeWalkthrough).toHaveBeenCalledTimes(1); - }); - await refresh(); - await waitFor(() => { - expect(getNarrativeWalkthrough).toHaveBeenCalledTimes(2); - expect(container.querySelector('.wt-stop-block')).toBeNull(); - expect(container.textContent).toContain('Generating walkthrough'); - expect(container.textContent).not.toContain('Regenerate walkthrough'); - }); - await refresh(); - await waitFor(() => { - expect(getNarrativeWalkthrough).toHaveBeenCalledTimes(3); - }); - await act(async () => { - resolveFirstRegeneration?.({ - status: 'ready', - walkthrough: staleRegeneration, - }); - await new Promise((resolve) => setTimeout(resolve, 0)); - }); - expect(container.querySelector('.wt-stop-block')).toBeNull(); - expect(container.textContent).not.toContain('Stale regeneration.'); - await act(async () => { - resolveSecondRegeneration?.({ - status: 'ready', - walkthrough: latestRegeneration, - }); - await new Promise((resolve) => setTimeout(resolve, 0)); - }); - await waitFor(() => { - expect(container.textContent).toContain('Latest regeneration.'); - expect(container.textContent).toContain('later.ts'); - }); -}); - -test('committing and then editing again clears and regenerates the walkthrough', async () => { - const appFile = createChangedFile('src/app.ts'); - const nextFile = createChangedFile('src/next.ts', { - fingerprint: 'src/next.ts:1', - patch: 'diff --git a/src/next.ts b/src/next.ts\n@@ -0,0 +1 @@\n+next\n', - status: 'added', - }); - const initialWalkthrough = { - ...createNarrativeWalkthroughFixture([{ added: 1, path: appFile.path, status: 'modified' }]), - focus: 'Initial walkthrough.', - }; - const nextWalkthrough = { - ...createNarrativeWalkthroughFixture([{ added: 1, path: nextFile.path, status: 'added' }]), - focus: 'Fresh walkthrough.', - }; - let onRepositoryChanged: ((change: { root: string }) => void) | null = null; - let stateRequests = 0; - let walkthroughRequests = 0; - let resolveNextWalkthrough: - | ((result: { status: 'ready'; walkthrough: NarrativeWalkthrough }) => void) - | null = null; - const getNarrativeWalkthrough = vi.fn(() => { - walkthroughRequests += 1; - if (walkthroughRequests === 1) { - return Promise.resolve({ - status: 'ready' as const, - walkthrough: initialWalkthrough, - }); - } - return new Promise<{ status: 'ready'; walkthrough: NarrativeWalkthrough }>((resolve) => { - resolveNextWalkthrough = resolve; - }); - }); - - window.codiff = createCodiffMock({ - getLaunchOptions: vi.fn(async () => ({ - repositoryPathProvided: true, - walkthrough: true, - })), - getNarrativeWalkthrough, - getRepositoryState: vi.fn(async () => { - stateRequests += 1; - return { - ...repositoryState, - files: stateRequests === 1 ? [appFile] : stateRequests === 2 ? [] : [nextFile], - }; - }), - onRepositoryChanged: vi.fn((callback) => { - onRepositoryChanged = callback; - return () => { - onRepositoryChanged = null; - }; - }), - }); - - const container = document.createElement('div'); - document.body.append(container); - let root: Root | null = null; - - await using _resource = { - async [Symbol.asyncDispose]() { - if (root) { - await act(async () => root?.unmount()); - } - container.remove(); - }, - }; - await act(async () => { - root = createRoot(container); - root.render(); - }); - await waitFor(() => { - expect(container.querySelector('.wt-stop-block')).not.toBeNull(); - }); - await act(async () => { - onRepositoryChanged?.({ root: '/repo' }); - await new Promise((resolve) => setTimeout(resolve, 0)); - }); - await act(async () => { - container.querySelector('.repository-change-reload')?.click(); - await new Promise((resolve) => setTimeout(resolve, 0)); - }); - await waitFor(() => { - expect(container.textContent).toContain('No local changes'); - expect(container.querySelector('.wt-stop-block')).toBeNull(); - }); - expect(getNarrativeWalkthrough).toHaveBeenCalledTimes(1); - await act(async () => { - onRepositoryChanged?.({ root: '/repo' }); - await new Promise((resolve) => setTimeout(resolve, 0)); - }); - await act(async () => { - container.querySelector('.repository-change-reload')?.click(); - await new Promise((resolve) => setTimeout(resolve, 0)); - }); - await waitFor(() => { - expect(getNarrativeWalkthrough).toHaveBeenCalledTimes(2); - expect(container.textContent).toContain('Generating walkthrough'); - expect(container.textContent).not.toContain('Initial walkthrough.'); - expect(container.querySelector('.wt-stop-block')).toBeNull(); - }); - expect(getNarrativeWalkthrough).toHaveBeenLastCalledWith(repositoryState.source, { - force: true, - previousWalkthrough: undefined, - }); - await act(async () => { - resolveNextWalkthrough?.({ status: 'ready', walkthrough: nextWalkthrough }); - await new Promise((resolve) => setTimeout(resolve, 0)); - }); - await waitFor(() => { - expect(container.textContent).toContain('Fresh walkthrough.'); - expect(container.textContent).toContain('next.ts'); - expect(container.textContent).not.toContain('Regenerate walkthrough'); - expect(container.textContent).not.toContain('Changed after the walkthrough was generated.'); - }); -}); - -test('walkthrough launch errors stay on the walkthrough tab without automatic retries', async () => { - const changedFile = { - fingerprint: 'src/app.ts:1', - path: 'src/app.ts', - sections: [ - { - binary: false, - id: 'src/app.ts:unstaged', - kind: 'unstaged', - patch: 'diff --git a/src/app.ts b/src/app.ts\n@@ -1 +1 @@\n-old\n+new\n', - }, - ], - status: 'modified', - } satisfies ChangedFile; - const getNarrativeWalkthrough = vi.fn(async () => ({ - reason: 'Codex walkthrough timed out.', - status: 'unavailable' as const, - })); - - window.codiff = createCodiffMock({ - getLaunchOptions: vi.fn(async () => ({ - repositoryPathProvided: true, - walkthrough: true, - })), - getNarrativeWalkthrough, - getRepositoryState: vi.fn(async () => ({ - ...repositoryState, - files: [changedFile], - })), - }); - - const container = document.createElement('div'); - document.body.append(container); - let root: Root | null = null; - - const getTab = (label: string) => - Array.from(container.querySelectorAll('button[role="tab"]')).find((button) => - button.textContent?.includes(label), - ) as HTMLButtonElement | undefined; - - await using _resource = { - async [Symbol.asyncDispose]() { - if (root) { - await act(async () => root?.unmount()); - } - container.remove(); - }, - }; - await act(async () => { - root = createRoot(container); - root.render(); - }); - await waitFor(() => { - expect(container.textContent).toContain('Walkthrough unavailable'); - }); - expect(getTab('Walkthrough')?.getAttribute('aria-selected')).toBe('true'); - expect(container.querySelector('.sidebar-walkthrough-status')).not.toBeNull(); - expect(container.querySelector('.sidebar .file-tree-shell')).toBeNull(); - expect(getNarrativeWalkthrough).toHaveBeenCalledTimes(1); - await act(async () => { - getTab('Tree')?.click(); - await new Promise((resolve) => setTimeout(resolve, 0)); - }); - await act(async () => { - getTab('Walkthrough')?.click(); - await new Promise((resolve) => setTimeout(resolve, 0)); - }); - expect(container.textContent).toContain('Walkthrough unavailable'); - expect(container.querySelector('.sidebar .file-tree-shell')).toBeNull(); - expect(getNarrativeWalkthrough).toHaveBeenCalledTimes(1); -}); - -test('walkthrough progress events replace the loading line without exposing agent output', async () => { - const changedFile = { - fingerprint: 'src/app.ts:1', - path: 'src/app.ts', - sections: [ - { - binary: false, - id: 'src/app.ts:unstaged', - kind: 'unstaged', - patch: 'diff --git a/src/app.ts b/src/app.ts\n@@ -1 +1 @@\n-old\n+new\n', - }, - ], - status: 'modified', - } satisfies ChangedFile; - let onProgress: ((progress: WalkthroughProgressEvent) => void) | null = null; - let resolveWalkthrough: ((result: { reason: string; status: 'unavailable' }) => void) | null = - null; - const getNarrativeWalkthrough = vi.fn( - () => - new Promise<{ reason: string; status: 'unavailable' }>((resolve) => { - resolveWalkthrough = resolve; - }), - ); - - window.codiff = createCodiffMock({ - getLaunchOptions: vi.fn(async () => ({ - repositoryPathProvided: true, - walkthrough: true, - })), - getNarrativeWalkthrough, - getRepositoryState: vi.fn(async () => ({ - ...repositoryState, - files: [changedFile], - })), - onWalkthroughProgress: vi.fn((callback) => { - onProgress = callback; - return () => { - onProgress = null; - }; - }), - }); - - const container = document.createElement('div'); - document.body.append(container); - let root: Root | null = null; - - await using _resource = { - async [Symbol.asyncDispose]() { - if (root) { - await act(async () => root?.unmount()); - } - container.remove(); - }, - }; - await act(async () => { - root = createRoot(container); - root.render(); - }); - await waitFor(() => { - expect(container.textContent).toContain('Generating walkthrough…'); - }); - await act(async () => { - onProgress?.({ phase: 'agent-generation' }); - }); - expect(container.textContent).toContain('Analyzing changes…'); - expect(container.textContent).not.toContain('Generating walkthrough…'); - await act(async () => { - onProgress?.({ phase: 'response-received' }); - }); - expect(container.textContent).toContain('Building walkthrough…'); - expect(container.querySelector('.wt-generation')).toBeNull(); - await act(async () => { - resolveWalkthrough?.({ - reason: 'Stopped for test.', - status: 'unavailable', - }); - await new Promise((resolve) => setTimeout(resolve, 0)); - }); -}); - -test('history filter matches commits by author name', async () => { - window.codiff = createCodiffMock({ - getRepositoryHistory: vi.fn(async () => ({ - entries: [ - { - author: 'Ada Lovelace', - committedAt: Date.now(), - parents: [], - ref: 'aaa1111', - subject: 'Fix parser', - }, - { - author: 'Grace Hopper', - committedAt: Date.now(), - parents: [], - ref: 'bbb2222', - subject: 'Update docs', - }, - ], - root: '/repo', - })), - }); - - const container = document.createElement('div'); - document.body.append(container); - let root: Root | null = null; - - const findButton = (label: string) => - Array.from(container.querySelectorAll('button')).find((button) => - button.textContent?.includes(label), - ); - const historySubjects = () => - Array.from(container.querySelectorAll('.history-entry-subject')).map( - (element) => element.textContent, - ); - - await using _resource = { - async [Symbol.asyncDispose]() { - if (root) { - await act(async () => root?.unmount()); - } - container.remove(); - }, - }; - await act(async () => { - root = createRoot(container); - root.render(); - }); - await waitFor(() => { - expect(container.querySelector('.loading')).toBeNull(); - }); - await act(async () => { - findButton('History')?.click(); - }); - await waitFor(() => { - expect(historySubjects()).toContain('Fix parser'); - expect(historySubjects()).toContain('Update docs'); - }); - const searchInput = container.querySelector('.sidebar-search'); - expect(searchInput).toBeTruthy(); - const setInputValue = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set; - await act(async () => { - setInputValue?.call(searchInput, 'grace'); - searchInput?.dispatchEvent(new Event('input', { bubbles: true })); - }); - await waitFor(() => { - expect(historySubjects()).toContain('Update docs'); - expect(historySubjects()).not.toContain('Fix parser'); - }); -}); - -test('Pi not-found walkthrough errors show the agent recovery panel', async () => { - window.codiff = createCodiffMock({ - getLaunchOptions: vi.fn(async () => ({ - agentBackend: 'pi' as const, - repositoryPathProvided: true, - walkthrough: true, - })), - getNarrativeWalkthrough: vi.fn(async () => ({ - code: 'PI_NOT_FOUND' as const, - reason: 'Pi CLI was not found.', - status: 'unavailable' as const, - })), - getRepositoryState: vi.fn(async () => ({ - ...repositoryState, - files: [createChangedFile('src/app.ts')], - })), - }); - - const container = document.createElement('div'); - document.body.append(container); - let root: Root | null = null; - - await using _resource = { - async [Symbol.asyncDispose]() { - if (root) { - await act(async () => root?.unmount()); - } - container.remove(); - }, - }; - await act(async () => { - root = createRoot(container); - root.render(); - }); - await waitFor(() => { - expect(container.textContent).toContain('Pi CLI not found'); - }); - expect(container.textContent).toContain('Pi CLI was not found.'); - expect(container.textContent).toContain('Review Files'); -}); diff --git a/core/__tests__/App-shell.test.tsx b/core/__tests__/App-shell.test.tsx new file mode 100644 index 00000000..34adaa0e --- /dev/null +++ b/core/__tests__/App-shell.test.tsx @@ -0,0 +1,136 @@ +/** + * @vitest-environment jsdom + */ + +import { expect, test, vi } from 'vite-plus/test'; +import { createDefaultConfig } from '../config/defaults.ts'; +import { writeReloadSelection } from '../lib/reload-selection.ts'; +import type { GitSha, RepositoryState } from '../types.ts'; +import { createChangedFile } from './helpers/fixtures.ts'; +import { renderReact, waitFor } from './helpers/react.tsx'; + +const hostProps = vi.hoisted(() => vi.fn()); + +vi.mock('../app/RepositoryReviewHost.tsx', () => ({ + RepositoryReviewHost: (props: unknown) => { + hostProps(props); + return
Repository host
; + }, +})); + +import App from '../App.tsx'; + +const state = { + branch: 'main', + files: [], + generatedAt: 1, + launchPath: '/repo', + root: '/repo', + source: { type: 'working-tree' }, +} satisfies RepositoryState; +const gitSha = (character: string) => character.repeat(40) as GitSha; + +const installAppApi = ({ + launchOptions = { repositoryPathProvided: true, walkthrough: false }, + repositoryState = state, +}: { + launchOptions?: Awaited>; + repositoryState?: RepositoryState; +} = {}) => { + const config = createDefaultConfig(); + const api = { + getAgentSkillStatus: vi.fn(async () => ({ installed: true, path: '/skill' })), + getConfig: vi.fn(async () => config), + getFeatureFlags: vi.fn(async () => ({ planSharing: false, walkthroughSharing: false })), + getGitIdentity: vi.fn(async () => null), + getLaunchOptions: vi.fn(async () => launchOptions), + getRepositoryHistory: vi.fn(async () => ({ entries: [], root: '/repo' })), + getRepositoryState: vi.fn(async () => repositoryState), + getTerminalHelperStatus: vi.fn(async () => ({ + command: 'codiff', + installed: true, + path: '/usr/local/bin/codiff', + })), + onConfigChanged: vi.fn(() => () => {}), + }; + window.codiff = api as unknown as Window['codiff']; + return { api, config }; +}; + +test('App bootstraps the desktop shell before mounting the repository host', async () => { + const config = createDefaultConfig(); + window.codiff = { + getAgentSkillStatus: vi.fn(async () => ({ installed: true, path: '/skill' })), + getConfig: vi.fn(async () => config), + getFeatureFlags: vi.fn(async () => ({ planSharing: false, walkthroughSharing: true })), + getGitIdentity: vi.fn(async () => ({ email: 'reviewer@example.com', name: 'Reviewer' })), + getLaunchOptions: vi.fn(async () => ({ repositoryPathProvided: true, walkthrough: false })), + getRepositoryHistory: vi.fn(async () => ({ entries: [], root: '/repo' })), + getRepositoryState: vi.fn(async () => state), + getTerminalHelperStatus: vi.fn(async () => ({ + command: 'codiff', + installed: true, + path: '/usr/local/bin/codiff', + })), + onConfigChanged: vi.fn(() => () => {}), + } as unknown as Window['codiff']; + + const view = await renderReact(); + try { + await waitFor(() => expect(view.container.textContent).toContain('Repository host')); + expect(hostProps).toHaveBeenCalledWith( + expect.objectContaining({ + bootstrap: expect.objectContaining({ + historySource: null, + mainMode: 'review', + selectedPath: null, + sidebarMode: 'history', + state, + }), + config, + initialHistoryLoading: true, + launchOptions: expect.objectContaining({ walkthrough: false }), + walkthroughSharingEnabled: true, + }), + ); + } finally { + await view.cleanup(); + } +}); + +test('App restores branch History scope and reload deltas as one bootstrap value', async () => { + hostProps.mockClear(); + const branchSource = { + baseSha: gitSha('a'), + headSha: gitSha('b'), + ref: 'feature', + type: 'branch-diff', + } as const; + const previousState = { + ...state, + files: [createChangedFile('src/branch.ts', { fingerprint: 'before' })], + source: { ...branchSource, type: 'branch-working-tree' as const }, + } satisfies RepositoryState; + const nextState = { + ...previousState, + files: [createChangedFile('src/branch.ts', { fingerprint: 'after' })], + } satisfies RepositoryState; + writeReloadSelection(previousState, 'src/branch.ts', branchSource, 'review'); + const { api } = installAppApi({ repositoryState: nextState }); + + await using view = await renderReact(); + await waitFor(() => expect(view.container.textContent).toContain('Repository host')); + expect(api.getRepositoryState).toHaveBeenCalledWith({ + ref: 'feature', + type: 'branch-working-tree', + }); + expect(api.getRepositoryHistory).not.toHaveBeenCalled(); + expect(hostProps.mock.lastCall?.[0]).toMatchObject({ + bootstrap: { + historySource: branchSource, + reloadDeltaPaths: new Set(['src/branch.ts']), + selectedPath: 'src/branch.ts', + }, + initialHistoryLoading: true, + }); +}); diff --git a/core/__tests__/App-startup.test.tsx b/core/__tests__/App-startup.test.tsx new file mode 100644 index 00000000..7c5c5b18 --- /dev/null +++ b/core/__tests__/App-startup.test.tsx @@ -0,0 +1,375 @@ +/** + * @vitest-environment jsdom + */ + +import { act } from 'react'; +import { expect, test, vi } from 'vite-plus/test'; +import App from '../App.tsx'; +import { createDefaultConfig } from '../config/defaults.ts'; +import type { + GitIdentity, + GitSha, + NarrativeWalkthroughResult, + RepositoryHistory, + RepositoryState, +} from '../types.ts'; +import { createChangedFile } from './helpers/fixtures.ts'; +import { renderReact, waitFor } from './helpers/react.tsx'; + +const reactActEnvironment = globalThis as typeof globalThis & { + ResizeObserver?: typeof ResizeObserver; + Worker?: typeof Worker; +}; +reactActEnvironment.ResizeObserver ??= class ResizeObserver { + disconnect() {} + observe() {} + unobserve() {} +}; +HTMLElement.prototype.scrollBy ??= function scrollBy() {}; +HTMLElement.prototype.scrollIntoView ??= function scrollIntoView() {}; +HTMLElement.prototype.scrollTo ??= function scrollTo() {}; +class StubWorker extends EventTarget { + constructor(_scriptURL: string | URL, _options?: WorkerOptions) { + super(); + } + onerror = null; + onmessage = null; + postMessage() {} + terminate() {} +} +reactActEnvironment.Worker ??= StubWorker as unknown as typeof Worker; + +const deferred = () => { + let reject!: (reason?: unknown) => void; + let resolve!: (value: Value) => void; + const promise = new Promise((next, fail) => { + resolve = next; + reject = fail; + }); + return { promise, reject, resolve }; +}; +const unsubscribe = () => {}; +const gitSha = (character: string) => character.repeat(40) as GitSha; + +const repositoryState = { + branch: 'main', + files: [createChangedFile('src/startup.ts')], + generatedAt: 1, + launchPath: '/repo', + root: '/repo', + source: { type: 'working-tree' }, +} satisfies RepositoryState; + +const createAppApi = (overrides: Record = {}) => ({ + applyUpdate: vi.fn(async () => ({ currentVersion: '0.0.0', phase: 'idle' as const })), + askReviewAssistant: vi.fn(async () => ({ reason: 'Not used.', status: 'unavailable' as const })), + cancelNarrativeWalkthrough: vi.fn(async () => {}), + completePlan: vi.fn(async () => {}), + createWalkthroughCommit: vi.fn(async () => ({ + sha: gitSha('a'), + status: 'committed' as const, + })), + dismissUpdate: vi.fn(async () => ({ currentVersion: '0.0.0', phase: 'idle' as const })), + getAgentSkillStatus: vi.fn(async () => ({ installed: true, path: '/skill' })), + getConfig: vi.fn(async () => createDefaultConfig()), + getFeatureFlags: vi.fn(async () => ({ planSharing: false, walkthroughSharing: false })), + getGitIdentity: vi.fn(async () => ({ email: 'reviewer@example.com', name: 'Reviewer' })), + getLaunchOptions: vi.fn(async () => ({ repositoryPathProvided: true, walkthrough: false })), + getMarkdownDocument: vi.fn(async () => ({ + content: '# Plan\n', + id: 'plan:/tmp/plan.md', + kind: 'plan' as const, + path: '/tmp/plan.md', + version: 'plan-version', + })), + getNarrativeWalkthrough: vi.fn(async () => ({ + reason: 'Not used.', + status: 'unavailable' as const, + })), + getPlanReview: vi.fn(async () => null), + getRepositoryHistory: vi.fn(async () => ({ entries: [], root: '/repo' })), + getRepositoryState: vi.fn(async () => repositoryState), + getReviewComments: vi.fn(async () => ({ generalComments: [], reviewComments: [] })), + getTerminalHelperStatus: vi.fn(async () => ({ + command: 'codiff', + installed: true, + path: '/usr/local/bin/codiff', + })), + getUpdateStatus: vi.fn(async () => ({ currentVersion: '0.0.0', phase: 'idle' as const })), + isWindowFullScreen: vi.fn(async () => false), + markPlanReady: vi.fn(async () => {}), + onConfigChanged: vi.fn(() => unsubscribe), + onCopyPendingCommentsRequest: vi.fn(() => unsubscribe), + onFindInDiffs: vi.fn(() => unsubscribe), + onMarkdownDocumentChanged: vi.fn(() => unsubscribe), + onOpenReviewSource: vi.fn(() => unsubscribe), + onPlanCloseRequested: vi.fn(() => unsubscribe), + onRefreshRequest: vi.fn(() => unsubscribe), + onRepositoryChanged: vi.fn(() => unsubscribe), + onUpdateStatusChanged: vi.fn(() => unsubscribe), + onWalkthroughCommitOutput: vi.fn(() => unsubscribe), + onWalkthroughProgress: vi.fn(() => unsubscribe), + onWindowFullScreenChanged: vi.fn(() => unsubscribe), + openConfigFile: vi.fn(async () => {}), + openFile: vi.fn(async () => {}), + openRepositoryFolder: vi.fn(async () => {}), + reportInitialLoadMilestone: vi.fn(), + resolvePullRequestUrl: vi.fn(async (value: string) => value), + saveMarkdownDocument: vi.fn(async (request: { content: string; kind: 'plan'; path: string }) => ({ + document: { + content: request.content, + id: `${request.kind}:${request.path}`, + kind: request.kind, + path: request.path, + version: 'next-version', + }, + status: 'saved' as const, + })), + savePlanReview: vi.fn(async (review) => review), + setDiffStyle: vi.fn(async () => {}), + setShowOutdated: vi.fn(async () => {}), + setWordWrap: vi.fn(async () => {}), + sharePlan: vi.fn(async () => ({ status: 'uploaded' as const, url: 'https://example.test/p' })), + shareWalkthrough: vi.fn(async () => ({ + status: 'uploaded' as const, + url: 'https://example.test/w', + })), + submitPullRequestComment: vi.fn(async () => { + throw new Error('Not used.'); + }), + submitPullRequestReview: vi.fn(async () => {}), + updateWalkthroughCommitMessage: vi.fn(async () => ({ + reason: 'Not used.', + status: 'unavailable' as const, + })), + ...overrides, +}); + +test('renders repository state before configuration and history finish loading', async () => { + const config = deferred>(); + const history = deferred(); + window.codiff = { + applyUpdate: vi.fn(async () => ({ currentVersion: '0.0.0', phase: 'idle' as const })), + dismissUpdate: vi.fn(async () => ({ currentVersion: '0.0.0', phase: 'idle' as const })), + getAgentSkillStatus: vi.fn(async () => ({ installed: true })), + getConfig: vi.fn(() => config.promise), + getFeatureFlags: vi.fn(async () => ({ planSharing: false, walkthroughSharing: false })), + getGitIdentity: vi.fn(async () => null), + getLaunchOptions: vi.fn(async () => ({ repositoryPathProvided: true, walkthrough: false })), + getRepositoryHistory: vi.fn(() => history.promise), + getRepositoryState: vi.fn(async () => ({ + branch: 'main', + files: [], + generatedAt: 1, + launchPath: '/repo', + root: '/repo', + source: { type: 'working-tree' as const }, + })), + getTerminalHelperStatus: vi.fn(async () => ({ installed: true })), + getUpdateStatus: vi.fn(async () => ({ currentVersion: '0.0.0', phase: 'idle' as const })), + isWindowFullScreen: vi.fn(async () => false), + onConfigChanged: vi.fn(() => unsubscribe), + onCopyPendingCommentsRequest: vi.fn(() => unsubscribe), + onFindInDiffs: vi.fn(() => unsubscribe), + onOpenReviewSource: vi.fn(() => unsubscribe), + onRefreshRequest: vi.fn(() => unsubscribe), + onRepositoryChanged: vi.fn(() => unsubscribe), + onUpdateStatusChanged: vi.fn(() => unsubscribe), + onWalkthroughProgress: vi.fn(() => unsubscribe), + onWindowFullScreenChanged: vi.fn(() => unsubscribe), + openRepositoryFolder: vi.fn(async () => {}), + resolvePullRequestUrl: vi.fn(async (value: string) => value), + } as unknown as Window['codiff']; + + const view = await renderReact(); + try { + await waitFor(() => expect(view.container.querySelector('main.review')).not.toBeNull()); + expect(window.codiff.getRepositoryHistory).toHaveBeenCalledOnce(); + expect(view.container.textContent).toContain('Loading history'); + + config.resolve(createDefaultConfig()); + history.resolve({ entries: [], root: '/repo' }); + } finally { + await view.cleanup(); + } +}); + +test('keeps a loaded repository usable when config or feature flags fail', async () => { + for (const failingCall of ['config', 'features'] as const) { + const api = createAppApi( + failingCall === 'config' + ? { + getConfig: vi.fn(async () => { + throw new Error('Config unavailable.'); + }), + } + : { + getFeatureFlags: vi.fn(async () => { + throw new Error('Features unavailable.'); + }), + }, + ); + window.codiff = api as unknown as Window['codiff']; + await using view = await renderReact(); + await waitFor(() => expect(view.container.querySelector('main.review')).not.toBeNull()); + expect(view.container.querySelector('.repository-change-banner.visible')).toBeNull(); + } +}); + +test('plan startup opens without repository or ancillary bootstrap completion', async () => { + const pending = new Promise(() => {}); + const api = createAppApi({ + getAgentSkillStatus: vi.fn(() => pending), + getConfig: vi.fn(() => pending), + getFeatureFlags: vi.fn(() => pending), + getGitIdentity: vi.fn(() => pending), + getLaunchOptions: vi.fn(async () => ({ + planFile: '/tmp/plan.md', + planResultFile: '/tmp/result.json', + repositoryPathProvided: true, + walkthrough: false, + })), + getRepositoryState: vi.fn(async () => repositoryState), + getTerminalHelperStatus: vi.fn(() => pending), + }); + window.codiff = api as unknown as Window['codiff']; + + await using view = await renderReact(); + await waitFor(() => expect(view.container.querySelector('.plan-shell')).not.toBeNull()); + expect(api.getRepositoryState).not.toHaveBeenCalled(); +}); + +test('plan startup stays usable when ancillary bootstrap calls reject', async () => { + const api = createAppApi({ + getAgentSkillStatus: vi.fn(async () => { + throw new Error('Skill unavailable.'); + }), + getConfig: vi.fn(async () => { + throw new Error('Config unavailable.'); + }), + getFeatureFlags: vi.fn(async () => { + throw new Error('Features unavailable.'); + }), + getLaunchOptions: vi.fn(async () => ({ + planFile: '/tmp/plan.md', + repositoryPathProvided: true, + walkthrough: false, + })), + getTerminalHelperStatus: vi.fn(async () => { + throw new Error('Helper unavailable.'); + }), + }); + window.codiff = api as unknown as Window['codiff']; + + await using view = await renderReact(); + await waitFor(() => expect(view.container.querySelector('.plan-shell')).not.toBeNull()); + expect(view.container.querySelector('.plan-share-button')).toBeNull(); +}); + +test('first-run classification does not wait for or get replaced by ancillary failures', async () => { + const pending = new Promise(() => {}); + const api = createAppApi({ + getConfig: vi.fn(async () => { + throw new Error('Config unavailable.'); + }), + getFeatureFlags: vi.fn(async () => { + throw new Error('Features unavailable.'); + }), + getLaunchOptions: vi.fn(async () => ({ repositoryPathProvided: false, walkthrough: false })), + getRepositoryState: vi.fn(async () => { + throw new Error('not a git repository'); + }), + getTerminalHelperStatus: vi.fn(() => pending), + }); + window.codiff = api as unknown as Window['codiff']; + + await using view = await renderReact(); + await waitFor(() => expect(view.container.textContent).toContain('Open a Git repository')); + expect(view.container.textContent).not.toContain('Config unavailable.'); + expect(view.container.textContent).not.toContain('Features unavailable.'); +}); + +test('an asynchronous walkthrough-file failure switches the controlled mode to History', async () => { + const walkthrough = deferred(); + const api = createAppApi({ + getLaunchOptions: vi.fn(async () => ({ + repositoryPathProvided: true, + walkthrough: false, + walkthroughFile: '/tmp/walkthrough.json', + })), + getNarrativeWalkthrough: vi.fn(() => walkthrough.promise), + }); + window.codiff = api as unknown as Window['codiff']; + + await using view = await renderReact(); + await waitFor(() => + expect( + Array.from(view.container.querySelectorAll('[role="tab"]')) + .find((button) => button.textContent?.includes('Walkthrough')) + ?.getAttribute('aria-selected'), + ).toBe('true'), + ); + walkthrough.resolve({ reason: 'The walkthrough no longer matches.', status: 'unavailable' }); + await waitFor(() => expect(view.container.textContent).toContain('Showing history instead.')); + const historyTab = Array.from( + view.container.querySelectorAll('[role="tab"]'), + ).find((button) => button.textContent?.includes('History')); + await waitFor(() => expect(historyTab?.getAttribute('aria-selected')).toBe('true')); + expect(view.container.querySelector('.sidebar-walkthrough-status')).toBeNull(); +}); + +test('waits for Git identity resolution before reporting deferred completion', async () => { + const identity = deferred(); + const api = createAppApi({ + getGitIdentity: vi.fn(() => identity.promise), + }); + window.codiff = api as unknown as Window['codiff']; + + await using view = await renderReact(); + await waitFor(() => expect(view.container.querySelector('main.review')).not.toBeNull()); + await waitFor(() => + expect(api.reportInitialLoadMilestone).toHaveBeenCalledWith('first-usable-review-rendered'), + ); + await waitFor(() => expect(api.getRepositoryHistory).toHaveBeenCalledOnce()); + expect(api.reportInitialLoadMilestone).not.toHaveBeenCalledWith('deferred-review-data-complete'); + + await act(async () => { + identity.resolve({ email: 'reviewer@example.com', name: 'Reviewer' }); + }); + await waitFor(() => + expect(api.reportInitialLoadMilestone).toHaveBeenCalledWith('deferred-review-data-complete'), + ); + expect( + api.reportInitialLoadMilestone.mock.calls.filter( + ([milestone]) => milestone === 'deferred-review-data-complete', + ), + ).toHaveLength(1); +}); + +test('Git identity failure settles deferred completion without blocking first usable', async () => { + const identity = deferred(); + const api = createAppApi({ + getGitIdentity: vi.fn(() => identity.promise), + }); + window.codiff = api as unknown as Window['codiff']; + + await using view = await renderReact(); + await waitFor(() => expect(view.container.querySelector('main.review')).not.toBeNull()); + await waitFor(() => + expect(api.reportInitialLoadMilestone).toHaveBeenCalledWith('first-usable-review-rendered'), + ); + await waitFor(() => expect(api.getRepositoryHistory).toHaveBeenCalledOnce()); + expect(api.reportInitialLoadMilestone).not.toHaveBeenCalledWith('deferred-review-data-complete'); + + await act(async () => { + identity.reject(new Error('Identity unavailable.')); + }); + await waitFor(() => + expect(api.reportInitialLoadMilestone).toHaveBeenCalledWith('deferred-review-data-complete'), + ); + expect( + api.reportInitialLoadMilestone.mock.calls.filter( + ([milestone]) => milestone === 'deferred-review-data-complete', + ), + ).toHaveLength(1); +}); diff --git a/core/__tests__/App.test.tsx b/core/__tests__/App.test.tsx index 7099236a..7ec20807 100644 --- a/core/__tests__/App.test.tsx +++ b/core/__tests__/App.test.tsx @@ -11,7 +11,6 @@ import { fileHasVisibleDiff, loadSectionContents, shouldLoadDiffSectionContents, - shouldPreloadSectionContentsForSearch, } from '../lib/diff.ts'; import { isDiffSearchShortcut } from '../lib/keyboard.ts'; import { renderInlineMarkdown, sanitizeMarkdownImages } from '../lib/markdown.tsx'; @@ -73,6 +72,42 @@ test('pure renames are visible without content hunks', () => { expect(fileHasVisibleDiff(file, false)).toBe(true); }); +test('loaded added and deleted files reconstruct an exact absent side', () => { + const added = { + fingerprint: 'added', + path: 'added.ts', + sections: [ + { + binary: false, + id: 'added', + kind: 'pull-request', + loadState: 'ready', + newFile: { contents: 'one\ntwo\n', name: 'added.ts' }, + patch: '', + }, + ], + status: 'added', + } satisfies ChangedFile; + const deleted = { + fingerprint: 'deleted', + path: 'deleted.ts', + sections: [ + { + binary: false, + id: 'deleted', + kind: 'pull-request', + loadState: 'ready', + oldFile: { contents: 'one\ntwo\n', name: 'deleted.ts' }, + patch: '', + }, + ], + status: 'deleted', + } satisfies ChangedFile; + + expect(getDiffLineCount(added, false)).toMatchObject({ additions: 2, deletions: 0 }); + expect(getDiffLineCount(deleted, false)).toMatchObject({ additions: 0, deletions: 2 }); +}); + test('patch-only text sections hydrate lazily instead of eager loading', () => { const patchOnlySection = { binary: false, @@ -82,10 +117,8 @@ test('patch-only text sections hydrate lazily instead of eager loading', () => { patch: 'diff --git a/src/app.ts b/src/app.ts\n@@ -1 +1 @@\n-old\n+new\n', } as const; - // Patch-only sections expand via `loadDiffFiles` hydration, not the eager - // Load flow, but diff search still preloads their full contents. + // Patch-only sections expand through explicit unchanged-context requests. expect(shouldLoadDiffSectionContents(patchOnlySection)).toBe(false); - expect(shouldPreloadSectionContentsForSearch(patchOnlySection)).toBe(true); expect( shouldLoadDiffSectionContents({ @@ -94,16 +127,6 @@ test('patch-only text sections hydrate lazily instead of eager loading', () => { }), ).toBe(true); - expect( - shouldPreloadSectionContentsForSearch({ - ...patchOnlySection, - summary: { - canLoad: false, - reason: 'Codiff could not load full file context.', - }, - }), - ).toBe(false); - expect( shouldLoadDiffSectionContents({ binary: false, @@ -324,6 +347,80 @@ test('diff search finds content matches across sides', () => { expect(result?.matchCount).toBe(1); }); +test('diff search independently filters new, old, and unchanged lines', () => { + const file = { + fingerprint: 'search-filters', + path: 'src/search.ts', + sections: [ + { + binary: false, + id: 'src/search.ts:unstaged', + kind: 'unstaged', + patch: + 'diff --git a/src/search.ts b/src/search.ts\n@@ -1,3 +1,3 @@\n needle context\n-old needle\n+new needle\n needle context\n', + }, + ], + status: 'modified', + } satisfies ChangedFile; + + expect(getDiffSearchResult(file, false, 'needle')?.matchCount).toBe(2); + expect( + getDiffSearchResult(file, false, 'needle', { + additions: false, + deletions: false, + unchanged: true, + })?.matchCount, + ).toBe(2); + expect( + getDiffSearchResult(file, false, 'needle', { + additions: true, + deletions: false, + unchanged: false, + })?.matchCount, + ).toBe(1); + expect( + getDiffSearchResult(file, false, 'needle', { + additions: false, + deletions: true, + unchanged: false, + })?.matchCount, + ).toBe(1); +}); + +test('diff search does not search hidden unchanged lines from complete file contents', () => { + const file = { + fingerprint: 'search-unloaded-context', + path: 'src/search.ts', + sections: [ + { + binary: false, + id: 'src/search.ts:unstaged', + kind: 'unstaged', + newFile: { + contents: + 'before\nneedle outside the patch\nafter\nline four\nline five\nline six\nline seven\nline eight\nline nine\nline ten\nnew value\n', + name: 'src/search.ts', + }, + oldFile: { + contents: + 'before\nneedle outside the patch\nafter\nline four\nline five\nline six\nline seven\nline eight\nline nine\nline ten\nold value\n', + name: 'src/search.ts', + }, + patch: 'diff --git a/src/search.ts b/src/search.ts\n@@ -4 +4 @@\n-old value\n+new value\n', + }, + ], + status: 'modified', + } satisfies ChangedFile; + + expect( + getDiffSearchResult(file, false, 'needle', { + additions: false, + deletions: false, + unchanged: true, + }), + ).toBeNull(); +}); + test('diff search includes file path matches', () => { const file = { fingerprint: 'path-search', @@ -647,6 +744,7 @@ test('review comment markdown includes file and patch context', () => { body: 'Please double-check this value.', filePath: 'src/comment.ts', id: 'comment-1', + kind: 'local-note', lineNumber: 2, sectionId: 'src/comment.ts:unstaged', side: 'additions', @@ -688,6 +786,7 @@ test('review comment markdown includes file-level comments', () => { body: 'Please review the file structure.', filePath: 'src/comment.ts', id: 'comment-file', + kind: 'local-note', sectionId: 'src/comment.ts:unstaged', }, ], @@ -729,6 +828,7 @@ test('review comment markdown uses custom prefix', () => { body: 'Check this.', filePath: 'src/comment.ts', id: 'comment-1', + kind: 'local-note', lineNumber: 2, sectionId: 'src/comment.ts:unstaged', side: 'additions', @@ -772,6 +872,7 @@ test('review comment markdown omits prefix when set to empty string', () => { body: 'Check this.', filePath: 'src/comment.ts', id: 'comment-1', + kind: 'local-note', lineNumber: 2, sectionId: 'src/comment.ts:unstaged', side: 'additions', @@ -815,6 +916,7 @@ test('review comment markdown includes multi-line ranges', () => { body: 'These should be considered together.', filePath: 'src/range.ts', id: 'comment-1', + kind: 'local-note', lineNumber: 2, sectionId: 'src/range.ts:unstaged', side: 'additions', diff --git a/core/__tests__/CopyCommentsButton.test.tsx b/core/__tests__/CopyCommentsButton.test.tsx index 4cc6fca8..59d1f3af 100644 --- a/core/__tests__/CopyCommentsButton.test.tsx +++ b/core/__tests__/CopyCommentsButton.test.tsx @@ -4,30 +4,40 @@ import { expect, test } from 'vite-plus/test'; import { CopyCommentsButton } from '../app/components/Panels.tsx'; -import type { ReviewComment } from '../lib/app-types.ts'; +import type { LocalReviewNote, ReviewComment } from '../lib/app-types.ts'; import { createChangedFile } from './helpers/fixtures.ts'; import { renderReact } from './helpers/react.tsx'; const file = createChangedFile('src/app.ts'); -const createReviewComment = (comment: Partial) => - ({ - body: 'Rename this helper.', - filePath: file.path, - id: 'comment-1', - lineNumber: 1, - sectionId: file.sections[0].id, - side: 'additions', - ...comment, - }) satisfies ReviewComment; +const createReviewComment = (comment: Partial): LocalReviewNote => ({ + body: 'Rename this helper.', + filePath: file.path, + id: 'comment-1', + kind: 'local-note', + lineNumber: 1, + sectionId: file.sections[0].id, + side: 'additions', + ...comment, +}); + +const submittedComment = { + author: { login: 'reviewer' }, + body: 'Existing comment.', + destination: 'share', + filePath: file.path, + id: 'comment-2', + isReadOnly: true, + kind: 'submitted-comment', + lineNumber: 1, + sectionId: file.sections[0].id, + side: 'additions', +} satisfies ReviewComment; test('stays visible but disabled until a comment with a body exists', async () => { await using app = await renderReact( { const firstFile = createChangedFile('src/first.ts', { fingerprint: 'first-current', @@ -60,3 +62,36 @@ test('review file trees share selection, activation, decorations, and row stylin ).toBe('true'); }); }); + +test('reveals an initially selected restored path exactly once', async () => { + const scrollIntoView = vi + .spyOn(HTMLElement.prototype, 'scrollIntoView') + .mockImplementation(() => {}); + const firstFile = createChangedFile('src/first.ts'); + const restoredFile = createChangedFile('src/restored.ts'); + await using view = await renderReact( + {}} + scrollSelectedPathIntoView + selectedPath={restoredFile.path} + showWhitespace={false} + />, + ); + + await waitFor(() => + expect(scrollIntoView).toHaveBeenCalledWith({ behavior: 'smooth', block: 'center' }), + ); + scrollIntoView.mockClear(); + await view.rerender( + {}} + scrollSelectedPathIntoView + selectedPath={restoredFile.path} + showWhitespace={false} + />, + ); + expect(scrollIntoView).not.toHaveBeenCalled(); + scrollIntoView.mockRestore(); +}); diff --git a/core/__tests__/PullRequestReviewButtons.test.tsx b/core/__tests__/PullRequestReviewButtons.test.tsx index 1e9a535b..b93cec26 100644 --- a/core/__tests__/PullRequestReviewButtons.test.tsx +++ b/core/__tests__/PullRequestReviewButtons.test.tsx @@ -43,14 +43,13 @@ vi.mock('@nkzw/mdx-editor', async () => { }; }); -test('GitHub comment reviews require inline comments or a review body', async () => { +test('provider comment reviews require inline comments or a review body', async () => { const onSubmitReview = vi.fn(async () => {}); await using view = await renderReact( , ); @@ -62,6 +61,8 @@ test('GitHub comment reviews require inline comments or a review body', async () ); expect(submitComments?.disabled).toBe(true); expect(addReviewComment?.disabled).toBe(false); + expect(view.container.querySelector('[aria-label="Approve review"]')).not.toBeNull(); + expect(view.container.querySelector('[aria-label="Request changes"]')).not.toBeNull(); await act(async () => addReviewComment?.click()); const textarea = view.container.querySelector( 'textarea[aria-label="Add review comment"]', @@ -79,14 +80,13 @@ test('GitHub comment reviews require inline comments or a review body', async () }); }); -test('GitHub comment reviews submit pending inline comments without a body', async () => { +test('provider comment reviews submit pending inline comments without a body', async () => { const onSubmitReview = vi.fn(async () => {}); await using view = await renderReact( , ); @@ -98,16 +98,15 @@ test('GitHub comment reviews submit pending inline comments without a body', asy expect(onSubmitReview).toHaveBeenCalledWith('COMMENT'); }); -test('GitHub comment reviews preserve the review body after submission fails', async () => { +test('provider comment reviews preserve the review body after submission fails', async () => { const onSubmitReview = vi.fn(async () => { - throw new Error('GitHub rejected the review.'); + throw new Error('The provider rejected the review.'); }); await using view = await renderReact( , ); @@ -144,7 +143,6 @@ test('comment review remains available when decision reviews are provider-blocke reason: 'You cannot review your own pull request.', }, }} - showCommentReview />, ); @@ -153,12 +151,23 @@ test('comment review remains available when decision reviews are provider-blocke expect(view.container.querySelector('[aria-label="Request changes"]')).toBeNull(); }); -test('non-GitHub reviews keep the existing review actions', async () => { +test('an explicitly disabled comment outcome is not actionable', async () => { await using view = await renderReact( - , + , ); expect(view.container.querySelector('[aria-label="Submit review comments"]')).toBeNull(); + expect(view.container.querySelector('[aria-label="Add review comment"]')).toBeNull(); expect(view.container.querySelector('[aria-label="Approve review"]')).not.toBeNull(); expect(view.container.querySelector('[aria-label="Request changes"]')).not.toBeNull(); }); diff --git a/core/__tests__/RepositoryReviewHost-capabilities.test.tsx b/core/__tests__/RepositoryReviewHost-capabilities.test.tsx new file mode 100644 index 00000000..e729cf06 --- /dev/null +++ b/core/__tests__/RepositoryReviewHost-capabilities.test.tsx @@ -0,0 +1,1829 @@ +/** + * @vitest-environment jsdom + */ + +import { act } from 'react'; +import { beforeEach, expect, test, vi } from 'vite-plus/test'; +import type { CodiffConfig } from '../config/types.ts'; +import { + resolveRepositoryReviewBootstrap, + type RepositoryReviewBootstrap, +} from '../lib/repository-review-bootstrap.ts'; +import type { ReviewSurfaceProps } from '../ReviewSurface.tsx'; +import type { + CodiffLaunchOptions, + GitSha, + NarrativeWalkthrough, + NarrativeWalkthroughResult, + RepositoryHistory, + RepositoryState, + ResolvedReviewSource, + RevisionContentBatchRequest, + RevisionContentBatchResult, + WalkthroughProgressEvent, +} from '../types.ts'; +import { createChangedFile } from './helpers/fixtures.ts'; +import { renderReact, waitFor } from './helpers/react.tsx'; + +const surfaceProps = vi.hoisted(() => vi.fn()); +const writeReloadSelection = vi.hoisted(() => vi.fn()); + +vi.mock('../ReviewSurface.tsx', async (importOriginal) => ({ + ...(await importOriginal()), + ReviewSurface: (props: ReviewSurfaceProps) => { + surfaceProps(props); + return
{props.capabilities?.desktop?.beforeContent}
; + }, +})); + +vi.mock('../lib/reload-selection.ts', async (importOriginal) => ({ + ...(await importOriginal()), + writeReloadSelection, +})); + +import { RepositoryReviewHost } from '../app/RepositoryReviewHost.tsx'; +import { createDefaultConfig } from '../config/defaults.ts'; + +const createMemoryStorage = () => { + const values = new Map(); + return { + clear: () => values.clear(), + getItem: (key: string) => values.get(key) ?? null, + key: (index: number) => [...values.keys()][index] ?? null, + get length() { + return values.size; + }, + removeItem: (key: string) => { + values.delete(key); + }, + setItem: (key: string, value: string) => { + values.set(key, value); + }, + }; +}; + +Object.defineProperty(globalThis, 'localStorage', { + configurable: true, + value: createMemoryStorage(), +}); + +beforeEach(() => { + window.localStorage.clear(); +}); + +const unsubscribe = () => {}; +const gitSha = (character: string) => character.repeat(40) as GitSha; +const deferred = () => { + let resolve!: (value: Value) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((nextResolve, nextReject) => { + resolve = nextResolve; + reject = nextReject; + }); + return { promise, reject, resolve }; +}; + +const stateFor = ( + source: ResolvedReviewSource, + files: RepositoryState['files'] = [], +): RepositoryState => ({ + branch: 'main', + files, + generatedAt: 1, + launchPath: '/repo', + root: '/repo', + source, +}); + +const installWindowApi = (overrides: Record = {}) => { + let findInDiffs: (() => void) | null = null; + let copyPendingComments: (() => string | Promise) | null = null; + let refreshRequest: (() => void) | null = null; + let repositoryChanged: (() => void) | null = null; + let walkthroughProgress: ((progress: WalkthroughProgressEvent) => void) | null = null; + let windowFullScreenChanged: ((isFullScreen: boolean) => void) | null = null; + const api = { + applyUpdate: vi.fn(async () => ({ currentVersion: '0.0.0', phase: 'idle' as const })), + askReviewAssistant: vi.fn(async () => ({ reply: 'Checked.', status: 'ready' as const })), + cancelDiffContentRequest: vi.fn(), + cancelNarrativeWalkthrough: vi.fn(async () => {}), + createWalkthroughCommit: vi.fn(async () => ({ status: 'committed' as const })), + dismissUpdate: vi.fn(async () => ({ currentVersion: '0.0.0', phase: 'idle' as const })), + getDiffImageContent: vi.fn(async () => ({ reason: 'Not used.', status: 'unavailable' })), + getDiffSectionContent: vi.fn(async (request: { kind: string; path: string }) => ({ + binary: false, + id: `${request.path}:${request.kind}`, + kind: request.kind, + loadState: 'ready', + patch: '@@ -1 +1 @@\n-old\n+new\n', + })), + getDiffSectionsContent: vi.fn(async () => ({ sections: [] })), + getNarrativeWalkthrough: vi.fn(async (): Promise => ({ + reason: 'Not used.', + status: 'unavailable', + })), + getRepositoryHistory: vi.fn(async (): Promise => ({ + entries: [], + root: '/repo', + })), + getRepositoryState: vi.fn(async () => stateFor({ type: 'working-tree' })), + getReviewComments: vi.fn(async () => ({ generalComments: [], reviewComments: [] })), + getUpdateStatus: vi.fn(async () => ({ currentVersion: '0.0.0', phase: 'idle' as const })), + isWindowFullScreen: vi.fn(async () => false), + onCopyPendingCommentsRequest: vi.fn((callback: () => string | Promise) => { + copyPendingComments = callback; + return unsubscribe; + }), + onFindInDiffs: vi.fn((callback: () => void) => { + findInDiffs = callback; + return unsubscribe; + }), + onOpenReviewSource: vi.fn(() => unsubscribe), + onRefreshRequest: vi.fn((callback: () => void) => { + refreshRequest = callback; + return unsubscribe; + }), + onRepositoryChanged: vi.fn((callback: () => void) => { + repositoryChanged = callback; + return unsubscribe; + }), + onUpdateStatusChanged: vi.fn(() => unsubscribe), + onWalkthroughCommitOutput: vi.fn(() => unsubscribe), + onWalkthroughProgress: vi.fn((callback: (progress: WalkthroughProgressEvent) => void) => { + walkthroughProgress = callback; + return unsubscribe; + }), + onWindowFullScreenChanged: vi.fn((callback: (isFullScreen: boolean) => void) => { + windowFullScreenChanged = callback; + return unsubscribe; + }), + openConfigFile: vi.fn(async () => {}), + openFile: vi.fn(async () => {}), + openRepositoryFolder: vi.fn(async () => {}), + readRevisionContent: vi.fn( + async (request: RevisionContentBatchRequest): Promise => ({ + results: request.requests.map((item) => { + const bytes = new TextEncoder().encode(`contents for ${item.path}`); + return { + key: item.key, + status: 'ready' as const, + value: { + bytes, + cacheKey: item.key, + path: item.path, + provenance: 'native-git' as const, + size: bytes.byteLength, + }, + }; + }), + }), + ), + reportInitialLoadMilestone: vi.fn(), + resolvePullRequestUrl: vi.fn(async (value: string) => value), + resolveReviewContext: vi.fn(async () => ({ reason: 'Not used.', status: 'unavailable' })), + setDiffStyle: vi.fn(async () => {}), + setShowOutdated: vi.fn(async () => {}), + setWordWrap: vi.fn(async () => {}), + shareWalkthrough: vi.fn(async () => ({ status: 'shared', url: 'https://example.test' })), + submitPullRequestComment: vi.fn(async () => ({})), + submitPullRequestReview: vi.fn(async () => ({ status: 'submitted', submittedDraftIds: [] })), + updateWalkthroughCommitMessage: vi.fn(async () => ({ status: 'unavailable' })), + ...overrides, + }; + window.codiff = api as unknown as Window['codiff']; + return { + api, + copyPendingComments: () => copyPendingComments, + findInDiffs: () => findInDiffs, + refreshRequest: () => refreshRequest, + repositoryChanged: () => repositoryChanged, + walkthroughProgress: () => walkthroughProgress, + windowFullScreenChanged: () => windowFullScreenChanged, + }; +}; + +type RenderHostOptions = { + bootstrap?: Partial; + initialHistoryLoading?: boolean; + initialWalkthroughFileError?: { path: string; reason: string } | null; + initialWalkthroughLoading?: boolean; + initialWalkthroughResult?: NarrativeWalkthroughResult; + launchOptions?: CodiffLaunchOptions; +}; + +const renderHost = async ( + state: RepositoryState, + config = createDefaultConfig(), + options: RenderHostOptions = {}, +) => { + const resolvedLaunchOptions = options.launchOptions ?? { + repositoryPathProvided: true, + walkthrough: false, + }; + const bootstrap = { + ...resolveRepositoryReviewBootstrap({ + launchOptions: resolvedLaunchOptions, + reloadSelection: null, + state, + }), + ...options.bootstrap, + }; + const render = (nextConfig: CodiffConfig) => ( + + ); + const view = await renderReact(render(config)); + return { + ...view, + rerenderConfig: (nextConfig: CodiffConfig) => view.rerender(render(nextConfig)), + }; +}; + +const walkthroughFor = ( + state: RepositoryState, + title: string, + agent: NarrativeWalkthrough['agent'] = 'codex', +): NarrativeWalkthrough => ({ + agent, + chapters: [], + focus: 'Review the generated change.', + generatedAt: '2026-08-05T00:00:00.000Z', + kind: 'narrative', + repo: { branch: state.branch, root: state.root }, + source: state.source, + support: [], + title, + version: 4, +}); + +const getSurfaceProps = () => { + const props = surfaceProps.mock.lastCall?.[0] as ReviewSurfaceProps | undefined; + expect(props).toBeDefined(); + return props!; +}; + +test('routes every resolved Electron review source through the shared surface', async () => { + const sources = [ + { type: 'working-tree' }, + { baseSha: gitSha('a'), headSha: gitSha('b'), ref: 'feature', type: 'branch-diff' }, + { + baseSha: gitSha('a'), + headSha: gitSha('b'), + ref: 'feature', + type: 'branch-working-tree', + }, + { base: 'main', head: 'feature', symmetric: true, type: 'range' }, + { sha: gitSha('c'), type: 'commit' }, + { + headSha: gitSha('d'), + number: 12, + provider: 'github', + type: 'pull-request', + url: 'https://github.com/example/repo/pull/12', + }, + { + headSha: gitSha('e'), + number: 23, + projectPath: 'example/repo', + provider: 'gitlab', + type: 'pull-request', + url: 'https://gitlab.example.com/example/repo/-/merge_requests/23', + }, + { + headSha: gitSha('f'), + number: 24, + type: 'pull-request', + url: 'https://reviews.example.com/example/repo/24', + }, + ] satisfies ReadonlyArray; + + for (const source of sources) { + surfaceProps.mockClear(); + installWindowApi(); + const view = await renderHost(stateFor(source)); + try { + await waitFor(() => expect(surfaceProps).toHaveBeenCalled()); + const props = getSurfaceProps(); + expect(props.snapshot.repository.source).toEqual(source); + expect(props.capabilities?.desktop).toBeDefined(); + expect(props.capabilities?.history?.currentSource).toEqual(source); + expect(props.capabilities?.content?.onLoadSection).toEqual(expect.any(Function)); + const hasProviderDestination = + source.type === 'pull-request' && + (source.provider === 'github' || source.provider === 'gitlab'); + expect(props.capabilities?.comments?.destination === 'provider').toBe(hasProviderDestination); + expect( + props.capabilities?.comments?.destination === 'provider' && + props.capabilities.comments.reviewSession != null, + ).toBe(hasProviderDestination); + expect(props.capabilities?.localReviewNotes != null).toBe(source.type !== 'pull-request'); + if (source.type === 'pull-request' && !hasProviderDestination) { + expect(props.capabilities?.comments).toBeUndefined(); + expect(props.capabilities?.localReviewNotes).toBeUndefined(); + } + } finally { + await view.cleanup(); + } + } +}); + +test('wires desktop commands, persistence, preferences, loading, and exact provider operations', async () => { + surfaceProps.mockClear(); + writeReloadSelection.mockClear(); + const { api, copyPendingComments, findInDiffs } = installWindowApi(); + const config = createDefaultConfig(); + config.settings.reviewCommentsPrefix = 'Team review notes'; + config.settings.showOutdated = false; + config.settings.wordWrap = true; + const source = { + headSha: gitSha('f'), + number: 12, + provider: 'github', + type: 'pull-request', + url: 'https://github.com/example/repo/pull/12', + } as const; + const view = await renderHost(stateFor(source), config); + + try { + await waitFor(() => expect(surfaceProps).toHaveBeenCalled()); + const props = getSurfaceProps(); + const capabilities = props.capabilities!; + const commandIds = capabilities.desktop?.commands?.map((command) => command.id) ?? []; + expect(commandIds).toEqual( + expect.arrayContaining([ + 'copy-comments', + 'decrease-code-font-size', + 'increase-code-font-size', + 'open-config-file', + 'open-file', + 'reload', + 'reset-code-font-size', + 'toggle-diff-layout', + 'toggle-outdated-comments', + 'toggle-viewed', + ]), + ); + expect( + capabilities.desktop?.commands?.find((command) => command.id === 'copy-comments')?.title, + ).toBe('Copy Pending Review Comments'); + expect( + capabilities.desktop?.commands?.find((command) => command.id === 'copy-comments-and-close') + ?.title, + ).toBe('Copy Pending Review Comments and Close'); + const imageFile = createChangedFile('image.png', { kind: 'pull-request' }); + await capabilities.content?.resolveImage?.(imageFile, imageFile.sections[0]!); + expect(api.readRevisionContent).toHaveBeenCalledOnce(); + expect(api.readRevisionContent.mock.calls[0]![0].requests).toHaveLength(2); + const comments = capabilities.comments; + expect(comments?.destination).toBe('provider'); + if (!comments || comments.destination !== 'provider') { + throw new Error('Expected provider comment capabilities.'); + } + expect(comments.authoring.onAsk).toEqual(expect.any(Function)); + expect(capabilities.desktop?.onOpenFile).toEqual(expect.any(Function)); + expect(capabilities.history).toBeDefined(); + expect(capabilities.walkthrough?.onShare).toEqual(expect.any(Function)); + + const copyPacket = vi.fn(() => '# Pending provider packet'); + const bridge = { + copyPendingComments: copyPacket, + getPersistenceState: () => ({ mode: 'tree' as const, selectedPath: 'src/provider.ts' }), + openDiffSearch: vi.fn(), + }; + await act(async () => props.onCommandBridgeChange?.(bridge)); + findInDiffs()?.(); + expect(bridge.openDiffSearch).toHaveBeenCalledTimes(1); + expect(copyPendingComments()?.()).toBe('# Pending provider packet'); + expect(copyPacket).toHaveBeenCalledTimes(1); + window.dispatchEvent(new Event('beforeunload')); + expect(writeReloadSelection).toHaveBeenCalledWith( + expect.objectContaining({ source }), + 'src/provider.ts', + source, + 'review', + ); + + await capabilities.preferences?.wordWrap?.onChange(false); + await capabilities.preferences?.outdatedVisibility?.onChange(true); + expect(api.setWordWrap).toHaveBeenCalledWith(false); + expect(api.setShowOutdated).toHaveBeenCalledWith(true); + expect(capabilities.preferences?.wordWrap?.value).toBe(true); + expect(capabilities.preferences?.outdatedVisibility?.value).toBe(false); + expect(capabilities.preferences?.pendingCommentPrefix?.value).toBe('Team review notes'); + + expect(comments).toMatchObject({ + authoring: { canCreateInline: true, onAsk: expect.any(Function) }, + destination: 'provider', + inline: { onSubmit: expect.any(Function) }, + reviewSession: { + drafts: { onChange: expect.any(Function), value: expect.any(Array) }, + submit: expect.any(Function), + }, + }); + expect(comments.general).toBeUndefined(); + + const comment = { + body: 'Submit this comment.', + filePath: 'src/provider.ts', + lineNumber: 1, + localDraftId: 'provider-draft', + position: { + range: { + base: { label: { kind: 'commit' as const, text: 'base' }, sha: gitSha('e') }, + head: { label: { kind: 'commit' as const, text: 'head' }, sha: gitSha('f') }, + }, + }, + side: 'additions' as const, + }; + await comments.inline.onSubmit?.(comment); + expect(api.submitPullRequestComment).toHaveBeenCalledWith({ comment, source }); + await comments.reviewSession?.submit({ + comments: [comment], + outcome: 'request-changes', + summary: 'Please address the inline feedback.', + }); + expect(api.submitPullRequestReview).toHaveBeenCalledWith({ + body: 'Please address the inline feedback.', + comments: [comment], + event: 'REQUEST_CHANGES', + source, + }); + } finally { + await view.cleanup(); + } +}); + +test('uses source-aware copy labels and default Markdown headings', async () => { + for (const source of [ + { type: 'working-tree' } as const, + { + headSha: gitSha('f'), + number: 12, + provider: 'github', + type: 'pull-request', + url: 'https://github.com/example/repo/pull/12', + } as const, + ]) { + surfaceProps.mockClear(); + installWindowApi(); + const view = await renderHost(stateFor(source)); + try { + await waitFor(() => expect(surfaceProps).toHaveBeenCalled()); + const capabilities = getSurfaceProps().capabilities!; + const provider = source.type === 'pull-request'; + expect( + capabilities.desktop?.commands?.find((command) => command.id === 'copy-comments')?.title, + ).toBe(provider ? 'Copy Pending Review Comments' : 'Copy Review Notes'); + expect(capabilities.preferences?.pendingCommentPrefix?.value).toBe( + provider ? '# Address these Pending Review Comments' : '# Address these Review Notes', + ); + } finally { + await view.cleanup(); + } + } +}); + +test('asks the review assistant with the flushed note value supplied by the surface', async () => { + surfaceProps.mockClear(); + const { api } = installWindowApi(); + const file = createChangedFile('src/immediate-ask.ts'); + const state = stateFor({ type: 'working-tree' }, [file]); + const view = await renderHost(state); + + try { + await waitFor(() => expect(surfaceProps).toHaveBeenCalled()); + await act(async () => { + getSurfaceProps().capabilities?.localReviewNotes?.onAsk?.({ + body: 'Check the just-flushed draft.', + filePath: file.path, + id: 'immediate-note', + kind: 'local-note', + lineNumber: 1, + sectionId: file.sections[0]!.id, + side: 'additions', + }); + }); + expect(api.askReviewAssistant).toHaveBeenCalledWith({ + comment: { + body: 'Check the just-flushed draft.', + filePath: file.path, + lineNumber: 1, + sectionId: file.sections[0]!.id, + side: 'additions', + }, + source: state.source, + }); + } finally { + await view.cleanup(); + } +}); + +test('opening a provider review eagerly reads exact content before mounting the surface', async () => { + surfaceProps.mockClear(); + const { api } = installWindowApi(); + const source = { + headSha: gitSha('d'), + number: 14, + provider: 'github', + type: 'pull-request', + url: 'https://github.com/example/repo/pull/14', + } as const; + const file = createChangedFile('src/stale-provider.ts', { kind: 'pull-request' }); + const deferredFile = { + ...file, + sections: file.sections.map((section) => ({ + ...section, + loadState: 'deferred' as const, + summary: { canLoad: true, reason: 'Exact contents are queued.' }, + })), + }; + const view = await renderHost(stateFor(source, [deferredFile])); + + try { + await waitFor(() => expect(surfaceProps).toHaveBeenCalled()); + expect(api.getDiffSectionsContent).not.toHaveBeenCalled(); + expect(api.getDiffSectionContent).not.toHaveBeenCalled(); + expect(api.readRevisionContent).toHaveBeenCalledOnce(); + expect(api.readRevisionContent.mock.calls[0]![0].requests).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + path: deferredFile.path, + revision: file.sections[0]!.range!.base, + }), + expect.objectContaining({ + path: deferredFile.path, + revision: file.sections[0]!.range!.head, + }), + ]), + ); + } finally { + await view.cleanup(); + } +}); + +test('explicit provider content loading reads and caches only the selected file range', async () => { + surfaceProps.mockClear(); + const { api } = installWindowApi(); + const source = { + headSha: gitSha('d'), + number: 14, + provider: 'github', + type: 'pull-request', + url: 'https://github.com/example/repo/pull/14', + } as const; + const selected = createChangedFile('src/selected.ts', { kind: 'pull-request' }); + const other = createChangedFile('src/other.ts', { kind: 'pull-request' }); + const view = await renderHost(stateFor(source, [selected, other])); + + try { + await waitFor(() => expect(surfaceProps).toHaveBeenCalled()); + const resolve = getSurfaceProps().capabilities?.content?.resolveSectionContents; + expect(resolve).toBeDefined(); + await act(async () => { + await resolve?.(selected, selected.sections[0]!); + await resolve?.(selected, selected.sections[0]!); + }); + + expect(api.readRevisionContent).toHaveBeenCalledOnce(); + const requests = api.readRevisionContent.mock.calls[0]![0].requests; + expect(requests).toHaveLength(2); + expect(requests.every((request) => request.path === selected.path)).toBe(true); + } finally { + await view.cleanup(); + } +}); + +test('loads deferred section content for supported Electron sources', async () => { + surfaceProps.mockClear(); + const { api } = installWindowApi(); + const file = createChangedFile('src/local.ts'); + const deferredFile = { + ...file, + sections: file.sections.map((section) => ({ + ...section, + loadState: 'deferred' as const, + summary: { canLoad: true, reason: 'Load local contents.' }, + })), + }; + const view = await renderHost(stateFor({ type: 'working-tree' }, [deferredFile])); + + try { + await waitFor(() => expect(surfaceProps).toHaveBeenCalled()); + await waitFor(() => expect(api.readRevisionContent).toHaveBeenCalledTimes(1)); + expect(api.readRevisionContent).toHaveBeenCalledWith({ + generation: expect.any(String), + requestId: expect.stringMatching(/^revision-content:/), + requests: expect.arrayContaining([ + expect.objectContaining({ path: 'src/local.ts', revision: file.sections[0]!.range!.base }), + expect.objectContaining({ path: 'src/local.ts', revision: file.sections[0]!.range!.head }), + ]), + source: { type: 'working-tree' }, + }); + await waitFor(() => + expect(getSurfaceProps().capabilities?.content?.itemVersionByKey).toEqual({ + 'src/local.ts': 1, + }), + ); + } finally { + await view.cleanup(); + } +}); + +test('bumps the mounted review key when deferred loading fails', async () => { + surfaceProps.mockClear(); + const { api } = installWindowApi(); + api.readRevisionContent.mockRejectedValueOnce(new Error('content unavailable')); + const file = createChangedFile('src/failure.ts'); + const deferredFile = { + ...file, + sections: file.sections.map((section) => ({ + ...section, + loadState: 'deferred' as const, + summary: { canLoad: true, reason: 'Load local contents.' }, + })), + }; + const view = await renderHost(stateFor({ type: 'working-tree' }, [deferredFile])); + + try { + await waitFor(() => expect(api.readRevisionContent).toHaveBeenCalledTimes(1)); + await waitFor(() => + expect(getSurfaceProps().capabilities?.content?.itemVersionByKey).toEqual({ + 'src/failure.ts': 1, + }), + ); + } finally { + await view.cleanup(); + } +}); + +test('shows the repository-change banner without reading repository state', async () => { + surfaceProps.mockClear(); + const { api, repositoryChanged } = installWindowApi(); + const view = await renderHost( + stateFor({ type: 'working-tree' }, [createChangedFile('src/notification.ts')]), + ); + api.getRepositoryState.mockClear(); + + try { + await waitFor(() => expect(repositoryChanged()).toEqual(expect.any(Function))); + await act(async () => repositoryChanged()?.()); + await waitFor(() => + expect(view.container.querySelector('.repository-change-banner.visible')).not.toBeNull(), + ); + expect(api.getRepositoryState).not.toHaveBeenCalled(); + } finally { + await view.cleanup(); + } +}); + +test('forwards initial and refresh-calculated reload deltas', async () => { + surfaceProps.mockClear(); + const { api, refreshRequest } = installWindowApi(); + const initialFile = createChangedFile('src/reload.ts', { + fingerprint: 'before', + patch: 'before', + }); + const refreshedFile = createChangedFile('src/reload.ts', { + fingerprint: 'after', + patch: 'after', + }); + api.getRepositoryState.mockResolvedValueOnce(stateFor({ type: 'working-tree' }, [refreshedFile])); + const view = await renderHost(stateFor({ type: 'working-tree' }, [initialFile]), undefined, { + bootstrap: { reloadDeltaPaths: new Set(['src/initial.ts']) }, + }); + + try { + await waitFor(() => + expect(getSurfaceProps().capabilities?.desktop?.reloadDeltaPaths).toEqual( + new Set(['src/initial.ts']), + ), + ); + await waitFor(() => expect(refreshRequest()).toEqual(expect.any(Function))); + await act(async () => refreshRequest()?.()); + await waitFor(() => + expect(getSurfaceProps().capabilities?.desktop?.reloadDeltaPaths).toEqual( + new Set(['src/reload.ts']), + ), + ); + } finally { + await view.cleanup(); + } +}); + +test('refreshes repository and History state atomically', async () => { + surfaceProps.mockClear(); + const { api, refreshRequest } = installWindowApi(); + const initialState = stateFor({ type: 'working-tree' }, [createChangedFile('src/before.ts')]); + const refreshedState = stateFor({ type: 'working-tree' }, [createChangedFile('src/after.ts')]); + const historyEntry = { + author: 'Ada Lovelace', + committedAt: Date.now(), + parentShas: [], + sha: gitSha('d'), + subject: 'Refresh atomically', + }; + api.getRepositoryState.mockResolvedValueOnce(refreshedState); + api.getRepositoryHistory.mockResolvedValueOnce({ entries: [historyEntry], root: '/repo' }); + const view = await renderHost(initialState); + + try { + await waitFor(() => expect(refreshRequest()).toEqual(expect.any(Function))); + await act(async () => refreshRequest()?.()); + await waitFor(() => { + const props = getSurfaceProps(); + expect(props.snapshot.files.map((file) => file.path)).toEqual(['src/after.ts']); + expect(props.capabilities?.history?.entries).toEqual([historyEntry]); + }); + expect(view.container.textContent).toContain('Review updated.'); + } finally { + await view.cleanup(); + } +}); + +test('an unchanged refresh preserves hydrated files and in-flight content and walkthrough work', async () => { + surfaceProps.mockClear(); + const { api, refreshRequest } = installWindowApi(); + const baseFile = createChangedFile('src/stable.ts', { fingerprint: 'initial-provider-state' }); + const file = { + ...baseFile, + sections: baseFile.sections.map((section) => ({ + ...section, + loadState: 'deferred' as const, + summary: { canLoad: true, reason: 'Load exact contents.' }, + })), + }; + const refreshedFile = { + ...file, + fingerprint: 'new-provider-state', + sections: file.sections.map((section) => ({ + ...section, + id: 'provider-reordered-section', + summary: { canLoad: true, reason: 'Different hydration metadata.' }, + })), + }; + const content = deferred(); + const walkthrough = deferred(); + api.getRepositoryState.mockResolvedValueOnce(stateFor({ type: 'working-tree' }, [refreshedFile])); + api.readRevisionContent.mockImplementationOnce(() => content.promise); + api.getNarrativeWalkthrough.mockImplementationOnce(() => walkthrough.promise); + const view = await renderHost(stateFor({ type: 'working-tree' }, [file])); + + try { + await waitFor(() => expect(refreshRequest()).toEqual(expect.any(Function))); + await act(async () => { + void getSurfaceProps().capabilities?.content?.onLoadSection?.(file, file.sections[0]); + void getSurfaceProps().capabilities?.walkthrough?.onGenerate?.(); + await Promise.resolve(); + }); + expect(api.readRevisionContent).toHaveBeenCalledOnce(); + expect(api.getNarrativeWalkthrough).toHaveBeenCalledOnce(); + + await act(async () => refreshRequest()?.()); + await waitFor(() => expect(view.container.textContent).toContain('Review is up to date.')); + expect(getSurfaceProps().snapshot.files[0].fingerprint).toBe('initial-provider-state'); + expect(api.cancelDiffContentRequest).not.toHaveBeenCalled(); + expect(api.getNarrativeWalkthrough).toHaveBeenCalledOnce(); + + await act(async () => { + const request = api.readRevisionContent.mock.calls[0]![0]; + content.resolve({ + results: request.requests.map((item) => { + const bytes = new TextEncoder().encode(`contents for ${item.path}`); + return { + key: item.key, + status: 'ready' as const, + value: { + bytes, + cacheKey: item.key, + path: item.path, + provenance: 'native-git' as const, + size: bytes.byteLength, + }, + }; + }), + }); + await content.promise; + walkthrough.resolve({ + status: 'ready', + walkthrough: walkthroughFor(stateFor({ type: 'working-tree' }, [file]), 'Preserved work'), + }); + await walkthrough.promise; + }); + await waitFor(() => + expect(getSurfaceProps().snapshot.walkthrough.title).toBe('Preserved work'), + ); + expect(getSurfaceProps().capabilities?.content?.itemVersionByKey).toEqual({ + 'src/stable.ts': 1, + }); + } finally { + await view.cleanup(); + } +}); + +test('shows refresh progress and keeps failures retryable', async () => { + surfaceProps.mockClear(); + const { api, refreshRequest } = installWindowApi(); + const state = stateFor({ type: 'working-tree' }, [createChangedFile('src/retry-refresh.ts')]); + const failedRefresh = deferred(); + api.getRepositoryState + .mockImplementationOnce(() => failedRefresh.promise) + .mockResolvedValueOnce(state); + const view = await renderHost(state); + + try { + await waitFor(() => expect(refreshRequest()).toEqual(expect.any(Function))); + await act(async () => refreshRequest()?.()); + expect(view.container.textContent).toContain('Refreshing review…'); + + await act(async () => { + failedRefresh.reject(new Error('Repository unavailable.')); + await failedRefresh.promise.catch(() => {}); + }); + await waitFor(() => expect(view.container.textContent).toContain('Refresh failed.')); + expect(view.container.textContent).toContain('Repository unavailable.'); + + const retry = Array.from(view.container.querySelectorAll('button')).find( + (button) => button.textContent?.trim() === 'Retry', + ); + expect(retry).toBeDefined(); + await act(async () => retry?.click()); + await waitFor(() => expect(view.container.textContent).toContain('Review is up to date.')); + expect(api.getRepositoryState).toHaveBeenCalledTimes(2); + } finally { + await view.cleanup(); + } +}); + +test('expands changed viewed files while leaving unchanged viewed files collapsed', async () => { + surfaceProps.mockClear(); + const { api, refreshRequest } = installWindowApi(); + const changed = createChangedFile('src/changed-viewed.ts', { fingerprint: 'before' }); + const unchanged = createChangedFile('src/unchanged-viewed.ts', { fingerprint: 'stable' }); + const initialState = stateFor({ type: 'working-tree' }, [changed, unchanged]); + const refreshedState = stateFor({ type: 'working-tree' }, [ + createChangedFile(changed.path, { fingerprint: 'after', patch: 'after' }), + unchanged, + ]); + api.getRepositoryState.mockResolvedValueOnce(refreshedState); + const view = await renderHost(initialState); + + try { + await waitFor(() => expect(surfaceProps).toHaveBeenCalled()); + await act(async () => { + getSurfaceProps().capabilities?.desktop?.onCollapsedChange?.( + new Set([changed.path, unchanged.path]), + ); + }); + await waitFor(() => + expect(getSurfaceProps().capabilities?.desktop?.collapsed).toEqual( + new Set([changed.path, unchanged.path]), + ), + ); + await act(async () => refreshRequest()?.()); + await waitFor(() => + expect(getSurfaceProps().capabilities?.desktop?.collapsed).toEqual(new Set([unchanged.path])), + ); + } finally { + await view.cleanup(); + } +}); + +test('changed reviews keep the old walkthrough until an explicit replacement finishes', async () => { + surfaceProps.mockClear(); + const { api, refreshRequest } = installWindowApi(); + const initialState = stateFor({ type: 'working-tree' }, [createChangedFile('src/initial.ts')]); + const firstState = stateFor({ type: 'working-tree' }, [createChangedFile('src/first.ts')]); + const secondState = stateFor({ type: 'working-tree' }, [createChangedFile('src/second.ts')]); + const firstWalkthrough = deferred(); + const secondWalkthrough = deferred(); + api.getRepositoryState.mockResolvedValueOnce(firstState).mockResolvedValueOnce(secondState); + api.getNarrativeWalkthrough + .mockImplementationOnce(() => firstWalkthrough.promise) + .mockImplementationOnce(() => secondWalkthrough.promise); + const view = await renderHost(initialState, undefined, { + initialWalkthroughResult: { + status: 'ready', + walkthrough: walkthroughFor(initialState, 'Initial walkthrough'), + }, + }); + + try { + await waitFor(() => expect(refreshRequest()).toEqual(expect.any(Function))); + await act(async () => refreshRequest()?.()); + await waitFor(() => + expect(getSurfaceProps().snapshot.files.map((file) => file.path)).toEqual(['src/first.ts']), + ); + expect(api.getNarrativeWalkthrough).not.toHaveBeenCalled(); + expect(getSurfaceProps().snapshot.walkthrough.title).toBe('Initial walkthrough'); + expect(view.container.querySelector('.repository-refresh-banner.stale')).not.toBeNull(); + + const restart = () => + Array.from(view.container.querySelectorAll('button')).find( + (button) => button.textContent?.trim() === 'Restart generation', + ); + await act(async () => restart()?.click()); + await waitFor(() => expect(api.getNarrativeWalkthrough).toHaveBeenCalledTimes(1)); + + await act(async () => refreshRequest()?.()); + await waitFor(() => + expect(getSurfaceProps().snapshot.files.map((file) => file.path)).toEqual(['src/second.ts']), + ); + expect(api.getNarrativeWalkthrough).toHaveBeenCalledTimes(1); + expect(view.container.querySelector('.repository-refresh-banner.stale')).not.toBeNull(); + + await act(async () => { + firstWalkthrough.resolve({ + status: 'ready', + walkthrough: walkthroughFor(firstState, 'Old-code walkthrough'), + }); + await firstWalkthrough.promise; + }); + await waitFor(() => expect(getSurfaceProps().capabilities?.walkthrough?.status).toBe('ready')); + expect(getSurfaceProps().snapshot.walkthrough.title).toBe('Initial walkthrough'); + expect(view.container.querySelector('.repository-refresh-banner.stale')).not.toBeNull(); + + await act(async () => restart()?.click()); + await waitFor(() => expect(api.getNarrativeWalkthrough).toHaveBeenCalledTimes(2)); + await act(async () => { + secondWalkthrough.resolve({ + status: 'ready', + walkthrough: walkthroughFor(secondState, 'Current walkthrough'), + }); + await secondWalkthrough.promise; + }); + await waitFor(() => + expect(getSurfaceProps().snapshot.walkthrough.title).toBe('Current walkthrough'), + ); + expect(view.container.querySelector('.repository-refresh-banner.stale')).toBeNull(); + } finally { + await view.cleanup(); + } +}); + +test('refresh exits commit mode for an empty tree and allows a new walkthrough after editing', async () => { + surfaceProps.mockClear(); + const { api, refreshRequest } = installWindowApi(); + const initialState = stateFor({ type: 'working-tree' }, [createChangedFile('src/committed.ts')]); + const emptyState = stateFor({ type: 'working-tree' }); + const editedState = stateFor({ type: 'working-tree' }, [createChangedFile('src/edited.ts')]); + api.getRepositoryState.mockResolvedValueOnce(emptyState).mockResolvedValueOnce(editedState); + const view = await renderHost(initialState, undefined, { + bootstrap: { mainMode: 'commit' }, + initialWalkthroughResult: { + status: 'ready', + walkthrough: walkthroughFor(initialState, 'Committed walkthrough'), + }, + }); + + try { + await waitFor(() => expect(getSurfaceProps().capabilities?.desktop?.commit?.open).toBe(true)); + await act(async () => refreshRequest()?.()); + await waitFor(() => expect(getSurfaceProps().capabilities?.desktop?.commit).toBeUndefined()); + + await act(async () => refreshRequest()?.()); + await waitFor(() => + expect(getSurfaceProps().snapshot.files.map((file) => file.path)).toEqual(['src/edited.ts']), + ); + api.getNarrativeWalkthrough.mockClear(); + await act(async () => getSurfaceProps().activeMode?.onChange('walkthrough')); + expect(api.getNarrativeWalkthrough).not.toHaveBeenCalled(); + const restart = Array.from(view.container.querySelectorAll('button')).find( + (button) => button.textContent?.trim() === 'Restart generation', + ); + expect(restart).toBeDefined(); + await act(async () => restart?.click()); + await waitFor(() => expect(api.getNarrativeWalkthrough).toHaveBeenCalledTimes(1)); + } finally { + await view.cleanup(); + } +}); + +test('uses the bootstrap mode and one-shot scroll target as controlled surface state', async () => { + surfaceProps.mockClear(); + installWindowApi(); + const files = [createChangedFile('src/first.ts'), createChangedFile('src/restored.ts')]; + const view = await renderHost(stateFor({ type: 'working-tree' }, files), undefined, { + bootstrap: { + initialScrollTarget: { + behavior: 'instant', + path: 'src/restored.ts', + request: 1, + }, + selectedPath: 'src/restored.ts', + sidebarMode: 'history', + }, + }); + + try { + await waitFor(() => expect(surfaceProps).toHaveBeenCalled()); + const props = getSurfaceProps(); + expect(props.activeMode?.value).toBe('history'); + expect(props).not.toHaveProperty('initialMode'); + expect(props.capabilities?.content?.initialScrollTarget).toEqual({ + behavior: 'instant', + path: 'src/restored.ts', + request: 1, + }); + expect(props.capabilities?.preferences?.selectedPath?.value).toBe('src/restored.ts'); + } finally { + await view.cleanup(); + } +}); + +test('keeps desktop file state, fullscreen state, and active walkthrough targets controlled', async () => { + surfaceProps.mockClear(); + const { api, windowFullScreenChanged } = installWindowApi(); + const firstFile = createChangedFile('src/first.ts'); + const activeFile = createChangedFile('src/active.ts'); + const state = stateFor({ type: 'working-tree' }, [firstFile, activeFile]); + const view = await renderHost(state, undefined, { + bootstrap: { sidebarMode: 'walkthrough' }, + initialWalkthroughResult: { status: 'ready', walkthrough: walkthroughFor(state, 'Ready') }, + }); + + try { + await waitFor(() => expect(surfaceProps).toHaveBeenCalled()); + expect(getSurfaceProps().capabilities?.desktop).toMatchObject({ + collapsed: new Set(), + isWindowFullscreen: false, + viewed: {}, + }); + + await act(async () => { + getSurfaceProps().capabilities?.desktop?.onCollapsedChange?.(new Set(['src/active.ts'])); + getSurfaceProps().capabilities?.desktop?.onViewedChange?.({ + 'src/active.ts': activeFile.fingerprint, + }); + }); + await waitFor(() => + expect(getSurfaceProps().capabilities?.desktop).toMatchObject({ + collapsed: new Set(['src/active.ts']), + viewed: { 'src/active.ts': activeFile.fingerprint }, + }), + ); + + await waitFor(() => expect(windowFullScreenChanged()).toEqual(expect.any(Function))); + await act(async () => windowFullScreenChanged()?.(true)); + await waitFor(() => + expect(getSurfaceProps().capabilities?.desktop?.isWindowFullscreen).toBe(true), + ); + + await act(async () => { + getSurfaceProps().capabilities?.desktop?.onActiveWalkthroughReviewTargetChange?.({ + file: activeFile, + reviewIdentity: { fingerprint: activeFile.fingerprint, key: 'walkthrough:active' }, + }); + getSurfaceProps().capabilities?.desktop?.onOpenSelectedFile?.(); + }); + expect(api.openFile).toHaveBeenCalledWith('src/active.ts'); + } finally { + await view.cleanup(); + } +}); + +test('cancels active walkthrough work when History switches sources', async () => { + surfaceProps.mockClear(); + const { api, walkthroughProgress } = installWindowApi(); + const sourceAState = stateFor({ type: 'working-tree' }, [createChangedFile('src/a.ts')]); + const sourceB = { sha: gitSha('b'), type: 'commit' } as const; + const sourceBRequest = { ref: gitSha('b'), type: 'commit' } as const; + const sourceBState = stateFor(sourceB, [createChangedFile('src/b.ts')]); + const pending = deferred(); + api.getNarrativeWalkthrough.mockImplementationOnce(() => pending.promise); + api.getRepositoryState.mockResolvedValueOnce(sourceBState); + const view = await renderHost(sourceAState); + + try { + await waitFor(() => expect(surfaceProps).toHaveBeenCalled()); + await act(async () => { + void getSurfaceProps().capabilities?.walkthrough?.onGenerate?.(); + await Promise.resolve(); + }); + expect(api.getNarrativeWalkthrough).toHaveBeenCalledWith({ type: 'working-tree' }, undefined); + await act(async () => + walkthroughProgress()?.({ + generation: { phase: 'generating', summary: 'Source A progress.' }, + }), + ); + expect(getSurfaceProps().capabilities?.walkthrough?.generationProgress?.summary).toBe( + 'Source A progress.', + ); + + await act(async () => getSurfaceProps().capabilities?.history?.onSelectSource(sourceBRequest)); + expect(api.cancelNarrativeWalkthrough).toHaveBeenCalledOnce(); + await waitFor(() => expect(getSurfaceProps().snapshot.repository.source).toEqual(sourceB)); + + await act(async () => + walkthroughProgress()?.({ + generation: { phase: 'generating', summary: 'Stale source A progress.' }, + phase: 'response-received', + }), + ); + expect(getSurfaceProps().capabilities?.walkthrough?.generationProgress?.summary).toBe( + 'Source A progress.', + ); + + await act(async () => { + pending.resolve({ + status: 'ready', + walkthrough: walkthroughFor(sourceAState, 'Stale source A walkthrough'), + }); + await pending.promise; + }); + expect(getSurfaceProps().snapshot.repository.source).toEqual(sourceB); + expect(getSurfaceProps().snapshot.walkthrough.title).not.toBe('Stale source A walkthrough'); + expect(getSurfaceProps().capabilities?.walkthrough).toMatchObject({ + status: 'idle', + unread: false, + }); + } finally { + await view.cleanup(); + } +}); + +test('source selection without active generation skips main-process cancellation', async () => { + surfaceProps.mockClear(); + const { api } = installWindowApi(); + const initialState = stateFor({ type: 'working-tree' }, [createChangedFile('src/initial.ts')]); + const nextSource = { ref: gitSha('c'), type: 'commit' } as const; + api.getRepositoryState.mockResolvedValueOnce( + stateFor({ sha: gitSha('c'), type: 'commit' }, [createChangedFile('src/next.ts')]), + ); + const view = await renderHost(initialState); + + try { + await waitFor(() => expect(surfaceProps).toHaveBeenCalled()); + await act(async () => getSurfaceProps().capabilities?.history?.onSelectSource(nextSource)); + await waitFor(() => + expect(getSurfaceProps().snapshot.repository.source).toEqual({ + sha: gitSha('c'), + type: 'commit', + }), + ); + expect(api.cancelNarrativeWalkthrough).not.toHaveBeenCalled(); + } finally { + await view.cleanup(); + } +}); + +test('same-source mode changes do not cancel active walkthrough generation', async () => { + surfaceProps.mockClear(); + const pending = deferred(); + const { api } = installWindowApi({ + getNarrativeWalkthrough: vi.fn(() => pending.promise), + }); + const view = await renderHost( + stateFor({ type: 'working-tree' }, [createChangedFile('src/current.ts')]), + ); + + try { + await waitFor(() => expect(surfaceProps).toHaveBeenCalled()); + await act(async () => { + void getSurfaceProps().capabilities?.walkthrough?.onGenerate?.(); + await Promise.resolve(); + }); + await waitFor(() => + expect(getSurfaceProps().capabilities?.walkthrough?.status).toBe('generating'), + ); + + await act(async () => getSurfaceProps().activeMode?.onChange('walkthrough')); + await act(async () => getSurfaceProps().activeMode?.onChange('tree')); + await act(async () => getSurfaceProps().activeMode?.onChange('comments')); + expect(api.cancelNarrativeWalkthrough).not.toHaveBeenCalled(); + + await act(async () => { + pending.resolve({ reason: 'Not used.', status: 'unavailable' }); + await pending.promise; + }); + } finally { + await view.cleanup(); + } +}); + +test('ignores a deferred History result after the review source changes', async () => { + surfaceProps.mockClear(); + const firstHistory = deferred(); + const secondHistory = deferred(); + const { api } = installWindowApi({ + getRepositoryHistory: vi + .fn() + .mockImplementationOnce(() => firstHistory.promise) + .mockImplementationOnce(() => secondHistory.promise), + }); + const initialState = stateFor({ type: 'working-tree' }, [createChangedFile('src/initial.ts')]); + const nextSource = { + headSha: gitSha('d'), + number: 42, + owner: 'example', + provider: 'github', + repo: 'repo', + type: 'pull-request', + url: 'https://github.com/example/repo/pull/42', + } as const; + const nextSourceRequest = nextSource; + const nextState = stateFor(nextSource, [createChangedFile('src/next.ts')]); + api.getRepositoryState.mockResolvedValueOnce(nextState); + const view = await renderHost(initialState, undefined, { initialHistoryLoading: true }); + + try { + await waitFor(() => expect(api.getRepositoryHistory).toHaveBeenCalledTimes(1)); + await act(async () => + getSurfaceProps().capabilities?.history?.onSelectSource(nextSourceRequest), + ); + await waitFor(() => expect(api.getRepositoryHistory).toHaveBeenCalledTimes(2)); + + await act(async () => { + firstHistory.resolve({ + entries: [ + { + author: 'Stale', + committedAt: Date.now(), + parentShas: [], + sha: gitSha('a'), + subject: 'Stale history', + }, + ], + root: '/repo', + }); + await firstHistory.promise; + }); + expect(getSurfaceProps().capabilities?.history?.entries).toEqual([]); + + const currentEntry = { + author: 'Current', + committedAt: Date.now(), + parentShas: [], + sha: gitSha('b'), + subject: 'Current history', + }; + await act(async () => { + secondHistory.resolve({ entries: [currentEntry], root: '/repo' }); + await secondHistory.promise; + }); + await waitFor(() => + expect(getSurfaceProps().capabilities?.history?.entries).toEqual([currentEntry]), + ); + } finally { + await view.cleanup(); + } +}); + +test('keeps provider and local drafts in their own History source sessions', async () => { + surfaceProps.mockClear(); + const { api } = installWindowApi(); + const pullRequestSource = { + headSha: gitSha('f'), + number: 12, + owner: 'example', + provider: 'github', + repo: 'repo', + type: 'pull-request', + url: 'https://github.com/example/repo/pull/12', + } as const; + const commitSha = gitSha('a'); + const commitRequest = { ref: commitSha, type: 'commit' } as const; + const commitSource = { sha: commitSha, type: 'commit' } as const; + const pullRequestState = stateFor(pullRequestSource, [createChangedFile('src/provider.ts')]); + const commitState = stateFor(commitSource, [createChangedFile('src/commit.ts')]); + api.getRepositoryState + .mockResolvedValueOnce(commitState) + .mockResolvedValueOnce(pullRequestState) + .mockResolvedValueOnce(commitState); + const view = await renderHost(pullRequestState); + const providerDraft = { + body: 'Keep this provider draft.', + filePath: 'src/provider.ts', + id: 'provider-draft', + kind: 'provider-draft' as const, + lineNumber: 1, + position: { + range: { + base: { label: { kind: 'commit' as const, text: 'base' }, sha: gitSha('e') }, + head: { label: { kind: 'commit' as const, text: 'head' }, sha: gitSha('f') }, + }, + }, + sectionId: 'src/provider.ts:unstaged', + side: 'additions' as const, + }; + const localNote = { + body: 'Keep this local commit note.', + filePath: 'src/commit.ts', + id: 'local-note', + kind: 'local-note' as const, + lineNumber: 1, + sectionId: 'src/commit.ts:unstaged', + side: 'additions' as const, + }; + + try { + await waitFor(() => expect(surfaceProps).toHaveBeenCalled()); + expect(getSurfaceProps().capabilities?.desktop).not.toHaveProperty('onRetargetToWorkingTree'); + const initialComments = getSurfaceProps().capabilities?.comments; + expect(initialComments?.destination).toBe('provider'); + if (!initialComments || initialComments.destination !== 'provider') { + throw new Error('Expected provider comment capabilities.'); + } + await act(async () => initialComments.reviewSession?.drafts.onChange([providerDraft])); + await waitFor(() => { + const comments = getSurfaceProps().capabilities?.comments; + expect(comments?.destination).toBe('provider'); + if (comments?.destination === 'provider') { + expect(comments.reviewSession?.drafts.value).toContainEqual(providerDraft); + } + }); + + await act(async () => getSurfaceProps().capabilities?.history?.onSelectSource(commitRequest)); + await waitFor(() => expect(getSurfaceProps().snapshot.repository.source).toEqual(commitSource)); + expect(getSurfaceProps().capabilities?.localReviewNotes).toBeDefined(); + expect(getSurfaceProps().capabilities?.localReviewNotes?.drafts?.value).toEqual([]); + expect(getSurfaceProps().capabilities?.history?.pullRequestSource).toEqual(pullRequestSource); + await act(async () => + getSurfaceProps().capabilities?.localReviewNotes?.drafts?.onChange([localNote]), + ); + await waitFor(() => + expect(getSurfaceProps().capabilities?.localReviewNotes?.drafts?.value).toContainEqual( + localNote, + ), + ); + + await act(async () => + getSurfaceProps().capabilities?.history?.onSelectSource(pullRequestSource), + ); + await waitFor(() => + expect(getSurfaceProps().snapshot.repository.source).toEqual(pullRequestSource), + ); + const restoredProviderComments = getSurfaceProps().capabilities?.comments; + expect(restoredProviderComments?.destination).toBe('provider'); + if (restoredProviderComments?.destination === 'provider') { + expect(restoredProviderComments.reviewSession?.drafts.value).toContainEqual(providerDraft); + } + + await act(async () => getSurfaceProps().capabilities?.history?.onSelectSource(commitRequest)); + await waitFor(() => expect(getSurfaceProps().snapshot.repository.source).toEqual(commitSource)); + expect(getSurfaceProps().capabilities?.localReviewNotes?.drafts?.value).toContainEqual( + localNote, + ); + } finally { + await view.cleanup(); + } +}); + +test('keeps a newer walkthrough request active when an older request fails', async () => { + surfaceProps.mockClear(); + const { api } = installWindowApi(); + const state = stateFor({ type: 'working-tree' }, [createChangedFile('src/retry.ts')]); + const older = deferred(); + const newer = deferred(); + api.getNarrativeWalkthrough + .mockImplementationOnce(() => older.promise) + .mockImplementationOnce(() => newer.promise); + const view = await renderHost(state); + + try { + await waitFor(() => expect(surfaceProps).toHaveBeenCalled()); + await act(async () => { + void getSurfaceProps().capabilities?.walkthrough?.onGenerate?.(); + void getSurfaceProps().capabilities?.walkthrough?.onGenerate?.(); + await Promise.resolve(); + }); + expect(api.getNarrativeWalkthrough).toHaveBeenCalledTimes(2); + + await act(async () => { + older.reject(new Error('stale failure')); + await older.promise.catch(() => {}); + }); + expect(getSurfaceProps().capabilities?.walkthrough).toMatchObject({ + error: null, + status: 'generating', + }); + + await act(async () => { + newer.resolve({ status: 'ready', walkthrough: walkthroughFor(state, 'Newer result') }); + await newer.promise; + }); + await waitFor(() => expect(getSurfaceProps().capabilities?.walkthrough?.status).toBe('ready')); + expect(getSurfaceProps().snapshot.walkthrough.title).toBe('Newer result'); + } finally { + await view.cleanup(); + } +}); + +test('forwards complete agent-unavailable walkthrough metadata to the surface', async () => { + for (const code of [ + 'CODEX_NOT_FOUND', + 'CLAUDE_NOT_FOUND', + 'OPENCODE_NOT_FOUND', + 'PI_NOT_FOUND', + ] as const) { + surfaceProps.mockClear(); + installWindowApi(); + const state = stateFor({ type: 'working-tree' }, [createChangedFile(`src/${code}.ts`)]); + const view = await renderHost(state, undefined, { + bootstrap: { sidebarMode: 'walkthrough' }, + initialWalkthroughResult: { + code, + reason: `${code} unavailable.`, + status: 'unavailable', + }, + }); + try { + await waitFor(() => expect(surfaceProps).toHaveBeenCalled()); + expect(getSurfaceProps().capabilities?.walkthrough?.error).toMatchObject({ + code, + reason: `${code} unavailable.`, + }); + } finally { + await view.cleanup(); + } + } +}); + +test('marks background walkthrough completion unread until Walkthrough is activated', async () => { + surfaceProps.mockClear(); + const { api } = installWindowApi(); + const state = stateFor({ type: 'working-tree' }, [createChangedFile('src/background.ts')]); + const pending = deferred(); + api.getNarrativeWalkthrough.mockImplementationOnce(() => pending.promise); + const view = await renderHost(state); + + try { + await waitFor(() => expect(surfaceProps).toHaveBeenCalled()); + await act(async () => { + getSurfaceProps().activeMode?.onChange('walkthrough'); + await Promise.resolve(); + }); + await waitFor(() => expect(api.getNarrativeWalkthrough).toHaveBeenCalledTimes(1)); + await act(async () => getSurfaceProps().activeMode?.onChange('tree')); + await act(async () => { + pending.resolve({ status: 'ready', walkthrough: walkthroughFor(state, 'Background ready') }); + await pending.promise; + }); + await waitFor(() => expect(getSurfaceProps().capabilities?.walkthrough?.unread).toBe(true)); + + await act(async () => getSurfaceProps().activeMode?.onChange('walkthrough')); + await waitFor(() => expect(getSurfaceProps().capabilities?.walkthrough?.unread).toBe(false)); + } finally { + await view.cleanup(); + } +}); + +test('does not mark walkthrough completion unread while Walkthrough remains active', async () => { + surfaceProps.mockClear(); + const { api } = installWindowApi(); + const state = stateFor({ type: 'working-tree' }, [createChangedFile('src/foreground.ts')]); + const pending = deferred(); + api.getNarrativeWalkthrough.mockImplementationOnce(() => pending.promise); + const view = await renderHost(state); + + try { + await waitFor(() => expect(surfaceProps).toHaveBeenCalled()); + await act(async () => { + getSurfaceProps().activeMode?.onChange('walkthrough'); + await Promise.resolve(); + }); + await act(async () => { + pending.resolve({ status: 'ready', walkthrough: walkthroughFor(state, 'Foreground ready') }); + await pending.promise; + }); + await waitFor(() => + expect(getSurfaceProps().capabilities?.walkthrough).toMatchObject({ + status: 'ready', + unread: false, + }), + ); + } finally { + await view.cleanup(); + } +}); + +test('uses the configured agent for placeholder walkthroughs and honors launch overrides', async () => { + const state = stateFor({ type: 'working-tree' }); + for (const agentBackend of ['codex', 'claude', 'opencode', 'pi'] as const) { + surfaceProps.mockClear(); + installWindowApi(); + const config = createDefaultConfig(); + config.settings.agentBackend = agentBackend; + const view = await renderHost(state, config); + try { + await waitFor(() => expect(surfaceProps).toHaveBeenCalled()); + expect(getSurfaceProps().snapshot.walkthrough.agent).toBe(agentBackend); + } finally { + await view.cleanup(); + } + } + + surfaceProps.mockClear(); + installWindowApi(); + const config = createDefaultConfig(); + config.settings.agentBackend = 'codex'; + const view = await renderHost(state, config, { + launchOptions: { agentBackend: 'claude', repositoryPathProvided: true, walkthrough: false }, + }); + try { + await waitFor(() => expect(surfaceProps).toHaveBeenCalled()); + expect(getSurfaceProps().snapshot.walkthrough.agent).toBe('claude'); + + const nextConfig = createDefaultConfig(); + nextConfig.settings.agentBackend = 'pi'; + await view.rerenderConfig(nextConfig); + expect(getSurfaceProps().snapshot.walkthrough.agent).toBe('claude'); + } finally { + await view.cleanup(); + } + + surfaceProps.mockClear(); + installWindowApi(); + const configuredView = await renderHost(state, config); + try { + await waitFor(() => expect(surfaceProps).toHaveBeenCalled()); + const nextConfig = createDefaultConfig(); + nextConfig.settings.agentBackend = 'opencode'; + await configuredView.rerenderConfig(nextConfig); + await waitFor(() => expect(getSurfaceProps().snapshot.walkthrough.agent).toBe('opencode')); + } finally { + await configuredView.cleanup(); + } +}); +test('applies non-whitespace config updates without reloading repository state', async () => { + surfaceProps.mockClear(); + const { api } = installWindowApi(); + const state = stateFor({ type: 'working-tree' }, [createChangedFile('src/config.ts')]); + const config = createDefaultConfig(); + const view = await renderHost(state, config); + api.getRepositoryState.mockClear(); + + try { + const nextConfig = createDefaultConfig(); + nextConfig.settings.wordWrap = true; + await view.rerenderConfig(nextConfig); + await waitFor(() => + expect(getSurfaceProps().capabilities?.preferences?.wordWrap?.value).toBe(true), + ); + expect(api.getRepositoryState).not.toHaveBeenCalled(); + } finally { + await view.cleanup(); + } +}); + +test('reloads the exact active source once when showWhitespace changes', async () => { + surfaceProps.mockClear(); + const { api } = installWindowApi(); + const source = { + baseSha: gitSha('a'), + headSha: gitSha('b'), + ref: 'feature', + type: 'branch-working-tree', + } as const; + const state = stateFor(source, [createChangedFile('src/whitespace.ts')]); + api.getRepositoryState.mockResolvedValueOnce(state); + const config = createDefaultConfig(); + const view = await renderHost(state, config); + + try { + const nextConfig = createDefaultConfig(); + nextConfig.settings.showWhitespace = true; + await view.rerenderConfig(nextConfig); + await waitFor(() => expect(api.getRepositoryState).toHaveBeenCalledTimes(1)); + expect(api.getRepositoryState).toHaveBeenCalledWith({ + ref: source.ref, + type: 'branch-working-tree', + }); + } finally { + await view.cleanup(); + } +}); + +test('discards stale repository results from rapid showWhitespace changes', async () => { + surfaceProps.mockClear(); + const { api } = installWindowApi(); + const initialState = stateFor({ type: 'working-tree' }, [createChangedFile('src/initial.ts')]); + const staleState = stateFor({ type: 'working-tree' }, [createChangedFile('src/stale.ts')]); + const currentState = stateFor({ type: 'working-tree' }, [createChangedFile('src/current.ts')]); + const stale = deferred(); + const current = deferred(); + api.getRepositoryState + .mockImplementationOnce(() => stale.promise) + .mockImplementationOnce(() => current.promise); + const view = await renderHost(initialState); + + try { + const showWhitespace = createDefaultConfig(); + showWhitespace.settings.showWhitespace = true; + await view.rerenderConfig(showWhitespace); + const hideWhitespace = createDefaultConfig(); + hideWhitespace.settings.showWhitespace = false; + await view.rerenderConfig(hideWhitespace); + await waitFor(() => expect(api.getRepositoryState).toHaveBeenCalledTimes(2)); + + await act(async () => { + current.resolve(currentState); + await current.promise; + }); + await waitFor(() => + expect(getSurfaceProps().snapshot.files.map((file) => file.path)).toEqual(['src/current.ts']), + ); + await act(async () => { + stale.resolve(staleState); + await stale.promise; + }); + expect(getSurfaceProps().snapshot.files.map((file) => file.path)).toEqual(['src/current.ts']); + } finally { + await view.cleanup(); + } +}); + +test('keeps failed whitespace reloads recoverable without applying stale state', async () => { + surfaceProps.mockClear(); + const { api } = installWindowApi(); + const initialState = stateFor({ type: 'working-tree' }, [createChangedFile('src/kept.ts')]); + api.getRepositoryState.mockRejectedValueOnce(new Error('Reload failed.')); + const view = await renderHost(initialState); + + try { + const showWhitespace = createDefaultConfig(); + showWhitespace.settings.showWhitespace = true; + await view.rerenderConfig(showWhitespace); + await waitFor(() => expect(view.container.textContent).toContain('Reload failed.')); + + api.getRepositoryState.mockResolvedValueOnce(initialState); + const hideWhitespace = createDefaultConfig(); + hideWhitespace.settings.showWhitespace = false; + await view.rerenderConfig(hideWhitespace); + await waitFor(() => expect(view.container.textContent).not.toContain('Reload failed.')); + expect(getSurfaceProps().snapshot.files.map((file) => file.path)).toEqual(['src/kept.ts']); + } finally { + await view.cleanup(); + } +}); + +test('hydrates provider comments after first usable and ignores a superseded source result', async () => { + surfaceProps.mockClear(); + const firstComments = deferred<{ + generalComments: []; + reviewComments: ReadonlyArray<{ + author: { login: string }; + body: string; + filePath: string; + id: string; + lineNumber: number; + side: 'additions'; + }>; + }>(); + const secondComments = deferred<{ + generalComments: []; + reviewComments: ReadonlyArray<{ + author: { login: string }; + body: string; + filePath: string; + id: string; + lineNumber: number; + side: 'additions'; + }>; + }>(); + const firstSource = { + headSha: gitSha('a'), + number: 12, + provider: 'github', + type: 'pull-request', + url: 'https://github.com/example/repo/pull/12', + } as const; + const secondSource = { + headSha: gitSha('b'), + number: 23, + projectPath: 'example/repo', + provider: 'gitlab', + type: 'pull-request', + url: 'https://gitlab.example.com/example/repo/-/merge_requests/23', + } as const; + const getReviewComments = vi.fn((source: ResolvedReviewSource, _requestId?: string) => + source.type === 'pull-request' && source.headSha === firstSource.headSha + ? firstComments.promise + : secondComments.promise, + ); + const getRepositoryState = vi.fn(async () => ({ + ...stateFor(secondSource, [createChangedFile('src/second.ts')]), + reviewComments: [], + reviewCommentsLoadState: 'not-loaded' as const, + })); + installWindowApi({ getRepositoryState, getReviewComments }); + const view = await renderHost({ + ...stateFor(firstSource, [createChangedFile('src/first.ts')]), + reviewComments: [], + reviewCommentsLoadState: 'not-loaded', + }); + + try { + await waitFor(() => + expect(getReviewComments).toHaveBeenCalledWith( + firstSource, + expect.stringMatching(/^review-comments:/), + ), + ); + expect(surfaceProps).toHaveBeenCalled(); + const initialProps = getSurfaceProps(); + expect(initialProps.snapshot.repository.source).toEqual(firstSource); + expect(initialProps.snapshot.reviewComments ?? []).toEqual([]); + + await act(async () => initialProps.capabilities?.history?.onSelectSource(secondSource)); + await waitFor(() => expect(getRepositoryState).toHaveBeenCalledWith(secondSource)); + await waitFor(() => expect(getSurfaceProps().snapshot.repository.source).toEqual(secondSource)); + await waitFor(() => + expect(getReviewComments).toHaveBeenCalledWith( + secondSource, + expect.stringMatching(/^review-comments:/), + ), + ); + + await act(async () => { + firstComments.resolve({ + generalComments: [], + reviewComments: [ + { + author: { login: 'stale-reviewer' }, + body: 'Stale comment', + filePath: 'src/first.ts', + id: 'stale', + lineNumber: 1, + side: 'additions', + }, + ], + }); + await firstComments.promise; + }); + expect(getSurfaceProps().snapshot.reviewComments ?? []).not.toEqual( + expect.arrayContaining([expect.objectContaining({ id: 'stale' })]), + ); + + await act(async () => { + secondComments.resolve({ + generalComments: [], + reviewComments: [ + { + author: { login: 'current-reviewer' }, + body: 'Current comment', + filePath: 'src/second.ts', + id: 'current', + lineNumber: 1, + side: 'additions', + }, + ], + }); + await secondComments.promise; + }); + await waitFor(() => + expect(getSurfaceProps().snapshot.reviewComments).toEqual([ + expect.objectContaining({ id: 'current' }), + ]), + ); + } finally { + await view.cleanup(); + } +}); + +test('keeps provider comment hydration failures visible and retryable', async () => { + surfaceProps.mockClear(); + const getReviewComments = vi + .fn() + .mockRejectedValueOnce(new Error('Provider comments are unavailable.')) + .mockResolvedValueOnce({ generalComments: [], reviewComments: [] }); + installWindowApi({ getReviewComments }); + const source = { + headSha: gitSha('c'), + number: 31, + provider: 'github', + type: 'pull-request', + url: 'https://github.com/example/repo/pull/31', + } as const; + const view = await renderHost({ + ...stateFor(source), + reviewComments: [], + reviewCommentsLoadState: 'not-loaded', + }); + + try { + await waitFor(() => + expect(view.container.textContent).toContain('Provider comments are unavailable.'), + ); + const retry = Array.from(view.container.querySelectorAll('button')).find( + (button) => button.textContent?.trim() === 'Retry', + ); + expect(retry).toBeDefined(); + await act(async () => retry?.click()); + await waitFor(() => expect(getReviewComments).toHaveBeenCalledTimes(2)); + await waitFor(() => expect(getSurfaceProps().snapshot.repository.source).toEqual(source)); + expect(view.container.textContent).not.toContain('Provider comments are unavailable.'); + } finally { + await view.cleanup(); + } +}); diff --git a/core/__tests__/RepositoryReviewHost.test.tsx b/core/__tests__/RepositoryReviewHost.test.tsx new file mode 100644 index 00000000..a0772e96 --- /dev/null +++ b/core/__tests__/RepositoryReviewHost.test.tsx @@ -0,0 +1,970 @@ +/** + * @vitest-environment jsdom + */ + +import { act } from 'react'; +import { expect, test, vi } from 'vite-plus/test'; +import { RepositoryReviewHost } from '../app/RepositoryReviewHost.tsx'; +import { createDefaultConfig } from '../config/defaults.ts'; +import { resolveRepositoryReviewBootstrap } from '../lib/repository-review-bootstrap.ts'; +import type { + GitSha, + NarrativeWalkthrough, + NarrativeWalkthroughResult, + RepositoryHistory, + RepositoryState, + RevisionContentBatchRequest, + RevisionContentBatchResult, +} from '../types.ts'; +import { createChangedFile } from './helpers/fixtures.ts'; +import { renderReact, waitFor } from './helpers/react.tsx'; + +const state = { + branch: 'main', + files: [], + generatedAt: 1, + launchPath: '/repo', + root: '/repo', + source: { type: 'working-tree' }, +} satisfies RepositoryState; + +const unsubscribe = () => {}; +const bootstrapFor = ( + repositoryState: RepositoryState, + launchOptions = { repositoryPathProvided: true, walkthrough: false } as const, +) => + resolveRepositoryReviewBootstrap({ + launchOptions, + reloadSelection: null, + state: repositoryState, + }); +const deferred = () => { + let resolve!: (value: Value) => void; + const promise = new Promise((next) => { + resolve = next; + }); + return { promise, resolve }; +}; + +const installCommitWindowApi = () => { + window.codiff = { + applyUpdate: vi.fn(async () => ({ currentVersion: '0.0.0', phase: 'idle' as const })), + cancelDiffContentRequest: vi.fn(), + createWalkthroughCommit: vi.fn(async () => ({ status: 'committed' })), + dismissUpdate: vi.fn(async () => ({ currentVersion: '0.0.0', phase: 'idle' as const })), + getUpdateStatus: vi.fn(async () => ({ currentVersion: '0.0.0', phase: 'idle' as const })), + isWindowFullScreen: vi.fn(async () => false), + onConfigChanged: vi.fn(() => unsubscribe), + onCopyPendingCommentsRequest: vi.fn(() => unsubscribe), + onFindInDiffs: vi.fn(() => unsubscribe), + onOpenReviewSource: vi.fn(() => unsubscribe), + onRefreshRequest: vi.fn(() => unsubscribe), + onRepositoryChanged: vi.fn(() => unsubscribe), + onUpdateStatusChanged: vi.fn(() => unsubscribe), + onWalkthroughCommitOutput: vi.fn(() => unsubscribe), + onWalkthroughProgress: vi.fn(() => unsubscribe), + onWindowFullScreenChanged: vi.fn(() => unsubscribe), + openRepositoryFolder: vi.fn(async () => {}), + resolvePullRequestUrl: vi.fn(async (value: string) => value), + updateWalkthroughCommitMessage: vi.fn(async () => ({ status: 'unavailable' })), + } as unknown as Window['codiff']; +}; + +test('RepositoryReviewHost renders local reviews through the shared surface', async () => { + window.codiff = { + applyUpdate: vi.fn(async () => ({ currentVersion: '0.0.0', phase: 'idle' as const })), + cancelDiffContentRequest: vi.fn(), + dismissUpdate: vi.fn(async () => ({ currentVersion: '0.0.0', phase: 'idle' as const })), + getUpdateStatus: vi.fn(async () => ({ currentVersion: '0.0.0', phase: 'idle' as const })), + isWindowFullScreen: vi.fn(async () => false), + onConfigChanged: vi.fn(() => unsubscribe), + onCopyPendingCommentsRequest: vi.fn(() => unsubscribe), + onFindInDiffs: vi.fn(() => unsubscribe), + onOpenReviewSource: vi.fn(() => unsubscribe), + onRefreshRequest: vi.fn(() => unsubscribe), + onRepositoryChanged: vi.fn(() => unsubscribe), + onUpdateStatusChanged: vi.fn(() => unsubscribe), + onWalkthroughProgress: vi.fn(() => unsubscribe), + onWindowFullScreenChanged: vi.fn(() => unsubscribe), + openRepositoryFolder: vi.fn(async () => {}), + resolvePullRequestUrl: vi.fn(async (value: string) => value), + } as unknown as Window['codiff']; + + const view = await renderReact( + , + ); + try { + await waitFor(() => expect(view.container.querySelector('.review-surface')).not.toBeNull()); + expect(view.container.querySelector('main.review')).not.toBeNull(); + expect(view.container.querySelector('aside.sidebar')).not.toBeNull(); + } finally { + await view.cleanup(); + } +}); + +test('RepositoryReviewHost renders provider reviews through the shared surface', async () => { + window.codiff = { + applyUpdate: vi.fn(async () => ({ currentVersion: '0.0.0', phase: 'idle' as const })), + cancelDiffContentRequest: vi.fn(), + dismissUpdate: vi.fn(async () => ({ currentVersion: '0.0.0', phase: 'idle' as const })), + getUpdateStatus: vi.fn(async () => ({ currentVersion: '0.0.0', phase: 'idle' as const })), + isWindowFullScreen: vi.fn(async () => false), + onConfigChanged: vi.fn(() => unsubscribe), + onCopyPendingCommentsRequest: vi.fn(() => unsubscribe), + onFindInDiffs: vi.fn(() => unsubscribe), + onOpenReviewSource: vi.fn(() => unsubscribe), + onRefreshRequest: vi.fn(() => unsubscribe), + onRepositoryChanged: vi.fn(() => unsubscribe), + onUpdateStatusChanged: vi.fn(() => unsubscribe), + onWalkthroughProgress: vi.fn(() => unsubscribe), + onWindowFullScreenChanged: vi.fn(() => unsubscribe), + openRepositoryFolder: vi.fn(async () => {}), + resolvePullRequestUrl: vi.fn(async (value: string) => value), + } as unknown as Window['codiff']; + const pullRequestState = { + ...state, + files: [createChangedFile('src/review.ts')], + source: { + headSha: 'c'.repeat(40), + number: 42, + provider: 'github', + targetBranch: 'main', + title: 'Review the shared host', + type: 'pull-request', + url: 'https://github.com/example/review/pull/42', + }, + } satisfies RepositoryState; + + const view = await renderReact( + , + ); + try { + await waitFor(() => + expect(view.container.querySelector('.merge-request-shell')).not.toBeNull(), + ); + expect(view.container.textContent).toContain('Review the shared host'); + expect(view.container.textContent).toContain('src/review.ts'); + expect(view.container.querySelector('.sidebar-commit-button')).toBeNull(); + expect(view.container.querySelector('button[aria-label="Back to Codiff"]')).toBeNull(); + + const historyButton = Array.from(view.container.querySelectorAll('button')).find( + (button) => button.textContent?.trim() === 'History', + ); + await act(async () => historyButton?.click()); + await waitFor(() => expect(view.container.querySelector('.history-list')).not.toBeNull()); + expect( + Array.from(view.container.querySelectorAll('.history-entry-subject')).map( + (entry) => entry.textContent, + ), + ).toEqual(['Review the shared host', 'Review commit']); + expect(view.container.textContent).not.toContain('Uncommitted changes'); + } finally { + await view.cleanup(); + } +}); + +test('provider reviews load exact contents before their first usable render', async () => { + const content = deferred(); + const readRevisionContent = vi.fn((_request: RevisionContentBatchRequest) => content.promise); + const reportInitialLoadMilestone = vi.fn(); + window.codiff = { + applyUpdate: vi.fn(async () => ({ currentVersion: '0.0.0', phase: 'idle' as const })), + cancelDiffContentRequest: vi.fn(), + dismissUpdate: vi.fn(async () => ({ currentVersion: '0.0.0', phase: 'idle' as const })), + getUpdateStatus: vi.fn(async () => ({ currentVersion: '0.0.0', phase: 'idle' as const })), + isWindowFullScreen: vi.fn(async () => false), + onConfigChanged: vi.fn(() => unsubscribe), + onCopyPendingCommentsRequest: vi.fn(() => unsubscribe), + onFindInDiffs: vi.fn(() => unsubscribe), + onOpenReviewSource: vi.fn(() => unsubscribe), + onRefreshRequest: vi.fn(() => unsubscribe), + onRepositoryChanged: vi.fn(() => unsubscribe), + onUpdateStatusChanged: vi.fn(() => unsubscribe), + onWalkthroughProgress: vi.fn(() => unsubscribe), + onWindowFullScreenChanged: vi.fn(() => unsubscribe), + openRepositoryFolder: vi.fn(async () => {}), + readRevisionContent, + reportInitialLoadMilestone, + resolvePullRequestUrl: vi.fn(async (value: string) => value), + } as unknown as Window['codiff']; + const baseSha = 'b'.repeat(40) as GitSha; + const headSha = 'c'.repeat(40) as GitSha; + const range = { + base: { label: { kind: 'commit' as const, text: 'base' }, sha: baseSha }, + head: { label: { kind: 'commit' as const, text: 'head' }, sha: headSha }, + }; + const deferredFile = createChangedFile('src/deferred.ts', { kind: 'pull-request' }); + const patchFile = createChangedFile('src/patch.ts', { kind: 'pull-request' }); + const pullRequestState = { + ...state, + files: [ + { + ...deferredFile, + sections: [ + { + ...deferredFile.sections[0]!, + loadState: 'deferred' as const, + patch: '', + range, + summary: { canLoad: true, reason: 'Load exact contents.' }, + }, + ], + }, + { + ...patchFile, + sections: [ + { + ...patchFile.sections[0]!, + loadState: 'ready' as const, + newFile: undefined, + oldFile: undefined, + range, + }, + ], + }, + ], + reviewCommentsLoadState: 'loaded' as const, + source: { + headSha, + number: 42, + provider: 'github' as const, + targetBranch: 'main', + title: 'Exact review content', + type: 'pull-request' as const, + url: 'https://github.com/example/review/pull/42', + }, + } satisfies RepositoryState; + + await using view = await renderReact( + , + ); + + await waitFor(() => expect(readRevisionContent).toHaveBeenCalledOnce()); + expect(readRevisionContent.mock.calls[0]![0].requests).toHaveLength(4); + expect(view.container.querySelector('.review-surface')).toBeNull(); + expect(reportInitialLoadMilestone).not.toHaveBeenCalledWith('first-usable-review-rendered'); + + const request = readRevisionContent.mock.calls[0]![0]; + await act(async () => + content.resolve({ + results: request.requests.map((item) => ({ + key: item.key, + status: 'ready' as const, + value: { + bytes: new TextEncoder().encode(item.revision === range.head ? 'new\n' : 'old\n'), + cacheKey: item.key, + path: item.path, + provenance: 'native-git' as const, + size: 4, + }, + })), + }), + ); + await waitFor(() => expect(view.container.querySelector('.review-surface')).not.toBeNull()); + expect(reportInitialLoadMilestone).toHaveBeenCalledWith('first-usable-review-rendered'); +}); + +test('working-tree reviews open the standalone commit view with generated seed text', async () => { + installCommitWindowApi(); + const file = createChangedFile('src/commit.ts'); + const workingState = { ...state, files: [file] } satisfies RepositoryState; + const walkthrough = { + agent: 'claude', + chapters: [], + commit: { body: 'Explain why this commit exists.', title: 'Prepare the standalone commit' }, + focus: 'Review the commit.', + generatedAt: '2026-08-05T00:00:00.000Z', + kind: 'narrative', + repo: { branch: workingState.branch, root: workingState.root }, + source: workingState.source, + support: [], + title: 'Commit walkthrough', + version: 4, + } satisfies NarrativeWalkthrough; + const view = await renderReact( + , + ); + + try { + await waitFor(() => + expect(view.container.querySelector('.sidebar-commit-button')).not.toBeNull(), + ); + await act(async () => { + (view.container.querySelector('.sidebar-commit-button') as HTMLButtonElement).click(); + }); + await waitFor(() => expect(view.container.querySelector('.wt-commit')).not.toBeNull()); + expect( + (view.container.querySelector('.wt-commit-subject-field') as HTMLInputElement).value, + ).toBe('Prepare the standalone commit'); + expect( + (view.container.querySelector('.wt-commit-msg-input') as HTMLTextAreaElement).value, + ).toBe('Explain why this commit exists.'); + expect(view.container.querySelector('.sidebar-commit-button')?.textContent).toBe('Tree'); + + await act(async () => { + (view.container.querySelector('.sidebar-commit-button') as HTMLButtonElement).click(); + }); + await waitFor(() => expect(view.container.querySelector('.wt-commit')).toBeNull()); + } finally { + await view.cleanup(); + } +}); + +test('working-tree reviews restore the standalone commit view from bootstrap state', async () => { + installCommitWindowApi(); + const workingState = { + ...state, + files: [createChangedFile('src/restored-commit.ts')], + } satisfies RepositoryState; + const bootstrap = { + ...bootstrapFor(workingState), + mainMode: 'commit' as const, + }; + await using view = await renderReact( + , + ); + + await waitFor(() => expect(view.container.querySelector('.wt-commit')).not.toBeNull()); +}); + +test('standalone commit preparation remains available without a ready walkthrough', async () => { + const unavailable = { + reason: 'Walkthrough generation is unavailable.', + status: 'unavailable', + } satisfies NarrativeWalkthroughResult; + + for (const initialWalkthroughResult of [undefined, unavailable]) { + installCommitWindowApi(); + const workingState = { + ...state, + files: [createChangedFile('src/standalone.ts')], + } satisfies RepositoryState; + const view = await renderReact( + , + ); + try { + await waitFor(() => + expect(view.container.querySelector('.sidebar-commit-button')).not.toBeNull(), + ); + await act(async () => { + (view.container.querySelector('.sidebar-commit-button') as HTMLButtonElement).click(); + }); + await waitFor(() => expect(view.container.querySelector('.wt-commit')).not.toBeNull()); + } finally { + await view.cleanup(); + } + } +}); + +test('reports first usable before initial history and deferred completion after it', async () => { + const history = deferred(); + const getRepositoryHistory = vi.fn(() => history.promise); + const reportInitialLoadMilestone = vi.fn(); + window.codiff = { + applyUpdate: vi.fn(async () => ({ currentVersion: '0.0.0', phase: 'idle' as const })), + cancelDiffContentRequest: vi.fn(), + dismissUpdate: vi.fn(async () => ({ currentVersion: '0.0.0', phase: 'idle' as const })), + getRepositoryHistory, + getUpdateStatus: vi.fn(async () => ({ currentVersion: '0.0.0', phase: 'idle' as const })), + isWindowFullScreen: vi.fn(async () => false), + onConfigChanged: vi.fn(() => unsubscribe), + onCopyPendingCommentsRequest: vi.fn(() => unsubscribe), + onFindInDiffs: vi.fn(() => unsubscribe), + onOpenReviewSource: vi.fn(() => unsubscribe), + onRefreshRequest: vi.fn(() => unsubscribe), + onRepositoryChanged: vi.fn(() => unsubscribe), + onUpdateStatusChanged: vi.fn(() => unsubscribe), + onWalkthroughProgress: vi.fn(() => unsubscribe), + onWindowFullScreenChanged: vi.fn(() => unsubscribe), + openRepositoryFolder: vi.fn(async () => {}), + reportInitialLoadMilestone, + resolvePullRequestUrl: vi.fn(async (value: string) => value), + } as unknown as Window['codiff']; + + const render = (gitIdentityReady: boolean) => ( + + ); + const view = await renderReact(render(false)); + try { + await waitFor(() => + expect(reportInitialLoadMilestone).toHaveBeenCalledWith('first-usable-review-rendered'), + ); + await waitFor(() => expect(getRepositoryHistory).toHaveBeenCalledOnce()); + expect(reportInitialLoadMilestone.mock.invocationCallOrder[0]!).toBeLessThan( + getRepositoryHistory.mock.invocationCallOrder[0]!, + ); + expect(reportInitialLoadMilestone).not.toHaveBeenCalledWith('deferred-review-data-complete'); + + await act(async () => history.resolve({ entries: [], root: '/repo' })); + expect(reportInitialLoadMilestone).not.toHaveBeenCalledWith('deferred-review-data-complete'); + + await view.rerender(render(true)); + await waitFor(() => + expect(reportInitialLoadMilestone).toHaveBeenCalledWith('deferred-review-data-complete'), + ); + } finally { + await view.cleanup(); + } +}); + +test('RepositoryReviewHost hydrates deferred provider comments', async () => { + const file = createChangedFile('src/review.ts', { kind: 'pull-request' }); + const source = { + headSha: 'c'.repeat(40), + number: 42, + provider: 'github', + targetBranch: 'main', + title: 'Review the shared host', + type: 'pull-request', + url: 'https://github.com/example/review/pull/42', + } as const; + const pullRequestState = { + ...state, + files: [file], + reviewCommentsLoadState: 'not-loaded' as const, + source, + } satisfies RepositoryState; + const getReviewComments = vi.fn(async (_source: typeof source, _requestId?: string) => ({ + generalComments: [ + { + comments: [ + { + author: { login: 'overview-reviewer' }, + body: 'Loaded overview feedback with the inline thread.', + id: 'github:overview:1', + }, + ], + id: 'overview-thread', + }, + ], + reviewComments: [ + { + author: { login: 'reviewer' }, + body: 'Loaded through the R04 review-comments capability.', + filePath: file.path, + id: 'github:1', + lineNumber: 1, + position: { range: file.sections[0]!.range! }, + side: 'additions' as const, + threadId: '1', + }, + ], + })); + window.codiff = { + applyUpdate: vi.fn(async () => ({ currentVersion: '0.0.0', phase: 'idle' as const })), + cancelDiffContentRequest: vi.fn(), + dismissUpdate: vi.fn(async () => ({ currentVersion: '0.0.0', phase: 'idle' as const })), + getReviewComments, + getUpdateStatus: vi.fn(async () => ({ currentVersion: '0.0.0', phase: 'idle' as const })), + isWindowFullScreen: vi.fn(async () => false), + onConfigChanged: vi.fn(() => unsubscribe), + onCopyPendingCommentsRequest: vi.fn(() => unsubscribe), + onFindInDiffs: vi.fn(() => unsubscribe), + onOpenReviewSource: vi.fn(() => unsubscribe), + onRefreshRequest: vi.fn(() => unsubscribe), + onRepositoryChanged: vi.fn(() => unsubscribe), + onUpdateStatusChanged: vi.fn(() => unsubscribe), + onWalkthroughProgress: vi.fn(() => unsubscribe), + onWindowFullScreenChanged: vi.fn(() => unsubscribe), + openRepositoryFolder: vi.fn(async () => {}), + reportInitialLoadMilestone: vi.fn(), + resolvePullRequestUrl: vi.fn(async (value: string) => value), + } as unknown as Window['codiff']; + await using view = await renderReact( + , + ); + + await waitFor(() => { + expect(getReviewComments).toHaveBeenCalledWith( + source, + expect.stringMatching(/^review-comments:/), + ); + expect(view.container.textContent).toContain( + 'Loaded through the R04 review-comments capability.', + ); + }); + const commentsButton = Array.from(view.container.querySelectorAll('button')).find((button) => + button.textContent?.includes('Comments'), + ); + await act(async () => commentsButton?.click()); + await waitFor(() => { + expect(view.container.querySelector('main.review')?.textContent).toContain( + 'Loaded overview feedback with the inline thread.', + ); + expect(view.container.querySelector('main.review')?.textContent).toContain( + 'Loaded through the R04 review-comments capability.', + ); + }); +}); + +test('RepositoryReviewHost renders historical comment regions and falls back after content failure', async () => { + const currentFile = createChangedFile('src/history.ts', { kind: 'pull-request' }); + const source = { + headSha: 'b'.repeat(40), + number: 43, + provider: 'github', + targetBranch: 'main', + title: 'Review historical anchors', + type: 'pull-request', + url: 'https://github.com/example/review/pull/43', + } as const; + const pullRequestState = { + ...state, + files: [currentFile], + reviewCommentsLoadState: 'not-loaded' as const, + source, + } satisfies RepositoryState; + const historicalRange = { + base: { + label: { kind: 'commit' as const, text: 'ccccccc' }, + sha: 'c'.repeat(40) as GitSha, + }, + head: { + label: { kind: 'commit' as const, text: 'ddddddd' }, + sha: 'd'.repeat(40) as GitSha, + }, + }; + const failedRange = { + base: { + label: { kind: 'commit' as const, text: 'eeeeeee' }, + sha: 'e'.repeat(40) as GitSha, + }, + head: { + label: { kind: 'commit' as const, text: 'fffffff' }, + sha: 'f'.repeat(40) as GitSha, + }, + }; + const getReviewComments = vi.fn(async () => ({ + generalComments: [], + reviewComments: [ + { + author: { login: 'historical-reviewer' }, + body: 'Render this against the historical code.', + filePath: currentFile.path, + id: 'github:historical', + isOutdated: true, + lineNumber: 2, + position: { range: historicalRange }, + side: 'additions' as const, + threadId: 'historical-thread', + }, + { + author: { login: 'failed-reviewer' }, + body: 'Keep this thread when historical content cannot load.', + filePath: 'src/unavailable-history.ts', + id: 'github:failed-history', + isOutdated: true, + lineNumber: 2, + position: { range: failedRange }, + side: 'additions' as const, + threadId: 'failed-thread', + }, + ], + })); + const readRevisionContent = vi.fn(async (request: RevisionContentBatchRequest) => ({ + results: request.requests.map((item) => { + if ('sha' in item.revision && ['e'.repeat(40), 'f'.repeat(40)].includes(item.revision.sha)) { + return { + key: item.key, + reason: 'Historical object is unavailable.', + status: 'unavailable' as const, + }; + } + const contents = + 'sha' in item.revision && item.revision.sha === historicalRange.base.sha + ? 'one\nold value\nthree\n' + : 'one\nnew value\nthree\n'; + const bytes = new TextEncoder().encode(contents); + return { + key: item.key, + status: 'ready' as const, + value: { + bytes, + cacheKey: `${item.key}:cache`, + path: item.path, + provenance: 'native-git' as const, + size: bytes.byteLength, + }, + }; + }), + })); + window.codiff = { + applyUpdate: vi.fn(async () => ({ currentVersion: '0.0.0', phase: 'idle' as const })), + cancelDiffContentRequest: vi.fn(), + dismissUpdate: vi.fn(async () => ({ currentVersion: '0.0.0', phase: 'idle' as const })), + getReviewComments, + getUpdateStatus: vi.fn(async () => ({ currentVersion: '0.0.0', phase: 'idle' as const })), + isWindowFullScreen: vi.fn(async () => false), + onConfigChanged: vi.fn(() => unsubscribe), + onCopyPendingCommentsRequest: vi.fn(() => unsubscribe), + onFindInDiffs: vi.fn(() => unsubscribe), + onOpenReviewSource: vi.fn(() => unsubscribe), + onRefreshRequest: vi.fn(() => unsubscribe), + onRepositoryChanged: vi.fn(() => unsubscribe), + onUpdateStatusChanged: vi.fn(() => unsubscribe), + onWalkthroughProgress: vi.fn(() => unsubscribe), + onWindowFullScreenChanged: vi.fn(() => unsubscribe), + openRepositoryFolder: vi.fn(async () => {}), + readRevisionContent, + reportInitialLoadMilestone: vi.fn(), + resolvePullRequestUrl: vi.fn(async (value: string) => value), + } as unknown as Window['codiff']; + const config = createDefaultConfig(); + config.settings.showOutdated = true; + + await using view = await renderReact( + , + ); + + await waitFor(() => expect(getReviewComments).toHaveBeenCalledOnce()); + expect(readRevisionContent).not.toHaveBeenCalled(); + await waitFor(() => { + const commentsTab = Array.from( + view.container.querySelectorAll('[role="tab"]'), + ).find((button) => button.textContent?.includes('Comments')); + expect(commentsTab?.getAttribute('aria-label')).toBe('Comments (2)'); + }); + const commentsButton = Array.from(view.container.querySelectorAll('button')).find((button) => + button.textContent?.includes('Comments'), + ); + await act(async () => commentsButton?.click()); + const historicalButton = Array.from(view.container.querySelectorAll('button')).find((button) => + button.textContent?.includes('Render this against the historical code.'), + ); + await act(async () => historicalButton?.click()); + await waitFor(() => expect(readRevisionContent).toHaveBeenCalledOnce()); + await waitFor(() => { + const main = view.container.querySelector('main.review'); + expect( + main?.querySelectorAll('.codiff-file-header:not(.codiff-source-description-header)'), + ).toHaveLength(1); + }); + await act(async () => commentsButton?.click()); + const failedButton = Array.from(view.container.querySelectorAll('button')).find((button) => + button.textContent?.includes('Keep this thread when historical content cannot load.'), + ); + await act(async () => failedButton?.click()); + await waitFor(() => expect(readRevisionContent).toHaveBeenCalledTimes(2)); + const requestedShas = readRevisionContent.mock.calls.flatMap(([request]) => + request.requests.flatMap((item) => ('sha' in item.revision ? [item.revision.sha] : [])), + ); + expect(requestedShas).toEqual( + expect.arrayContaining([ + historicalRange.base.sha, + historicalRange.head.sha, + failedRange.base.sha, + failedRange.head.sha, + ]), + ); +}); + +test('RepositoryReviewHost hydrates provider content before mounting and cancels on unmount', async () => { + const baseFile = createChangedFile('src/lazy.ts', { kind: 'pull-request' }); + const file = { + ...baseFile, + sections: [ + { + ...baseFile.sections[0]!, + binary: false, + id: 'src/lazy.ts:pull-request', + kind: 'pull-request' as const, + loadState: 'deferred' as const, + patch: '', + summary: { canLoad: true, reason: 'Load exact contents.' }, + }, + ], + }; + const pullRequestState = { + ...state, + files: [file], + reviewCommentsLoadState: 'loaded' as const, + source: { + headSha: 'c'.repeat(40), + number: 42, + provider: 'github' as const, + targetBranch: 'main', + title: 'Lazy content', + type: 'pull-request' as const, + url: 'https://github.com/example/review/pull/42', + }, + } satisfies RepositoryState; + const readRevisionContent = vi.fn( + (_request: RevisionContentBatchRequest) => new Promise(() => {}), + ); + const cancelDiffContentRequest = vi.fn(); + window.codiff = { + applyUpdate: vi.fn(async () => ({ currentVersion: '0.0.0', phase: 'idle' as const })), + cancelDiffContentRequest, + dismissUpdate: vi.fn(async () => ({ currentVersion: '0.0.0', phase: 'idle' as const })), + getUpdateStatus: vi.fn(async () => ({ currentVersion: '0.0.0', phase: 'idle' as const })), + isWindowFullScreen: vi.fn(async () => false), + onConfigChanged: vi.fn(() => unsubscribe), + onCopyPendingCommentsRequest: vi.fn(() => unsubscribe), + onFindInDiffs: vi.fn(() => unsubscribe), + onOpenReviewSource: vi.fn(() => unsubscribe), + onRefreshRequest: vi.fn(() => unsubscribe), + onRepositoryChanged: vi.fn(() => unsubscribe), + onUpdateStatusChanged: vi.fn(() => unsubscribe), + onWalkthroughProgress: vi.fn(() => unsubscribe), + onWindowFullScreenChanged: vi.fn(() => unsubscribe), + openRepositoryFolder: vi.fn(async () => {}), + readRevisionContent, + resolvePullRequestUrl: vi.fn(async (value: string) => value), + } as unknown as Window['codiff']; + + const view = await renderReact( + , + ); + await waitFor(() => expect(readRevisionContent).toHaveBeenCalledOnce()); + expect(view.container.querySelector('main.review')).toBeNull(); + await view.cleanup(); + expect(cancelDiffContentRequest).toHaveBeenCalledWith( + readRevisionContent.mock.calls[0]![0].requestId, + ); +}); + +test('RepositoryReviewHost cancels an active image request on unmount', async () => { + const baseFile = createChangedFile('image.png', { kind: 'pull-request' }); + const file = { + ...baseFile, + sections: [ + { + ...baseFile.sections[0]!, + binary: true, + id: 'image.png:pull-request', + kind: 'pull-request' as const, + loadState: 'binary' as const, + patch: 'Binary files a/image.png and b/image.png differ', + }, + ], + }; + const pullRequestState = { + ...state, + files: [file], + reviewCommentsLoadState: 'loaded' as const, + source: { + headSha: 'c'.repeat(40), + number: 42, + provider: 'github' as const, + targetBranch: 'main', + title: 'Image content', + type: 'pull-request' as const, + url: 'https://github.com/example/review/pull/42', + }, + } satisfies RepositoryState; + const readRevisionContent = vi.fn( + (_request: RevisionContentBatchRequest) => new Promise(() => {}), + ); + const cancelDiffContentRequest = vi.fn(); + window.codiff = { + applyUpdate: vi.fn(async () => ({ currentVersion: '0.0.0', phase: 'idle' as const })), + cancelDiffContentRequest, + dismissUpdate: vi.fn(async () => ({ currentVersion: '0.0.0', phase: 'idle' as const })), + getUpdateStatus: vi.fn(async () => ({ currentVersion: '0.0.0', phase: 'idle' as const })), + isWindowFullScreen: vi.fn(async () => false), + onConfigChanged: vi.fn(() => unsubscribe), + onCopyPendingCommentsRequest: vi.fn(() => unsubscribe), + onFindInDiffs: vi.fn(() => unsubscribe), + onOpenReviewSource: vi.fn(() => unsubscribe), + onRefreshRequest: vi.fn(() => unsubscribe), + onRepositoryChanged: vi.fn(() => unsubscribe), + onUpdateStatusChanged: vi.fn(() => unsubscribe), + onWalkthroughProgress: vi.fn(() => unsubscribe), + onWindowFullScreenChanged: vi.fn(() => unsubscribe), + openRepositoryFolder: vi.fn(async () => {}), + readRevisionContent, + resolvePullRequestUrl: vi.fn(async (value: string) => value), + } as unknown as Window['codiff']; + + const view = await renderReact( + , + ); + await waitFor(() => expect(readRevisionContent).toHaveBeenCalledOnce()); + const requestId = readRevisionContent.mock.calls[0]![0].requestId; + expect(requestId).toMatch(/^revision-content:/); + await view.cleanup(); + expect(cancelDiffContentRequest).toHaveBeenCalledWith(requestId); +}); + +test('RepositoryReviewHost cancels provider comment enrichment on source replacement', async () => { + const source = { + headSha: 'c'.repeat(40), + number: 42, + provider: 'github' as const, + targetBranch: 'main', + title: 'Cancelable comments', + type: 'pull-request' as const, + url: 'https://github.com/example/review/pull/42', + }; + const firstState = { + ...state, + files: [createChangedFile('src/review.ts')], + reviewCommentsLoadState: 'not-loaded' as const, + source, + } satisfies RepositoryState; + const commitSha = 'd'.repeat(40) as GitSha; + const secondState = { + ...state, + files: [], + source: { sha: commitSha, type: 'commit' as const }, + } satisfies RepositoryState; + const getReviewComments = vi.fn( + (_source: typeof source, _requestId?: string) => new Promise(() => {}), + ); + const getRepositoryState = vi.fn(async () => secondState); + const cancelDiffContentRequest = vi.fn(); + window.codiff = { + applyUpdate: vi.fn(async () => ({ currentVersion: '0.0.0', phase: 'idle' as const })), + cancelDiffContentRequest, + cancelNarrativeWalkthrough: vi.fn(async () => {}), + dismissUpdate: vi.fn(async () => ({ currentVersion: '0.0.0', phase: 'idle' as const })), + getRepositoryHistory: vi.fn(async () => ({ entries: [], root: '/repo' })), + getRepositoryState, + getReviewComments, + getUpdateStatus: vi.fn(async () => ({ currentVersion: '0.0.0', phase: 'idle' as const })), + isWindowFullScreen: vi.fn(async () => false), + onConfigChanged: vi.fn(() => unsubscribe), + onCopyPendingCommentsRequest: vi.fn(() => unsubscribe), + onFindInDiffs: vi.fn(() => unsubscribe), + onOpenReviewSource: vi.fn(() => unsubscribe), + onRefreshRequest: vi.fn(() => unsubscribe), + onRepositoryChanged: vi.fn(() => unsubscribe), + onUpdateStatusChanged: vi.fn(() => unsubscribe), + onWalkthroughCommitOutput: vi.fn(() => unsubscribe), + onWalkthroughProgress: vi.fn(() => unsubscribe), + onWindowFullScreenChanged: vi.fn(() => unsubscribe), + openRepositoryFolder: vi.fn(async () => {}), + reportInitialLoadMilestone: vi.fn(), + resolvePullRequestUrl: vi.fn(async (value: string) => value), + } as unknown as Window['codiff']; + + await using view = await renderReact( + , + ); + + await waitFor(() => expect(getReviewComments).toHaveBeenCalledOnce()); + const requestId = getReviewComments.mock.calls[0]![1]; + expect(requestId).toMatch(/^review-comments:/); + const historyButton = Array.from(view.container.querySelectorAll('button')).find( + (button) => button.textContent?.trim() === 'History', + ); + await act(async () => historyButton?.click()); + let commitButton: HTMLButtonElement | undefined; + await waitFor(() => { + commitButton = Array.from( + view.container.querySelectorAll('.history-entry'), + ).find((candidate) => candidate.title === 'Commit source'); + expect(commitButton).not.toBeUndefined(); + }); + await act(async () => commitButton!.click()); + await waitFor(() => { + expect(getRepositoryState).toHaveBeenCalledWith({ ref: commitSha, type: 'commit' }); + expect(cancelDiffContentRequest).toHaveBeenCalledWith(requestId); + }); +}); diff --git a/core/__tests__/ReviewCodeView-scroll.test.tsx b/core/__tests__/ReviewCodeView-scroll.test.tsx index 295216ac..42cb3d4e 100644 --- a/core/__tests__/ReviewCodeView-scroll.test.tsx +++ b/core/__tests__/ReviewCodeView-scroll.test.tsx @@ -14,6 +14,8 @@ import { import type { ChangedFile, DefinitionSearchResult, + DiffSection, + GitSha, PullRequestCodeQualityFinding, ReviewSource, } from '../types.ts'; @@ -26,6 +28,8 @@ import { type ReviewDiffBlock, } from './helpers/review-code-view.tsx'; +const gitSha = (value: string) => value as GitSha; + const markdownEditorMock = vi.hoisted(() => ({ flush: vi.fn<() => Promise>(async () => true), heightByAriaLabel: new Map(), @@ -295,8 +299,8 @@ test('combined branch Markdown edits only the final working-tree section', async const initialFile = createCombinedFile('# Edited\n', 'plan.md:combined-initial'); const refreshedFile = createCombinedFile('# Saved\n', 'plan.md:combined-refreshed'); const combinedSource = { - baseRef: 'base123', - headRef: 'head123', + baseSha: gitSha('base123'), + headSha: gitSha('head123'), ref: 'main', type: 'branch-working-tree', } satisfies ReviewSource; @@ -381,8 +385,8 @@ test('combined branch-only Markdown sections remain read-only', async () => { { const comment = { author: { login: 'ai-reviewer', name: 'AI Code Reviewer' }, body: '
\nReview rationale\n\nThis branch needs attention.\n\n
', + destination: 'provider', filePath: file.path, id: 'comment-details', isReadOnly: true, + kind: 'submitted-comment', lineNumber: 1, - sectionId: file.sections[0].id, + resolvedSectionId: file.sections[0].id, side: 'additions', } satisfies ReviewComment; @@ -835,12 +844,14 @@ test('resolved review threads collapse inline, expand when focused, and can be r author: { login: 'reviewer' }, body: 'Please keep this explicit.', canResolveThread: true, + destination: 'provider', filePath: file.path, id: 'gitlab:99', isReadOnly: true, isThreadResolved: true, + kind: 'submitted-comment', lineNumber: 1, - sectionId: file.sections[0].id, + resolvedSectionId: file.sections[0].id, side: 'additions', threadId: 'discussion-1', }, @@ -848,12 +859,14 @@ test('resolved review threads collapse inline, expand when focused, and can be r author: { login: 'author' }, body: 'Resolved in the latest update.', canResolveThread: true, + destination: 'provider', filePath: file.path, id: 'gitlab:103', isReadOnly: true, isThreadResolved: true, + kind: 'submitted-comment', lineNumber: 1, - sectionId: file.sections[0].id, + resolvedSectionId: file.sections[0].id, side: 'additions', threadId: 'discussion-1', }, @@ -924,11 +937,13 @@ test('resolving an open review thread collapses it in place', async () => { author: { login: 'reviewer' }, body: 'Please keep this explicit.', canResolveThread: true, + destination: 'provider', filePath: file.path, id: 'gitlab:99', isReadOnly: true, + kind: 'submitted-comment', lineNumber: 1, - sectionId: file.sections[0].id, + resolvedSectionId: file.sections[0].id, side: 'additions', threadId: 'discussion-1', } satisfies ReviewComment; @@ -969,11 +984,13 @@ test('comment scroll targets navigate directly to their diff annotation', async const comment = { author: { login: 'reviewer' }, body: 'Linked review comment.', + destination: 'provider', filePath: file.path, id: 'gitlab:99', isReadOnly: true, + kind: 'submitted-comment', lineNumber: 1, - sectionId: file.sections[0].id, + resolvedSectionId: file.sections[0].id, side: 'additions', threadId: 'discussion-1', } satisfies ReviewComment; @@ -1112,7 +1129,7 @@ test('read-only walkthroughs can opt into the viewed control', async () => { }); const viewedButton = container.querySelector('.codiff-viewed-button'); expect(viewedButton).not.toBeNull(); - expect(container.querySelector('.codiff-button')).toBeNull(); + expect(container.querySelector('[title="Open file in editor"]')).not.toBeNull(); await act(async () => { viewedButton?.click(); }); @@ -1357,6 +1374,7 @@ test('hunk navigation orders deletion comments before added rows in unified chan body: 'Needs work.', filePath: 'src/first.ts', id: 'comment-1', + kind: 'local-note', lineNumber: 1, sectionId: 'src/first.ts:unstaged', side: 'deletions', @@ -1417,6 +1435,7 @@ test('review comment typing stays local until a comment action commits it', asyn body: '', filePath: file.path, id: 'comment-1', + kind: 'local-note', lineNumber: 1, sectionId: 'src/comment.ts:unstaged', side: 'additions', @@ -1468,7 +1487,10 @@ test('review comment typing stays local until a comment action commits it', asyn askButton.click(); }); expect(onUpdateComment).toHaveBeenCalledWith('comment-1', 'Please check this.'); - expect(onAskCodex).toHaveBeenCalledWith('comment-1'); + expect(onAskCodex).toHaveBeenCalledWith({ + ...comment, + body: 'Please check this.', + }); }); const renderLocalReviewComment = async ({ @@ -1491,6 +1513,7 @@ const renderLocalReviewComment = async ({ body, filePath: file.path, id: 'comment-1', + kind: 'local-note', lineNumber: 1, sectionId: file.sections[0].id, side: 'additions', @@ -1555,6 +1578,7 @@ function StatefulReviewCommentHarness({ const [comments, setComments] = useState>([initialComment]); const commentState = useReviewCommentDrafts({ comments, + draftKind: 'provider-draft', onCommentFileChange: ignoreCommentFileChange, setComments, }); @@ -1608,6 +1632,7 @@ test('pointer-driven comment blurs preserve each newly focused editor across rep body: '', filePath: file.path, id: 'comment-1', + kind: 'provider-draft', lineNumber: 1, sectionId: file.sections[0].id, side: 'additions', @@ -1750,7 +1775,9 @@ test('local review comments ask the agent with Mod+Alt+Enter', async () => { }; await setInputValue(textarea, 'Explain this change.'); await pressCommentShortcut(textarea, true); - expect(onAskCodex).toHaveBeenCalledWith('comment-1'); + expect(onAskCodex).toHaveBeenCalledWith( + expect.objectContaining({ body: 'Explain this change.', id: 'comment-1' }), + ); expect(onUpdateComment).toHaveBeenCalledWith('comment-1', 'Explain this change.'); expect(document.activeElement).toBe(textarea); }); @@ -1789,6 +1816,7 @@ test('a Codex reply does not repeatedly invalidate the diff item layout', async body: 'Please explain this change.', filePath: file.path, id: 'comment-1', + kind: 'local-note', lineNumber: 1, sectionId: 'src/comment.ts:unstaged', side: 'additions', @@ -1808,7 +1836,7 @@ test('a Codex reply does not repeatedly invalidate the diff item layout', async ...comment, codexReply: { body: 'This reply is tall enough to use a different markdown measurement.', - status: 'ready', + status: 'ready' as const, }, }, ]} @@ -1825,6 +1853,7 @@ test('failed pull request comments keep their draft and can be retried', async ( body: 'Keep this comment.', filePath: file.path, id: 'comment-1', + kind: 'provider-draft', lineNumber: 1, remoteSubmit: { error: @@ -1872,6 +1901,7 @@ test('working-tree share comments support the Comment button and Mod+Enter', asy body: 'Submit this shared comment.', filePath: file.path, id: 'shared-comment', + kind: 'share-draft', lineNumber: 1, sectionId: file.sections[0].id, side: 'additions', @@ -1913,8 +1943,13 @@ test('working-tree share comments support the Comment button and Mod+Enter', asy expect(onSubmitComment).toHaveBeenCalledWith(comment.id); }); -test('file comments can be created for GitLab merge requests but not GitHub pull requests', async () => { - const file = createChangedFile('src/comment.ts'); +test('file comments can be created for GitLab merge requests and GitHub pull requests', async () => { + const file: ChangedFile = createChangedFile('src/comment.ts'); + const range = { + base: { label: { kind: 'commit' as const, text: 'base' }, sha: gitSha('a'.repeat(40)) }, + head: { label: { kind: 'commit' as const, text: 'head' }, sha: gitSha('b'.repeat(40)) }, + }; + file.sections[0]!.range = range; const onCreateComment = vi.fn(); const gitLabSource = { provider: 'gitlab', @@ -1944,6 +1979,7 @@ test('file comments can be created for GitLab merge requests but not GitHub pull expect(onCreateComment).toHaveBeenCalledWith({ anchor: 'file', filePath: 'src/comment.ts', + position: { range }, sectionId: 'src/comment.ts:unstaged', }); await view.rerender( @@ -1954,7 +1990,17 @@ test('file comments can be created for GitLab merge requests but not GitHub pull supportsReviewCommentActions />, ); - expect(view.container.querySelector('.codiff-file-comment-button')).toBeNull(); + const gitHubFileCommentButton = view.container.querySelector( + '.codiff-file-comment-button', + ); + expect(gitHubFileCommentButton).not.toBeNull(); + await act(async () => gitHubFileCommentButton?.click()); + expect(onCreateComment).toHaveBeenLastCalledWith({ + anchor: 'file', + filePath: 'src/comment.ts', + position: { range }, + sectionId: 'src/comment.ts:unstaged', + }); }); test('file-level review comments render as measured file annotations', async () => { @@ -1966,10 +2012,12 @@ test('file-level review comments render as measured file annotations', async () anchor: 'file', author: { login: 'reviewer' }, body: 'Review this file as a whole.', + destination: 'provider', filePath: file.path, id: 'gitlab:file', isReadOnly: true, - sectionId: 'src/comment.ts:unstaged', + kind: 'submitted-comment', + resolvedSectionId: 'src/comment.ts:unstaged', }, ]} files={[file]} @@ -2381,7 +2429,7 @@ test('historical definitions outside the diff do not open the current checkout', files={[file]} onFindDefinitions={onFindDefinitions} onOpenDefinition={onOpenDefinition} - source={{ ref: 'abcdef0', type: 'commit' }} + source={{ sha: gitSha('abcdef0'), type: 'commit' }} />, ); await using _platform = { @@ -2537,7 +2585,7 @@ test('definition result is invalidated when the review source changes', async () files={[createChangedFile('src/next.ts', { kind: 'commit' })]} onFindDefinitions={onFindDefinitions} onOpenDefinition={() => {}} - source={{ ref: 'abcdef0', type: 'commit' }} + source={{ sha: gitSha('abcdef0'), type: 'commit' }} />, ); await act(async () => { @@ -2609,10 +2657,18 @@ test('modifier navigation follows file hosts reused by CodeView virtualization', test('line content clicks only ignore text selected on the clicked line', async () => { const onCreateComment = vi.fn(); - const file = createChangedFileWithPatch( + const file: ChangedFile = createChangedFileWithPatch( 'src/click.ts', 'diff --git a/src/click.ts b/src/click.ts\n@@ -1,2 +1,2 @@\n-old one\n-old two\n+new one\n+new two\n', ); + const reviewRange = { + base: { kind: 'index' as const, label: { kind: 'review-marker' as const, text: 'Index' } }, + head: { + kind: 'working-copy' as const, + label: { kind: 'review-marker' as const, text: 'Working copy' }, + }, + }; + file.sections[0]!.range = reviewRange; const container = document.createElement('div'); const shadowHost = document.createElement('div'); const shadowRoot = shadowHost.attachShadow({ mode: 'open' }); @@ -2657,6 +2713,7 @@ test('line content clicks only ignore text selected on the clicked line', async expect(onCreateComment).toHaveBeenLastCalledWith({ filePath: 'src/click.ts', lineNumber: 1, + position: { range: reviewRange }, sectionId: 'src/click.ts:unstaged', side: 'additions', }); @@ -2710,6 +2767,7 @@ test('line content clicks only ignore text selected on the clicked line', async expect(onCreateComment).toHaveBeenLastCalledWith({ filePath: 'src/click.ts', lineNumber: 2, + position: { range: reviewRange }, sectionId: 'src/click.ts:unstaged', side: 'additions', }); @@ -2723,6 +2781,7 @@ test('line content clicks only ignore text selected on the clicked line', async expect(onCreateComment).toHaveBeenLastCalledWith({ filePath: 'src/click.ts', lineNumber: 1, + position: { range: reviewRange }, sectionId: 'src/click.ts:unstaged', side: 'additions', }); @@ -2735,3 +2794,232 @@ test('line content clicks only ignore text selected on the clicked line', async }); expect(onCreateComment).toHaveBeenCalledTimes(4); }); + +test('read-only patch context resolves through the optional host capability without mutation', async () => { + const partialFile = createChangedFileWithPatch( + 'src/context.ts', + 'diff --git a/src/context.ts b/src/context.ts\n@@ -2 +2 @@\n-old\n+new\n', + ); + const file = { + ...partialFile, + sections: partialFile.sections.map((section) => ({ + ...section, + range: { + base: { label: { kind: 'commit' as const, text: 'base' }, sha: 'a'.repeat(40) as GitSha }, + head: { label: { kind: 'commit' as const, text: 'head' }, sha: 'b'.repeat(40) as GitSha }, + }, + })), + } satisfies ChangedFile; + const source = { + host: 'gitlab.example.com', + projectPath: 'example/repo', + provider: 'gitlab' as const, + type: 'pull-request' as const, + url: 'https://gitlab.example.com/example/repo/-/merge_requests/1', + }; + const before = JSON.stringify(file); + const resolveSectionContents = vi.fn(async () => ({ + newFile: { contents: 'before\nnew\nafter\n', name: file.path }, + oldFile: { contents: 'before\nold\nafter\n', name: file.path }, + })); + const view = await renderReact( + , + ); + + try { + const item = codeViewMock.lastItems.find((candidate) => candidate.type === 'diff') as + | { fileDiff: unknown } + | undefined; + const loadDiffFiles = codeViewMock.lastOptions?.loadDiffFiles as + | ((fileDiff: unknown) => Promise) + | undefined; + expect(loadDiffFiles).toBeTypeOf('function'); + await expect(loadDiffFiles?.(item?.fileDiff)).resolves.toMatchObject({ + newFile: { contents: 'before\nnew\nafter\n' }, + oldFile: { contents: 'before\nold\nafter\n' }, + }); + expect(resolveSectionContents).toHaveBeenCalledWith(file, file.sections[0]); + expect(JSON.stringify(file)).toBe(before); + } finally { + await view.cleanup(); + } +}); + +test('coverage warnings do not disable immutable context expansion for real files', async () => { + const partialFile = createChangedFileWithPatch( + 'src/truncated.ts', + 'diff --git a/src/truncated.ts b/src/truncated.ts\n@@ -2 +2 @@\n-old\n+new\n', + ); + const range = { + base: { + label: { kind: 'commit' as const, text: 'base' }, + sha: 'a'.repeat(40) as GitSha, + }, + head: { + label: { kind: 'commit' as const, text: 'head' }, + sha: 'b'.repeat(40) as GitSha, + }, + }; + const file = { + ...partialFile, + sections: partialFile.sections.map((section) => ({ + ...section, + kind: 'pull-request' as const, + loadState: 'ready' as const, + range, + })), + } satisfies ChangedFile; + const warning = { + fingerprint: 'review-range-incomplete:7', + path: 'Review diff incomplete', + sections: [ + { + binary: false, + id: 'review-range-incomplete:7', + kind: 'pull-request' as const, + loadState: 'error' as const, + patch: '', + summary: { + canLoad: false, + reason: 'GitLab returned only the first changed file.', + }, + }, + ], + status: 'modified' as const, + } satisfies ChangedFile; + const source = { + host: 'gitlab.example.com', + projectPath: 'example/repo', + provider: 'gitlab' as const, + type: 'pull-request' as const, + url: 'https://gitlab.example.com/example/repo/-/merge_requests/7', + }; + const resolveSectionContents = vi.fn(async () => ({ + newFile: { contents: 'before\nnew\nafter\n', name: file.path }, + oldFile: { contents: 'before\nold\nafter\n', name: file.path }, + })); + await using view = await renderReact( + , + ); + + const diffItems = codeViewMock.lastItems.filter( + (candidate) => candidate.type === 'diff', + ) as unknown as Array<{ + fileDiff: { isPartial?: boolean; name?: string }; + }>; + const realItem = diffItems.find((item) => item.fileDiff.name === file.path); + const warningItem = diffItems.find((item) => item.fileDiff.name === warning.path); + const loadDiffFiles = codeViewMock.lastOptions?.loadDiffFiles as + | ((fileDiff: unknown) => Promise) + | undefined; + + expect(loadDiffFiles).toBeTypeOf('function'); + await expect(loadDiffFiles?.(realItem?.fileDiff)).resolves.toMatchObject({ + newFile: { contents: 'before\nnew\nafter\n' }, + oldFile: { contents: 'before\nold\nafter\n' }, + }); + expect(resolveSectionContents).toHaveBeenCalledWith(file, file.sections[0]); + expect(view.container.textContent).toContain(warning.path); + expect(warningItem?.fileDiff.isPartial).toBe(false); + expect(resolveSectionContents).toHaveBeenCalledTimes(1); +}); + +test('mutable local sections retain context expansion through the host loader', async () => { + const partialFile = createChangedFileWithPatch( + 'src/local-context.ts', + 'diff --git a/src/local-context.ts b/src/local-context.ts\n@@ -2 +2 @@\n-old\n+new\n', + ); + const originalSection = partialFile.sections[0]!; + const file = { + ...partialFile, + sections: [ + { ...originalSection, id: 'src/local-context.ts:staged', kind: 'staged' as const }, + { ...originalSection, id: 'src/local-context.ts:unstaged', kind: 'unstaged' as const }, + ], + } satisfies ChangedFile; + const resolveSectionContents = vi.fn(async (_file: ChangedFile, section: DiffSection) => ({ + newFile: { contents: `before\n${section.kind} new\nafter\n`, name: file.path }, + oldFile: { contents: `before\n${section.kind} old\nafter\n`, name: file.path }, + })); + await using _view = await renderReact( + , + ); + + const diffItems = codeViewMock.lastItems.filter( + (candidate) => candidate.type === 'diff', + ) as unknown as Array<{ fileDiff: unknown }>; + const loadDiffFiles = codeViewMock.lastOptions?.loadDiffFiles as + | ((fileDiff: unknown) => Promise) + | undefined; + expect(loadDiffFiles).toBeTypeOf('function'); + for (const item of diffItems) { + await expect(loadDiffFiles?.(item.fileDiff)).resolves.toMatchObject({ + newFile: { contents: expect.stringContaining('new') }, + oldFile: { contents: expect.stringContaining('old') }, + }); + } + expect(resolveSectionContents).toHaveBeenCalledTimes(2); + expect(resolveSectionContents.mock.calls.map(([, section]) => section.kind)).toEqual([ + 'staged', + 'unstaged', + ]); +}); + +test('unavailable host context leaves the origin-independent patch review intact', async () => { + const partialFile = createChangedFile('src/unavailable-context.ts'); + const file = { + ...partialFile, + sections: partialFile.sections.map((section) => ({ + ...section, + range: { + base: { label: { kind: 'commit' as const, text: 'base' }, sha: 'c'.repeat(40) as GitSha }, + head: { label: { kind: 'commit' as const, text: 'head' }, sha: 'd'.repeat(40) as GitSha }, + }, + })), + } satisfies ChangedFile; + const view = await renderReact( + { + throw new Error("GitLab could not load before contents for 'src/unavailable-context.ts'."); + }} + source={{ + host: 'gitlab.example.com', + projectPath: 'example/repo', + provider: 'gitlab', + type: 'pull-request', + url: 'https://gitlab.example.com/example/repo/-/merge_requests/1', + }} + />, + ); + + try { + const item = codeViewMock.lastItems.find((candidate) => candidate.type === 'diff') as + | { fileDiff: unknown } + | undefined; + const loadDiffFiles = codeViewMock.lastOptions?.loadDiffFiles as ( + fileDiff: unknown, + ) => Promise; + await expect(loadDiffFiles(item?.fileDiff)).rejects.toThrow( + "GitLab could not load before contents for 'src/unavailable-context.ts'.", + ); + expect(codeViewMock.lastItems.some((candidate) => candidate.type === 'diff')).toBe(true); + } finally { + await view.cleanup(); + } +}); diff --git a/core/__tests__/ReviewSurface-capabilities.test.tsx b/core/__tests__/ReviewSurface-capabilities.test.tsx new file mode 100644 index 00000000..b6c6b33c --- /dev/null +++ b/core/__tests__/ReviewSurface-capabilities.test.tsx @@ -0,0 +1,1746 @@ +/** + * @vitest-environment jsdom + */ + +import { act, useEffect, useState, type Dispatch, type SetStateAction } from 'react'; +import { createRoot } from 'react-dom/client'; +import { expect, expectTypeOf, test, vi } from 'vite-plus/test'; +import { createDefaultConfig } from '../config/defaults.ts'; +import { getShortcutLabel } from '../config/keymap.ts'; +import type { + LocalReviewNote, + ProviderCommentDraft, + ProviderInlineComment, +} from '../lib/app-types.ts'; +import { + buildSharedReviewSnapshot, + ReviewSurface, + type ProviderReviewCommentCapabilities, + type ReviewSurfaceCommandBridge, + type ReviewSurfaceProps, + type ShareReviewCommentCapabilities, + type SubmitProviderReviewRequest, +} from '../ReviewSurface.tsx'; +import type { + CommitMetadata, + GitSha, + HistoryEntry, + NarrativeWalkthrough, + RepositoryState, + SharedWalkthroughSnapshot, + SubmittedReviewComment, +} from '../types.ts'; +import { createChangedFile } from './helpers/fixtures.ts'; +import { waitFor } from './helpers/react.tsx'; + +const reactActEnvironment = globalThis as typeof globalThis & { + ResizeObserver?: typeof ResizeObserver; +}; +reactActEnvironment.ResizeObserver ??= class ResizeObserver { + disconnect() {} + observe() {} + unobserve() {} +}; +HTMLElement.prototype.scrollBy ??= function scrollBy() {}; +HTMLElement.prototype.scrollIntoView ??= function scrollIntoView() {}; +HTMLElement.prototype.scrollTo ??= function scrollTo() {}; + +const snapshot = { + branch: 'main', + codiffVersion: 'test', + exportedAt: '2026-08-04T00:00:00.000Z', + files: [createChangedFile('src/app.ts')], + kind: 'codiff-walkthrough-share', + preferences: { + codeFontFamily: 'Fira Code', + codeFontSize: 13, + diffStyle: 'split', + showWhitespace: false, + theme: 'system', + wordWrap: false, + }, + repository: { root: '/repo', source: { type: 'working-tree' } }, + version: 1, + walkthrough: { + agent: 'codex', + chapters: [], + focus: 'Review capability boundaries.', + generatedAt: '2026-08-04T00:00:00.000Z', + kind: 'narrative', + repo: { branch: 'main', root: '/repo' }, + source: { type: 'working-tree' }, + support: [], + title: 'Capability review', + version: 4, + }, +} satisfies SharedWalkthroughSnapshot; + +const createShareComments = ( + overrides: Partial = {}, +): ShareReviewCommentCapabilities => ({ + authoring: {}, + destination: 'share', + inline: {}, + ...overrides, +}); + +const createProviderComments = ( + overrides: Partial = {}, +): ProviderReviewCommentCapabilities => ({ + authoring: {}, + destination: 'provider', + inline: {}, + ...overrides, +}); + +const providerSnapshot = { + ...snapshot, + repository: { + root: '/repo', + source: { + number: 7, + owner: 'cloudflare', + provider: 'github', + repo: 'codiff', + type: 'pull-request', + url: 'https://github.com/cloudflare/codiff/pull/7', + }, + }, + walkthrough: { + ...snapshot.walkthrough, + source: { + number: 7, + owner: 'cloudflare', + provider: 'github', + repo: 'codiff', + type: 'pull-request', + url: 'https://github.com/cloudflare/codiff/pull/7', + }, + }, +} satisfies SharedWalkthroughSnapshot; + +const gitlabProviderSnapshot = { + ...providerSnapshot, + repository: { + root: '/repo', + source: { + headSha: 'b'.repeat(40), + host: 'gitlab.example.com', + number: 23, + projectPath: 'group/project', + provider: 'gitlab', + title: 'Review neutral comments', + type: 'pull-request', + url: 'https://gitlab.example.com/group/project/-/merge_requests/23', + }, + }, + walkthrough: { + ...providerSnapshot.walkthrough, + source: { + headSha: 'b'.repeat(40), + host: 'gitlab.example.com', + number: 23, + projectPath: 'group/project', + provider: 'gitlab', + title: 'Review neutral comments', + type: 'pull-request', + url: 'https://gitlab.example.com/group/project/-/merge_requests/23', + }, + }, +} satisfies SharedWalkthroughSnapshot; + +type WithOptionalSnapshot = Props extends unknown + ? Omit & { snapshot?: SharedWalkthroughSnapshot } + : never; +type OptionalSnapshotProps = WithOptionalSnapshot; + +const renderSurface = async (props: OptionalSnapshotProps = {}) => { + const container = document.createElement('div'); + document.body.append(container); + const root = createRoot(container); + const render = async (nextProps: OptionalSnapshotProps) => { + const { snapshot: nextSnapshot = snapshot, ...rest } = nextProps; + await act(async () => root.render()); + }; + await render(props); + return { + container, + render, + async [Symbol.asyncDispose]() { + await act(async () => root.unmount()); + container.remove(); + }, + }; +}; + +const findButton = (container: HTMLElement, label: string) => + Array.from(container.querySelectorAll('button')).find( + (button) => button.textContent?.trim() === label, + ); + +const findModeButton = (container: HTMLElement, label: string) => + Array.from(container.querySelectorAll('button[role="tab"]')).find((button) => + (button.getAttribute('aria-label') ?? button.textContent?.trim())?.startsWith(label), + ); + +const openCommandBar = async () => { + await act(async () => { + window.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, ctrlKey: true, key: 'p' })); + }); +}; + +test('omits modes and commands whose capabilities are absent', async () => { + await using view = await renderSurface({ + initialMode: 'tree', + keymap: { ...createDefaultConfig().keymap, commandBar: 'Ctrl+p' }, + }); + + const modes = Array.from(view.container.querySelectorAll('[role="tab"]')).map((tab) => + tab.textContent?.trim(), + ); + expect(modes).toEqual(['Walkthrough', 'Tree']); + + await openCommandBar(); + expect(view.container.querySelector('.command-bar')?.textContent).not.toContain('Host action'); +}); + +test('keeps a controlled mode unchanged until its parent supplies a new value', async () => { + const onModeChange = vi.fn(); + const history = { + currentSource: { type: 'working-tree' } as const, + entries: [], + hasMore: false, + loading: false, + onLoadMore: vi.fn(), + onSelectSource: vi.fn(), + }; + await using view = await renderSurface({ + activeMode: { onChange: onModeChange, value: 'tree' }, + capabilities: { history }, + }); + + await act(async () => findButton(view.container, 'History')?.click()); + expect(onModeChange).toHaveBeenCalledWith('history'); + expect(findButton(view.container, 'Tree')?.getAttribute('aria-selected')).toBe('true'); + expect(findButton(view.container, 'History')?.getAttribute('aria-selected')).toBe('false'); + + await view.render({ + activeMode: { onChange: onModeChange, value: 'history' }, + capabilities: { history }, + }); + expect(findButton(view.container, 'Tree')?.getAttribute('aria-selected')).toBe('false'); + expect(findButton(view.container, 'History')?.getAttribute('aria-selected')).toBe('true'); +}); + +test('uses initialMode only for a surface that owns its later mode changes', async () => { + const history = { + currentSource: { type: 'working-tree' } as const, + entries: [], + hasMore: false, + loading: false, + onLoadMore: vi.fn(), + onSelectSource: vi.fn(), + }; + await using view = await renderSurface({ capabilities: { history }, initialMode: 'history' }); + + expect(findButton(view.container, 'History')?.getAttribute('aria-selected')).toBe('true'); + await act(async () => findButton(view.container, 'Tree')?.click()); + expect(findButton(view.container, 'Tree')?.getAttribute('aria-selected')).toBe('true'); +}); + +test('rejects simultaneous controlled and uncontrolled mode inputs at the type boundary', () => { + type ConflictingModeProps = { + activeMode: { onChange: (mode: 'tree') => void; value: 'tree' }; + initialMode: 'history'; + snapshot: SharedWalkthroughSnapshot; + }; + + expectTypeOf().not.toMatchTypeOf(); + + type ConflictingAnnotationProps = { + capabilities: { + comments: ShareReviewCommentCapabilities; + localReviewNotes: { canCreateInline: true }; + }; + snapshot: SharedWalkthroughSnapshot; + }; + expectTypeOf().not.toMatchTypeOf(); + expectTypeOf().not.toMatchTypeOf(); + expectTypeOf().not.toMatchTypeOf(); +}); + +test('exposes Comments through persisted-comment capabilities independent of source', async () => { + await using local = await renderSurface({ + capabilities: { localReviewNotes: { canCreateInline: true } }, + initialMode: 'tree', + }); + expect(findModeButton(local.container, 'Comments')).toBeUndefined(); + + await using share = await renderSurface({ + capabilities: { comments: createShareComments() }, + initialMode: 'tree', + }); + expect(findModeButton(share.container, 'Comments')).not.toBeUndefined(); + + await using provider = await renderSurface({ + capabilities: { comments: createProviderComments() }, + initialMode: 'tree', + snapshot: providerSnapshot, + }); + expect(findModeButton(provider.container, 'Comments')).not.toBeUndefined(); + + await using readOnly = await renderSurface({ + initialMode: 'tree', + snapshot: { + ...snapshot, + reviewComments: [ + { + author: { login: 'reviewer' }, + body: 'Existing read-only comment.', + filePath: snapshot.files[0]!.path, + id: 'existing-read-only', + lineNumber: 1, + side: 'additions', + }, + ], + }, + }); + expect(findModeButton(readOnly.container, 'Comments')).not.toBeUndefined(); +}); + +test('composes implicit inline reply permission with the host reply capability', async () => { + const file = snapshot.files[0]!; + const comment = { + author: { login: 'reviewer' }, + body: 'Reply to this existing thread.', + filePath: file.path, + id: 'existing-comment', + lineNumber: 1, + sectionId: file.sections[0]!.id, + side: 'additions' as const, + threadId: 'existing-thread', + }; + const snapshotWithThread = { + ...snapshot, + reviewComments: [comment], + } satisfies SharedWalkthroughSnapshot; + const onSubmit = vi.fn(async () => { + throw new Error('Not used by this test.'); + }); + const capabilities = { + comments: createShareComments({ + authoring: { canCreateInline: true }, + inline: { onSubmit }, + }), + }; + await using view = await renderSurface({ + capabilities, + initialMode: 'tree', + snapshot: snapshotWithThread, + }); + + await waitFor(() => expect(findButton(view.container, 'Reply')).not.toBeUndefined()); + + await view.render({ + capabilities, + initialMode: 'tree', + snapshot: { + ...snapshotWithThread, + reviewComments: [{ ...comment, canReplyThread: false }], + }, + }); + await waitFor(() => expect(findButton(view.container, 'Reply')).toBeUndefined()); + + await view.render({ + capabilities: { + comments: createShareComments({ + authoring: { canCreateInline: true }, + inline: {}, + }), + }, + initialMode: 'tree', + snapshot: snapshotWithThread, + }); + await waitFor(() => expect(findButton(view.container, 'Reply')).toBeUndefined()); +}); + +test('dispatches neutral GitLab review sessions with pending drafts and summaries', async () => { + const file = gitlabProviderSnapshot.files[0]!; + const draft: ProviderCommentDraft = { + body: 'Keep the provider target exact.', + filePath: file.path, + id: 'gitlab-draft-1', + kind: 'provider-draft', + lineNumber: 1, + position: { + range: { + base: { + label: { kind: 'commit', text: 'base' }, + sha: 'a'.repeat(40) as GitSha, + }, + head: { + label: { kind: 'commit', text: 'head' }, + sha: 'b'.repeat(40) as GitSha, + }, + }, + }, + sectionId: file.sections[0]!.id, + side: 'additions', + }; + const submit = vi.fn(async (_request: SubmitProviderReviewRequest) => ({ + status: 'submitted' as const, + submittedDraftIds: [draft.id], + })); + await using view = await renderSurface({ + capabilities: { + comments: createProviderComments({ + authoring: { canCreateInline: true }, + reviewSession: { + drafts: { onChange: vi.fn(), value: [draft] }, + submit, + }, + }), + }, + initialMode: 'tree', + snapshot: gitlabProviderSnapshot, + }); + + expect(view.container.querySelector('[aria-label="Submit review comments"]')).not.toBeNull(); + expect(view.container.querySelector('[aria-label="Approve review"]')).not.toBeNull(); + expect(view.container.querySelector('[aria-label="Request changes"]')).not.toBeNull(); + + await act(async () => + view.container + .querySelector('[aria-label="Submit review comments"]') + ?.click(), + ); + await waitFor(() => expect(submit).toHaveBeenCalledTimes(1)); + expect(submit).toHaveBeenNthCalledWith(1, { + comments: [ + expect.objectContaining({ + body: draft.body, + filePath: draft.filePath, + localDraftId: draft.id, + position: draft.position, + }), + ], + outcome: 'comment', + }); + + await act(async () => + view.container.querySelector('[aria-label="Add review comment"]')?.click(), + ); + const editor = view.container.querySelector( + '[contenteditable="true"][aria-label="Add review comment"]', + ); + expect(editor).not.toBeNull(); + await act(async () => { + if (editor) { + editor.focus(); + editor.textContent = 'Neutral GitLab summary.'; + const selection = window.getSelection(); + const range = document.createRange(); + range.selectNodeContents(editor); + range.collapse(false); + selection?.removeAllRanges(); + selection?.addRange(range); + document.dispatchEvent(new Event('selectionchange')); + editor.dispatchEvent( + new InputEvent('input', { + bubbles: true, + data: 'Neutral GitLab summary.', + inputType: 'insertText', + }), + ); + } + }); + await waitFor(() => + expect( + view.container.querySelector('.review-submit-popover-submit.comment') + ?.disabled, + ).toBe(false), + ); + await act(async () => + view.container + .querySelector('.review-submit-popover-submit.comment') + ?.click(), + ); + await waitFor(() => expect(submit).toHaveBeenCalledTimes(2)); + expect(submit).toHaveBeenNthCalledWith(2, { + comments: [expect.objectContaining({ localDraftId: draft.id })], + outcome: 'comment', + summary: 'Neutral GitLab summary.', + }); +}); + +test('keeps title-only source footer actions reachable', async () => { + const onMergePullRequest = vi.fn(async () => {}); + const source = { + ...providerSnapshot.repository.source, + description: undefined, + mergeState: { + autoMergeEnabled: false, + canCancelAutoMerge: false, + canMerge: true, + canSetAutoMerge: false, + checks: [], + forceRemoveSourceBranch: false, + options: { removeSourceBranch: false, squash: false }, + sha: 'a'.repeat(40), + status: 'ready' as const, + statusLabel: 'Ready to merge', + }, + title: 'Title-only source', + }; + await using view = await renderSurface({ + capabilities: { sourceNavigation: { onMergePullRequest } }, + initialMode: 'tree', + snapshot: { + ...providerSnapshot, + repository: { ...providerSnapshot.repository, source }, + walkthrough: { ...providerSnapshot.walkthrough, source }, + }, + }); + + expect(view.container.textContent).toContain('Title-only source'); + expect(view.container.querySelector('button[aria-label="Expand description"]')).toBeNull(); + const merge = findButton(view.container, 'Merge'); + expect(merge).not.toBeUndefined(); + await act(async () => merge?.click()); + expect(onMergePullRequest).toHaveBeenCalledWith({ + autoMerge: false, + removeSourceBranch: false, + squash: false, + }); +}); + +test('keeps empty provider Comments visible and guides inline feedback through Tree', async () => { + await using empty = await renderSurface({ + capabilities: { comments: createProviderComments() }, + initialMode: 'comments', + snapshot: providerSnapshot, + }); + expect(findButton(empty.container, 'Comments')).not.toBeUndefined(); + expect(empty.container.textContent).toContain('No review comments yet'); + expect(empty.container.textContent).toContain('Add inline feedback from Tree'); + expect(empty.container.textContent).toContain('PR #7'); + + const file = providerSnapshot.files[0]!; + await using inlineOnly = await renderSurface({ + capabilities: { comments: createProviderComments() }, + initialMode: 'comments', + snapshot: { + ...providerSnapshot, + reviewComments: [ + { + author: { login: 'reviewer' }, + body: 'Inline feedback exists.', + filePath: file.path, + id: 'inline-only', + lineNumber: 1, + side: 'additions', + }, + ], + }, + }); + expect(inlineOnly.container.textContent).toContain('No overview comments yet'); + expect(inlineOnly.container.textContent).toContain( + 'Inline review comments are available in Tree.', + ); + expect( + Array.from(inlineOnly.container.querySelectorAll('[role="tab"]')) + .find((button) => button.textContent?.includes('Comments')) + ?.getAttribute('aria-label'), + ).toBe('Comments (1)'); +}); + +test('renders overview threads before Pierre-anchored old, ranged, and file comments', async () => { + const file = createChangedFile('src/anchored.ts', { + kind: 'pull-request', + patch: + 'diff --git a/src/anchored.ts b/src/anchored.ts\n@@ -1,3 +1,3 @@\n context\n-old value\n+new value\n tail\n', + }); + const position = { range: file.sections[0]!.range! }; + await using view = await renderSurface({ + capabilities: { comments: createProviderComments() }, + initialMode: 'comments', + snapshot: { + ...providerSnapshot, + files: [file], + repository: { + ...providerSnapshot.repository, + generalComments: [ + { + comments: [ + { + author: { login: 'overview-author' }, + body: 'Overview feedback arrives with the inline threads.', + id: 'overview-1', + url: 'https://github.test/overview-1', + }, + ], + id: 'overview-thread', + }, + ], + }, + reviewComments: [ + { + author: { login: 'old-author' }, + body: 'Old-side feedback stays on the reported deletion.', + filePath: file.path, + id: 'old-side', + lineNumber: 2, + position, + side: 'deletions', + threadId: 'old-thread', + }, + { + author: { login: 'range-author' }, + body: 'The cross-side range stays intact.', + filePath: file.path, + id: 'cross-side-range', + lineNumber: 2, + position, + side: 'additions', + startLineNumber: 2, + startSide: 'deletions', + threadId: 'range-thread', + }, + { + anchor: 'file', + author: { login: 'file-author' }, + body: 'The file-level thread stays on this file.', + filePath: file.path, + id: 'file-comment', + position, + threadId: 'file-thread', + }, + ], + }, + }); + + const main = view.container.querySelector('main.review'); + expect(main?.textContent).toContain('Overview feedback arrives with the inline threads.'); + expect(main?.textContent).toContain('Old-side feedback stays on the reported deletion.'); + expect(main?.textContent).toContain('The cross-side range stays intact.'); + expect(main?.textContent).toContain('The file-level thread stays on this file.'); + expect(main?.textContent?.indexOf('Overview feedback')).toBeLessThan( + main?.textContent?.indexOf('Old-side feedback') ?? -1, + ); + expect(main?.querySelectorAll('.codiff-file-header')).toHaveLength(3); + expect(view.container.textContent).toContain('Old line 2'); + expect(view.container.textContent).toContain('Old line 2 to New line 2'); + expect(view.container.textContent).toContain('File'); + expect(main?.textContent).not.toContain('Comments without a code region'); +}); + +test('lists complete threads when revision, file, side, or line coordinates cannot resolve', async () => { + const file = createChangedFile('src/available.ts', { kind: 'pull-request' }); + const position = { range: file.sections[0]!.range! }; + await using view = await renderSurface({ + capabilities: { comments: createProviderComments() }, + initialMode: 'comments', + snapshot: { + ...providerSnapshot, + files: [file], + reviewComments: [ + { + author: { login: 'revision-author' }, + body: 'Missing immutable revision.', + filePath: file.path, + id: 'missing-revision', + lineNumber: 1, + side: 'additions', + threadId: 'missing-revision-thread', + }, + { + author: { login: 'file-author' }, + body: 'Missing file.', + filePath: 'src/missing.ts', + id: 'missing-file', + lineNumber: 1, + position, + side: 'additions', + threadId: 'missing-file-thread', + }, + { + author: { login: 'side-author' }, + body: 'Missing side.', + filePath: file.path, + id: 'missing-side', + lineNumber: 1, + position, + threadId: 'missing-side-thread', + }, + { + author: { login: 'line-author' }, + body: 'Out-of-range line.', + filePath: file.path, + id: 'bad-line', + lineNumber: 999, + position, + side: 'additions', + threadId: 'bad-line-thread', + }, + ], + }, + }); + + const missing = view.container.querySelector('main.review .missing-review-comments'); + expect(missing?.querySelectorAll('.missing-review-comment-thread')).toHaveLength(4); + for (const text of [ + 'Missing immutable revision.', + 'Missing file.', + 'Missing side.', + 'Out-of-range line.', + ]) { + expect(missing?.textContent).toContain(text); + } + expect(missing?.textContent).toContain('revision-author'); + expect(missing?.textContent).toContain('Code region unavailable'); +}); + +test('preserves missing-region permalink and edit, delete, reply, and resolve actions', async () => { + const onDelete = vi.fn(async () => {}); + const onResolve = vi.fn(async () => {}); + const onSubmit = vi.fn(async () => { + throw new Error('Not submitted by this test.'); + }); + const onUpdate = vi.fn(async () => {}); + await using view = await renderSurface({ + capabilities: { + comments: createProviderComments({ + inline: { onDelete, onResolve, onSubmit, onUpdate }, + }), + }, + initialMode: 'comments', + snapshot: { + ...providerSnapshot, + reviewComments: [ + { + author: { login: 'action-author' }, + body: 'Keep every action available in the fallback.', + canDelete: true, + canEdit: true, + canResolveThread: true, + filePath: 'src/missing-actions.ts', + id: 'action-comment', + lineNumber: 12, + position: { range: providerSnapshot.files[0]!.sections[0]!.range! }, + side: 'additions', + threadId: 'action-thread', + url: 'https://github.test/action-comment', + }, + ], + }, + }); + + const fallback = view.container.querySelector('main.review .missing-review-comments'); + expect(fallback?.textContent).toContain('Keep every action available in the fallback.'); + expect(fallback?.textContent).toContain('action-author'); + expect(fallback?.querySelector('a[href="https://github.test/action-comment"]')).not.toBeNull(); + expect(findButton(fallback as HTMLElement, 'Edit')).not.toBeUndefined(); + expect(fallback?.querySelector('button[aria-label="Delete comment"]')).not.toBeNull(); + expect(findButton(fallback as HTMLElement, 'Reply')).not.toBeUndefined(); + const resolve = findButton(fallback as HTMLElement, 'Resolve'); + expect(resolve).not.toBeUndefined(); + await act(async () => resolve?.click()); + await waitFor(() => expect(onResolve).toHaveBeenCalledWith('action-thread', true)); +}); + +test('keeps unresolved outdated provider comments in Comments without guessing a diff target', async () => { + const unresolvedSnapshot = { + ...providerSnapshot, + reviewComments: [ + { + author: { login: 'reviewer' }, + body: 'The original provider line is unavailable.', + filePath: 'src/missing.ts', + id: 'provider:unresolved', + isOutdated: true, + lineNumber: 999, + position: { range: providerSnapshot.files[0]!.sections[0]!.range! }, + side: 'additions' as const, + url: 'https://github.com/cloudflare/codiff/pull/7#discussion_r1', + }, + ], + } satisfies SharedWalkthroughSnapshot; + await using view = await renderSurface({ + capabilities: { comments: createProviderComments() }, + initialMode: 'comments', + snapshot: unresolvedSnapshot, + }); + + const commentsTab = () => + Array.from(view.container.querySelectorAll('[role="tab"]')).find((button) => + button.textContent?.includes('Comments'), + ); + expect(commentsTab()?.getAttribute('aria-label')).toBe('Comments (1)'); + expect(view.container.textContent).toContain('Location unavailable'); + expect(view.container.textContent).toContain('The original provider line is unavailable.'); + expect(view.container.textContent).toContain('reviewer'); + expect(view.container.textContent).toContain('Outdated'); + expect(view.container.textContent).toContain('View on provider'); + const entry = view.container.querySelector('.sidebar-comment-entry'); + await act(async () => entry?.click()); + expect(commentsTab()?.getAttribute('aria-selected')).toBe('true'); + expect(entry?.getAttribute('aria-current')).toBe('true'); + expect(view.container.querySelector('.file-tree-shell')).toBeNull(); + + await view.render({ + capabilities: { + comments: createProviderComments(), + preferences: { outdatedVisibility: { onChange: vi.fn(), value: false } }, + }, + initialMode: 'comments', + snapshot: unresolvedSnapshot, + }); + expect(commentsTab()?.getAttribute('aria-label')).toBe('Comments'); + expect(view.container.textContent).not.toContain('The original provider line is unavailable.'); +}); + +test('labels shared review provenance as Codiff rather than a provider', async () => { + const file = snapshot.files[0]!; + await using view = await renderSurface({ + capabilities: { comments: createShareComments() }, + initialMode: 'comments', + snapshot: { + ...snapshot, + reviewComments: [ + { + author: { login: 'ada' }, + body: 'Shared Codiff feedback.', + filePath: file.path, + id: 'share:comment', + lineNumber: 1, + sectionId: file.sections[0]!.id, + side: 'additions', + url: 'https://codiff.example/share/comment/1', + }, + ], + }, + }); + + expect(view.container.textContent).toContain('Shared Codiff feedback.'); + expect(view.container.textContent).toContain('ada'); + expect(view.container.textContent).toContain('View on Codiff'); + expect(view.container.textContent).not.toContain('View on provider'); +}); + +test('keeps provider source-description collapse state across Tree and Comments', async () => { + const richSnapshot = { + ...providerSnapshot, + repository: { + ...providerSnapshot.repository, + source: { + ...providerSnapshot.repository.source, + author: { login: 'ada', name: 'Ada Lovelace' }, + description: 'Shared source-description body.', + headSha: 'a'.repeat(40) as HistoryEntry['sha'], + title: 'Shared source-description title', + }, + }, + walkthrough: { + ...providerSnapshot.walkthrough, + source: { + ...providerSnapshot.walkthrough.source, + author: { login: 'ada', name: 'Ada Lovelace' }, + description: 'Shared source-description body.', + headSha: 'a'.repeat(40) as HistoryEntry['sha'], + title: 'Shared source-description title', + }, + }, + } satisfies SharedWalkthroughSnapshot; + await using view = await renderSurface({ + capabilities: { comments: createProviderComments() }, + initialMode: 'tree', + snapshot: richSnapshot, + }); + + const collapse = view.container.querySelector( + 'button[aria-label="Collapse description"]', + ); + expect(collapse).not.toBeNull(); + await act(async () => collapse?.click()); + expect(view.container.querySelector('button[aria-label="Expand description"]')).not.toBeNull(); + + await act(async () => findButton(view.container, 'Comments')?.click()); + expect(view.container.querySelector('button[aria-label="Expand description"]')).not.toBeNull(); + expect(view.container.textContent).toContain('Shared source-description title'); + expect(view.container.textContent).not.toContain('Shared source-description body.'); + + await act(async () => findButton(view.container, 'Tree')?.click()); + expect(view.container.querySelector('button[aria-label="Expand description"]')).not.toBeNull(); +}); + +test('renders the same source-description semantics once in Tree, Walkthrough, and Comments', async () => { + const file = providerSnapshot.files[0]!; + const source = { + ...providerSnapshot.repository.source, + author: { login: 'ada', name: 'Ada Lovelace' }, + description: 'Cross-mode source-description body.', + headSha: 'b'.repeat(40) as HistoryEntry['sha'], + title: 'Cross-mode source-description title', + }; + const walkthrough = { + ...providerSnapshot.walkthrough, + chapters: [ + { + blurb: 'Review the source overview.', + icon: 'gear' as const, + id: 'overview', + stops: [ + { + added: 1, + deleted: 1, + hunkIds: [`${file.sections[0]!.id}:h1`], + hunks: [ + { + added: 1, + anchor: { + display: file.path, + sectionId: file.sections[0]!.id, + side: 'both' as const, + }, + deleted: 1, + id: `${file.sections[0]!.id}:h1`, + path: file.path, + status: file.status, + }, + ], + id: 'overview-stop', + importance: 'critical' as const, + prose: 'Review the source overview.', + title: 'Overview stop', + }, + ], + title: 'Overview', + }, + ], + source, + } satisfies NarrativeWalkthrough; + await using view = await renderSurface({ + capabilities: { + comments: createProviderComments({ + reviewSession: { + drafts: { onChange: vi.fn(), value: [] }, + submit: async () => ({ status: 'submitted', submittedDraftIds: [] }), + }, + }), + }, + initialMode: 'walkthrough', + snapshot: { + ...providerSnapshot, + repository: { ...providerSnapshot.repository, source }, + walkthrough, + }, + }); + const assertSourceDescriptionOnce = () => { + expect(view.container.textContent?.match(/Cross-mode source-description title/g)).toHaveLength( + 1, + ); + expect(view.container.textContent?.match(/Cross-mode source-description body\./g)).toHaveLength( + 1, + ); + expect(view.container.textContent?.match(/Ada Lovelace/g)).toHaveLength(1); + expect(view.container.textContent?.match(/Approve/g)).toHaveLength(1); + }; + + await waitFor(assertSourceDescriptionOnce); + await act(async () => findButton(view.container, 'Tree')?.click()); + await waitFor(assertSourceDescriptionOnce); + await act(async () => findButton(view.container, 'Comments')?.click()); + await waitFor(assertSourceDescriptionOnce); +}); + +test('copies local notes with the local label and Markdown heading', async () => { + const file = snapshot.files[0]!; + const draft = { + body: 'Keep this local note.', + filePath: file.path, + id: 'local-note', + kind: 'local-note', + lineNumber: 1, + sectionId: file.sections[0]!.id, + side: 'additions', + } satisfies LocalReviewNote; + const writeText = vi.fn(async () => {}); + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { writeText }, + }); + const bridge = { current: null as ReviewSurfaceCommandBridge | null }; + await using view = await renderSurface({ + capabilities: { + localReviewNotes: { + drafts: { onChange: vi.fn(), value: [draft] }, + }, + }, + initialMode: 'tree', + onCommandBridgeChange: (value) => { + bridge.current = value; + }, + }); + + const copyButton = view.container.querySelector( + 'button[aria-label="Copy Review Notes"]', + ); + expect(copyButton).not.toBeNull(); + const markdown = bridge.current?.copyPendingComments(); + expect(markdown).toContain('# Address these Review Notes'); + await act(async () => copyButton?.click()); + await waitFor(() => expect(writeText).toHaveBeenCalledWith(markdown)); +}); + +test('copies provider drafts with the provider label and Markdown heading', async () => { + const file = providerSnapshot.files[0]!; + const draft = { + body: 'Submit this provider draft.', + filePath: file.path, + id: 'provider-draft', + kind: 'provider-draft', + lineNumber: 1, + sectionId: file.sections[0]!.id, + side: 'additions', + } satisfies ProviderCommentDraft; + const writeText = vi.fn(async () => {}); + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { writeText }, + }); + const bridge = { current: null as ReviewSurfaceCommandBridge | null }; + await using view = await renderSurface({ + capabilities: { + comments: createProviderComments({ + authoring: { drafts: { onChange: vi.fn(), value: [draft] } }, + }), + }, + initialMode: 'tree', + onCommandBridgeChange: (value) => { + bridge.current = value; + }, + snapshot: providerSnapshot, + }); + + const copyButton = view.container.querySelector( + 'button[aria-label="Copy Pending Review Comments"]', + ); + expect(copyButton).not.toBeNull(); + const markdown = bridge.current?.copyPendingComments(); + expect(markdown).toContain('# Address these Pending Review Comments'); + await act(async () => copyButton?.click()); + await waitFor(() => expect(writeText).toHaveBeenCalledWith(markdown)); +}); + +test('excludes existing provider comments from Ask and pending-copy flows', async () => { + const file = createChangedFile('src/app.ts', { kind: 'pull-request' }); + const onAsk = vi.fn(); + const bridge = { current: null as ReviewSurfaceCommandBridge | null }; + await using view = await renderSurface({ + capabilities: { + comments: createProviderComments({ + authoring: { + drafts: { onChange: vi.fn(), value: [] }, + onAsk, + }, + }), + }, + initialMode: 'tree', + onCommandBridgeChange: (value) => { + bridge.current = value; + }, + snapshot: { + ...providerSnapshot, + files: [file], + reviewComments: [ + { + author: { login: 'reviewer' }, + body: 'Existing provider feedback.', + filePath: file.path, + id: 'provider:existing', + lineNumber: 1, + position: { range: file.sections[0]!.range! }, + side: 'additions', + }, + ], + }, + }); + + expect(view.container.textContent).toContain('Existing provider feedback.'); + expect(findButton(view.container, 'Ask')).toBeUndefined(); + const copyButton = view.container.querySelector('.copy-comments-button'); + expect(copyButton).not.toBeNull(); + expect(copyButton?.disabled).toBe(true); + expect(copyButton?.getAttribute('title')).toBe('Copy Pending Review Comments (0)'); + expect(bridge.current?.copyPendingComments()).toBe(''); + expect(onAsk).not.toHaveBeenCalled(); +}); + +test('hides the persistent copy action while a desktop source switch is pending', async () => { + const file = snapshot.files[0]!; + await using view = await renderSurface({ + capabilities: { + desktop: { isSwitchingSource: true }, + localReviewNotes: { + drafts: { + onChange: vi.fn(), + value: [ + { + body: 'Keep this note through the source switch.', + filePath: file.path, + id: 'switching-note', + kind: 'local-note', + lineNumber: 1, + sectionId: file.sections[0]!.id, + side: 'additions', + }, + ], + }, + }, + }, + initialMode: 'tree', + }); + + expect(view.container.querySelector('.copy-comments-button')).toBeNull(); +}); + +test('shows the configured sidebar shortcut in the collapse tooltip', async () => { + const keymap = { ...createDefaultConfig().keymap, toggleSidebar: 'Alt+b' }; + await using view = await renderSurface({ initialMode: 'tree', keymap }); + const toggle = view.container.querySelector('.sidebar-toggle-button'); + expect(toggle?.title).toBe(`Collapse sidebar (${getShortcutLabel(keymap, 'toggleSidebar')})`); + await act(async () => toggle?.click()); + expect(toggle?.title).toBe(`Expand sidebar (${getShortcutLabel(keymap, 'toggleSidebar')})`); +}); + +test('shows the file filter only in Tree and keeps History unfiltered', async () => { + const entries = [ + { + author: 'Ada Lovelace', + committedAt: Date.now(), + parentShas: [], + sha: 'a'.repeat(40) as HistoryEntry['sha'], + subject: 'Fix parser', + }, + { + author: 'Grace Hopper', + committedAt: Date.now(), + parentShas: [], + sha: 'b'.repeat(40) as HistoryEntry['sha'], + subject: 'Update docs', + }, + ] satisfies ReadonlyArray; + const onLoadMore = vi.fn(); + await using view = await renderSurface({ + capabilities: { + history: { + currentSource: { type: 'working-tree' }, + entries, + hasMore: true, + loading: false, + onLoadMore, + onSelectSource: vi.fn(), + }, + }, + initialMode: 'tree', + }); + expect(view.container.querySelector('.sidebar-search')?.placeholder).toBe( + 'Filter files', + ); + await act(async () => findButton(view.container, 'Walkthrough')?.click()); + expect(view.container.querySelector('.sidebar-search')).toBeNull(); + await act(async () => findButton(view.container, 'History')?.click()); + expect(view.container.querySelector('.sidebar-search')).toBeNull(); + const historySubjects = () => + Array.from(view.container.querySelectorAll('.history-entry-subject')).map( + (element) => element.textContent, + ); + expect(historySubjects()).toEqual(['Uncommitted changes', 'Fix parser', 'Update docs']); + + await act(async () => { + view.container.querySelector('.history-list')?.dispatchEvent(new Event('scroll')); + }); + expect(onLoadMore).toHaveBeenCalledOnce(); +}); + +test('preserves structured walkthrough failure metadata', async () => { + await using view = await renderSurface({ + capabilities: { + walkthrough: { + error: { code: 'PI_NOT_FOUND', reason: 'Pi CLI was not found.' }, + status: 'failed', + }, + }, + initialMode: 'walkthrough', + }); + expect(view.container.textContent).toContain('Pi CLI was not found.'); +}); + +test('renders tailored recovery for every unavailable agent executable', async () => { + for (const { agent, code, displayLabel, reasonLabel } of [ + { agent: 'codex', code: 'CODEX_NOT_FOUND', displayLabel: 'Codex', reasonLabel: 'Codex' }, + { + agent: 'claude', + code: 'CLAUDE_NOT_FOUND', + displayLabel: 'Claude Code', + reasonLabel: 'Claude', + }, + { + agent: 'opencode', + code: 'OPENCODE_NOT_FOUND', + displayLabel: 'OpenCode', + reasonLabel: 'OpenCode', + }, + { agent: 'pi', code: 'PI_NOT_FOUND', displayLabel: 'Pi', reasonLabel: 'Pi' }, + ] as const) { + await using view = await renderSurface({ + capabilities: { + walkthrough: { + error: { code, reason: `${reasonLabel} CLI was not found.` }, + status: 'failed', + }, + }, + initialMode: 'walkthrough', + snapshot: { + ...snapshot, + walkthrough: { ...snapshot.walkthrough, agent }, + }, + }); + + expect(view.container.textContent).toContain(`${displayLabel} CLI not found`); + expect(view.container.textContent).toContain(`${reasonLabel} CLI was not found.`); + expect(findButton(view.container, 'Review Files')).not.toBeUndefined(); + expect(findButton(view.container, 'Try again')).toBeUndefined(); + await act(async () => findButton(view.container, 'Review Files')?.click()); + expect(findButton(view.container, 'Tree')?.getAttribute('aria-selected')).toBe('true'); + } +}); + +test('keeps generic and task-level generation failures retryable', async () => { + const onGenerate = vi.fn(async () => {}); + await using generic = await renderSurface({ + capabilities: { + walkthrough: { + error: { reason: 'Generation stopped.' }, + onGenerate, + status: 'failed', + }, + }, + initialMode: 'walkthrough', + }); + expect(findButton(generic.container, 'Try again')).not.toBeUndefined(); + await act(async () => findButton(generic.container, 'Try again')?.click()); + expect(onGenerate).toHaveBeenCalledTimes(1); + + const retryFailedTasks = vi.fn(async () => {}); + await using partial = await renderSurface({ + capabilities: { + walkthrough: { + error: { reason: 'One task failed.' }, + generationProgress: { + completed: 1, + phase: 'generating-units', + summary: 'One walkthrough task failed.', + total: 2, + units: [ + { id: 'ready', label: 'Ready task', status: 'ready' }, + { + detail: 'Model request failed.', + id: 'failed', + label: 'Failed task', + status: 'failed', + }, + ], + }, + onGenerate: retryFailedTasks, + status: 'failed', + }, + }, + initialMode: 'walkthrough', + }); + expect(partial.container.textContent).toContain('Model request failed.'); + expect(findButton(partial.container, 'Retry failed tasks')).not.toBeUndefined(); + await act(async () => findButton(partial.container, 'Retry failed tasks')?.click()); + expect(retryFailedTasks).toHaveBeenCalledTimes(1); + await partial.render({ + capabilities: { walkthrough: { status: 'ready' } }, + initialMode: 'walkthrough', + }); + expect(findButton(partial.container, 'Retry failed tasks')).toBeUndefined(); +}); + +test('renders the walkthrough unread indicator from host capability state', async () => { + await using view = await renderSurface({ + capabilities: { walkthrough: { unread: true } }, + initialMode: 'tree', + }); + expect(view.container.querySelector('.review-mode-dot')).not.toBeNull(); + + await view.render({ + capabilities: { walkthrough: { unread: false } }, + initialMode: 'tree', + }); + expect(view.container.querySelector('.review-mode-dot')).toBeNull(); +}); + +test('composes host commands while keeping controlled preferences authoritative', async () => { + const onModeChange = vi.fn(); + const onWordWrapChange = vi.fn(); + const hostAction = vi.fn(); + const bridge = { current: null as ReviewSurfaceCommandBridge | null }; + await using view = await renderSurface({ + activeMode: { onChange: onModeChange, value: 'tree' }, + capabilities: { + comments: createProviderComments({ onSignIn: () => {} }), + desktop: { + commands: [{ execute: hostAction, id: 'host-action', title: 'Host action' }], + }, + history: { + currentSource: { type: 'working-tree' }, + entries: [], + hasMore: false, + loading: false, + onLoadMore: () => {}, + onSelectSource: () => {}, + }, + preferences: { + wordWrap: { onChange: onWordWrapChange, value: true }, + }, + }, + keymap: { + ...createDefaultConfig().keymap, + commandBar: 'Ctrl+p', + toggleWordWrap: 'Ctrl+Alt+w', + }, + onCommandBridgeChange: (value) => { + bridge.current = value; + }, + snapshot: providerSnapshot, + }); + + expect( + Array.from(view.container.querySelectorAll('[role="tab"]')).map((tab) => + tab.textContent?.trim(), + ), + ).toEqual(['Walkthrough', 'Tree', 'History', 'Comments']); + + await act(async () => findButton(view.container, 'Comments')?.click()); + expect(onModeChange).toHaveBeenCalledWith('comments'); + expect(findButton(view.container, 'Tree')?.getAttribute('aria-selected')).toBe('true'); + expect(bridge.current?.getPersistenceState().mode).toBe('tree'); + + await openCommandBar(); + await act(async () => findButton(view.container, 'Host action')?.click()); + expect(hostAction).toHaveBeenCalledTimes(1); + + await act(async () => { + window.dispatchEvent( + new KeyboardEvent('keydown', { altKey: true, bubbles: true, ctrlKey: true, key: 'w' }), + ); + }); + expect(onWordWrapChange).toHaveBeenCalledWith(false); +}); + +test('forwards controlled draft updates atomically across an asynchronous submission', async () => { + const file = snapshot.files[0]!; + const firstDraft: ProviderCommentDraft = { + body: 'First draft', + filePath: file.path, + id: 'draft-1', + kind: 'provider-draft', + lineNumber: 1, + position: { + range: { + base: { + label: { kind: 'commit', text: 'base' }, + sha: 'a'.repeat(40) as import('../types.ts').GitSha, + }, + head: { + label: { kind: 'commit', text: 'head' }, + sha: 'b'.repeat(40) as import('../types.ts').GitSha, + }, + }, + }, + sectionId: file.sections[0]!.id, + side: 'additions', + }; + const secondDraft: ProviderCommentDraft = { + ...firstDraft, + body: 'Second draft', + id: 'draft-2', + }; + let completeSubmission!: (comment: SubmittedReviewComment) => void; + const submission = new Promise((resolve) => { + completeSubmission = resolve; + }); + const onSubmitComment = vi.fn(() => submission); + let setDrafts!: Dispatch>>; + let latestDrafts: ReadonlyArray = []; + + function ControlledSurface({ currentSnapshot }: { currentSnapshot: SharedWalkthroughSnapshot }) { + const [drafts, updateDrafts] = useState>([firstDraft]); + useEffect(() => { + setDrafts = updateDrafts; + latestDrafts = drafts; + }, [drafts]); + return ( + + ); + } + + const container = document.createElement('div'); + document.body.append(container); + const root = createRoot(container); + await act(async () => root.render()); + const commentButton = Array.from( + container.querySelectorAll('button.review-comment-action'), + ).find((button) => button.textContent === 'Comment'); + expect(commentButton).not.toBeUndefined(); + await act(async () => commentButton?.click()); + expect(onSubmitComment).toHaveBeenCalledTimes(1); + await act(async () => setDrafts((current) => [...current, secondDraft])); + await act(async () => + completeSubmission({ + author: { login: 'ada', name: 'Ada' }, + body: firstDraft.body, + destination: 'provider', + filePath: firstDraft.filePath, + id: 'submitted-1', + isReadOnly: true, + lineNumber: firstDraft.lineNumber, + position: firstDraft.position, + side: firstDraft.side, + }), + ); + await waitFor(() => expect(latestDrafts.map(({ id }) => id)).toEqual(['draft-2'])); + expect(container.textContent).toContain('First draft'); + + const nextSnapshot = { + ...providerSnapshot, + repository: { + root: '/another-repo', + source: { + ...providerSnapshot.repository.source, + number: 8, + url: 'https://github.com/cloudflare/codiff/pull/8', + }, + }, + } satisfies SharedWalkthroughSnapshot; + await act(async () => root.render()); + await waitFor(() => expect(container.textContent).not.toContain('First draft')); + + await act(async () => root.unmount()); + container.remove(); +}); + +test('search filters never load hidden full-file content', async () => { + const deferredFile = { + ...createChangedFile('src/lazy.ts', { kind: 'pull-request', patch: '' }), + sections: [ + { + binary: false, + id: 'src/lazy.ts:pull-request', + kind: 'pull-request', + loadState: 'deferred', + patch: '', + summary: { canLoad: true, reason: 'Load exact contents.' }, + }, + ], + } satisfies SharedWalkthroughSnapshot['files'][number]; + const lazySnapshot = { + ...snapshot, + files: [deferredFile], + repository: { + root: '/repo', + source: { + number: 7, + owner: 'cloudflare', + provider: 'github', + repo: 'codiff', + type: 'pull-request', + url: 'https://github.com/cloudflare/codiff/pull/7', + }, + }, + } satisfies SharedWalkthroughSnapshot; + const onLoadSection = vi.fn(); + const bridge = { current: null as ReviewSurfaceCommandBridge | null }; + await using view = await renderSurface({ + capabilities: { content: { onLoadSection } }, + initialMode: 'tree', + onCommandBridgeChange: (value) => { + bridge.current = value; + }, + snapshot: lazySnapshot, + }); + + await act(async () => bridge.current?.openDiffSearch()); + const input = view.container.querySelector('.diff-search-input'); + expect(input).not.toBeNull(); + const setInputValue = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set; + await act(async () => { + setInputValue?.call(input, 'needle'); + input?.dispatchEvent(new Event('input', { bubbles: true })); + }); + const filters = [ + ...view.container.querySelectorAll('.diff-search-filters input'), + ]; + for (const filter of filters) { + await act(async () => filter.click()); + await act(async () => filter.click()); + } + expect(onLoadSection).not.toHaveBeenCalled(); + expect(view.container.querySelector('.empty-panel')?.textContent).toContain( + 'No matches in diffs', + ); +}); + +test('tracks controlled collapsed and viewed state across same-source rerenders', async () => { + const file = snapshot.files[0]!; + const capabilities = { + desktop: { + collapsed: new Set(), + onCollapsedChange: vi.fn(), + onViewedChange: vi.fn(), + viewed: {}, + }, + } satisfies NonNullable; + await using view = await renderSurface({ capabilities, initialMode: 'tree' }); + expect( + view.container.querySelector('[aria-label="Collapse file"]')?.getAttribute('aria-expanded'), + ).toBe('true'); + + await view.render({ + capabilities: { + desktop: { + ...capabilities.desktop, + collapsed: new Set([file.path]), + viewed: { [file.path]: file.fingerprint }, + }, + }, + initialMode: 'tree', + }); + expect( + view.container.querySelector('[aria-label="Expand file"]')?.getAttribute('aria-expanded'), + ).toBe('false'); + const tree = view.container.querySelector('file-tree-container'); + const viewedStyle = tree?.shadowRoot?.querySelector( + 'style[data-codiff-viewed-rows]', + ); + expect(viewedStyle?.textContent).toContain(file.path); +}); + +test('distinguishes web, windowed desktop, and fullscreen desktop shell spacing', async () => { + await using web = await renderSurface(); + expect(web.container.querySelector('.app-shell')?.className).toContain('share-shell'); + expect(web.container.querySelector('.app-shell')?.className).not.toContain('merge-request-shell'); + + await using desktop = await renderSurface({ capabilities: { desktop: {} } }); + expect(desktop.container.querySelector('.app-shell')?.className).toContain('merge-request-shell'); + expect(desktop.container.querySelector('.app-shell')?.className).not.toContain( + 'window-fullscreen', + ); + + await using fullscreen = await renderSurface({ + capabilities: { desktop: { isWindowFullscreen: true } }, + }); + expect(fullscreen.container.querySelector('.app-shell')?.className).toContain( + 'window-fullscreen', + ); +}); + +test('preserves generated commit text when desktop commit capability is enabled', async () => { + const file = snapshot.files[0]!; + const seededSnapshot = { + ...snapshot, + walkthrough: { + ...snapshot.walkthrough, + chapters: [ + { + blurb: 'Review the implementation.', + icon: 'gear', + id: 'implementation', + stops: [ + { + added: 1, + deleted: 1, + hunkIds: [`${file.sections[0]!.id}:h1`], + hunks: [ + { + added: 1, + anchor: { + display: file.path, + sectionId: file.sections[0]!.id, + side: 'both', + }, + deleted: 1, + id: `${file.sections[0]!.id}:h1`, + path: file.path, + status: file.status, + }, + ], + id: 'implementation-path', + importance: 'critical', + prose: 'Review this file.', + title: 'Implementation path', + }, + ], + title: 'Implementation', + }, + ], + commit: { body: 'Keep the generated body.', title: 'Use the generated subject' }, + }, + } satisfies SharedWalkthroughSnapshot; + await using view = await renderSurface({ + capabilities: { + walkthrough: { + commit: async () => ({ sha: 'a'.repeat(40) as CommitMetadata['sha'], status: 'committed' }), + status: 'ready', + updateCommitMessage: async (request) => ({ + body: request.body, + status: 'ready', + subject: request.subject, + }), + }, + }, + snapshot: seededSnapshot, + }); + const commitAction = view.container.querySelector('.wt-toc-commit-action'); + expect(commitAction).not.toBeNull(); + await act(async () => commitAction?.click()); + await waitFor(() => { + expect(view.container.querySelector('.wt-commit-subject-field')?.value).toBe( + 'Use the generated subject', + ); + }); + expect(view.container.querySelector('.wt-commit-msg-input')?.value).toBe( + 'Keep the generated body.', + ); +}); + +test('copies commit metadata into snapshots and renders the commit card', async () => { + const sha = 'a'.repeat(40) as CommitMetadata['sha']; + const person = { + date: '2026-08-04T00:00:00.000Z', + email: 'ada@example.com', + name: 'Ada Lovelace', + }; + const commitMetadata = { + author: person, + body: 'Explain the immutable review target.', + committer: person, + files: [], + parentShas: [], + refs: ['main'], + sha, + shortSha: sha.slice(0, 7), + signature: { status: 'unsigned' }, + stats: { + additions: 1, + binaryFiles: 0, + deletions: 1, + files: 1, + renamedFiles: 0, + }, + subject: 'Preserve commit context', + trailers: [], + } satisfies CommitMetadata; + const state = { + branch: 'main', + commitMetadata, + files: snapshot.files, + generatedAt: Date.parse(snapshot.exportedAt), + launchPath: '/repo', + root: '/repo', + source: { sha, type: 'commit' }, + } satisfies RepositoryState; + const commitSnapshot = buildSharedReviewSnapshot({ + preferences: snapshot.preferences, + state, + title: 'Commit review', + walkthrough: { ...snapshot.walkthrough, source: state.source }, + }); + expect(commitSnapshot.commitMetadata).toEqual(commitMetadata); + + await using view = await renderSurface({ initialMode: 'tree', snapshot: commitSnapshot }); + await waitFor(() => expect(view.container.textContent).toContain('Preserve commit context')); + expect(view.container.textContent).toContain('Explain the immutable review target.'); + expect(view.container.textContent).toContain('Ada Lovelace'); +}); + +test('forwards the active walkthrough review target to the desktop host', async () => { + const file = snapshot.files[0]!; + const walkthrough = { + ...snapshot.walkthrough, + chapters: [ + { + blurb: 'Review the implementation.', + icon: 'gear', + id: 'implementation', + stops: [ + { + added: 1, + deleted: 1, + hunkIds: [`${file.sections[0]!.id}:h1`], + hunks: [ + { + added: 1, + anchor: { + display: file.path, + sectionId: file.sections[0]!.id, + side: 'both', + }, + deleted: 1, + id: `${file.sections[0]!.id}:h1`, + path: file.path, + status: file.status, + }, + ], + id: 'implementation-path', + importance: 'critical', + prose: 'Review this file.', + title: 'Implementation path', + }, + ], + title: 'Implementation', + }, + ], + } satisfies NarrativeWalkthrough; + const onTargetChange = vi.fn(); + await using view = await renderSurface({ + capabilities: { + desktop: { onActiveWalkthroughReviewTargetChange: onTargetChange }, + }, + initialMode: 'walkthrough', + snapshot: { ...snapshot, walkthrough }, + }); + await act(async () => findButton(view.container, 'Implementation path')?.click()); + await waitFor(() => + expect(onTargetChange).toHaveBeenCalledWith( + expect.objectContaining({ + file: expect.objectContaining({ path: file.path }), + reviewIdentity: expect.objectContaining({ key: expect.any(String) }), + }), + ), + ); +}); + +test('distinguishes an empty source from filtered files', async () => { + await using view = await renderSurface({ + initialMode: 'tree', + snapshot: { ...snapshot, files: [] }, + }); + expect(view.container.querySelector('.empty-panel')?.textContent).toContain('No local changes'); + expect(view.container.querySelector('.empty-panel')?.textContent).toContain('/repo'); +}); diff --git a/core/__tests__/SharedWalkthroughApp.test.tsx b/core/__tests__/ReviewSurface.test.tsx similarity index 57% rename from core/__tests__/SharedWalkthroughApp.test.tsx rename to core/__tests__/ReviewSurface.test.tsx index 23a3c2c3..eddce81d 100644 --- a/core/__tests__/SharedWalkthroughApp.test.tsx +++ b/core/__tests__/ReviewSurface.test.tsx @@ -6,10 +6,11 @@ import { act } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { expect, test, vi } from 'vite-plus/test'; import { ReviewTopBar } from '../app/components/ReviewTopBar.tsx'; -import { ReviewSurface, type ReviewCommenting } from '../react.ts'; +import { createDefaultConfig } from '../config/defaults.ts'; +import { ReviewSurface } from '../ReviewSurface.tsx'; import type { NarrativeWalkthrough, SharedWalkthroughSnapshot } from '../types.ts'; import { createChangedFile } from './helpers/fixtures.ts'; -import { renderReact, waitFor } from './helpers/react.tsx'; +import { waitFor } from './helpers/react.tsx'; const reactActEnvironment = globalThis as typeof globalThis & { ResizeObserver?: typeof ResizeObserver; @@ -20,6 +21,7 @@ reactActEnvironment.ResizeObserver ??= class ResizeObserver { unobserve() {} }; HTMLElement.prototype.scrollBy ??= function scrollBy() {}; +HTMLElement.prototype.scrollIntoView ??= function scrollIntoView() {}; HTMLElement.prototype.scrollTo ??= function scrollTo() {}; const createMarkdownFile = (path = 'README.md') => @@ -57,290 +59,323 @@ const commenting = { onReplyGeneralComment: async () => {}, onResolveDiscussion: async () => {}, onSignIn: () => {}, - onSubmitComment: async () => { - throw new Error('Not used by this test.'); - }, onSubmitGeneralComment: async () => {}, onUpdateComment: async () => {}, onUpdateGeneralComment: async () => {}, -} satisfies ReviewCommenting; +}; -const sharedWalkthroughSource = { type: 'working-tree' } as const; -const sharedWalkthroughSnapshot = { - branch: 'main', - codiffVersion: '1.4.1', - exportedAt: '2026-06-19T00:00:00.000Z', - files: [createChangedFile('src/app.ts')], - kind: 'codiff-walkthrough-share', - preferences: { - codeFontFamily: 'Fira Code', - codeFontSize: 13, - diffStyle: 'split', - showWhitespace: false, - theme: 'system', - wordWrap: false, - }, - repository: { root: '/Users/ada/dev/codiff-web', source: sharedWalkthroughSource }, - version: 1, - walkthrough: { - agent: 'codex', - chapters: [], - focus: 'Focus on the implementation.', - generatedAt: '2026-06-19T00:00:00.000Z', - kind: 'narrative', - repo: { branch: 'main', root: '/Users/ada/dev/codiff-web' }, - source: sharedWalkthroughSource, - support: [], - title: 'Shared walkthrough', - version: 4, - }, -} satisfies SharedWalkthroughSnapshot; +test('review top bar renders its leading control at the far left', async () => { + const container = document.createElement('div'); + document.body.append(container); + const root = createRoot(container); -test.each([ - { iconTransform: null, position: 'left' }, - { iconTransform: 'scale(-1, 1)', position: 'right' }, -] as const)('review top bar places its sidebar control on the $position', async (testCase) => { - await using view = await renderReact( - - Codiff - - } - mode="tree" - modes={[{ icon: null, label: 'Tree', value: 'tree' }]} - onModeChange={() => {}} - onToggleSidebar={() => {}} - repository="cloudflare/voidzero/codiff-web" - sidebarCollapsed={false} - sidebarPosition={testCase.position} - toggleTitle="Collapse sidebar" - />, - ); + await act(async () => { + root.render( + + Codiff + + } + mode="tree" + modes={[{ icon: null, label: 'Tree', value: 'tree' }]} + onModeChange={() => {}} + onToggleSidebar={() => {}} + repository="cloudflare/voidzero/codiff-web" + sidebarCollapsed={false} + toggleTitle="Collapse sidebar" + />, + ); + }); - const leftRegion = view.container.querySelector('.review-top-bar-left'); + const leftRegion = container.querySelector('.review-top-bar-left'); expect(leftRegion?.firstElementChild?.className).toBe('codiff-logo'); expect(leftRegion?.nextElementSibling?.classList.contains('review-mode-control')).toBe(true); expect(leftRegion?.nextElementSibling?.nextElementSibling?.className).toBe( 'review-top-bar-right', ); - const toggle = view.container.querySelector( - `.review-top-bar-${testCase.position} > .sidebar-toggle-button`, - ); - expect(toggle).not.toBeNull(); - expect(toggle?.querySelector('svg')?.getAttribute('transform')).toBe(testCase.iconTransform); -}); -test.each([ - { columns: '292px 0 minmax(0, 1fr)', position: 'left' }, - { columns: 'minmax(0, 1fr) 0 292px', position: 'right' }, -] as const)('$position review surface layout', async (testCase) => { - window.localStorage.clear(); - await using view = await renderReact( - , - ); - - const shell = view.container.querySelector('.app-shell'); - expect(shell?.dataset.sidebarPosition).toBe(testCase.position); - await waitFor(() => { - expect(shell?.style.gridTemplateColumns).toBe(testCase.columns); - }); + await act(async () => root.unmount()); + container.remove(); }); -test('review surface starts with the sidebar collapsed on mobile viewports', async () => { - window.localStorage.clear(); - const matchMedia = vi.spyOn(window, 'matchMedia').mockImplementation( - (query) => - ({ - addEventListener() {}, - addListener() {}, - dispatchEvent: () => false, - matches: query === '(max-width: 720px)', - media: query, - onchange: null, - removeEventListener() {}, - removeListener() {}, - }) as MediaQueryList, - ); +test('share viewer shows the complete repository path when there is no repository link', async () => { + const file = createChangedFile('src/app.ts'); + const source = { type: 'working-tree' } as const; + const snapshot = { + branch: 'main', + codiffVersion: '1.4.1', + exportedAt: '2026-06-19T00:00:00.000Z', + files: [file], + kind: 'codiff-walkthrough-share', + preferences: { + codeFontFamily: 'Fira Code', + codeFontSize: 13, + diffStyle: 'split', + showWhitespace: false, + theme: 'system', + wordWrap: false, + }, + repository: { root: '/Users/ada/dev/codiff-web', source }, + reviewComments: [], + version: 1, + walkthrough: { + agent: 'codex', + chapters: [], + focus: 'Review the change.', + generatedAt: '2026-06-19T00:00:00.000Z', + kind: 'narrative', + repo: { branch: 'main', root: '/Users/ada/dev/codiff-web' }, + source, + support: [], + title: 'Review', + version: 4, + }, + } satisfies SharedWalkthroughSnapshot; + const originalHome = process.env.HOME; + process.env.HOME = '/Users/ada'; + const container = document.createElement('div'); + document.body.append(container); + let root!: Root; try { - await using view = await renderReact(); - + await act(async () => { + root = createRoot(container); + root.render(); + }); await waitFor(() => { - expect( - view.container.querySelector('.app-shell')?.classList.contains('sidebar-collapsed'), - ).toBe(true); - expect( - view.container - .querySelector('.sidebar-toggle-button') - ?.getAttribute('aria-label'), - ).toBe('Expand sidebar'); + expect(container.querySelector('.review-top-bar-repository')).not.toBeNull(); }); + expect(container.querySelector('.review-top-bar-repository')?.textContent).toBe( + '~/dev/codiff-web', + ); + } finally { + if (originalHome == null) { + delete process.env.HOME; + } else { + process.env.HOME = originalHome; + } + await act(async () => root.unmount()); + container.remove(); + } +}); + +test('shared review surface owns configured search and portable commands', async () => { + const file = createChangedFile('src/shared-command.ts'); + const snapshot = { + branch: 'main', + codiffVersion: 'test', + exportedAt: '2026-07-29T00:00:00.000Z', + files: [file], + kind: 'codiff-walkthrough-share', + preferences: { + codeFontFamily: 'Fira Code', + codeFontSize: 13, + diffStyle: 'split', + showWhitespace: false, + theme: 'system', + wordWrap: false, + }, + repository: { root: '/repo', source: { type: 'working-tree' } }, + version: 1, + walkthrough: { + agent: 'codex', + chapters: [], + focus: 'Review the change.', + generatedAt: '2026-07-29T00:00:00.000Z', + kind: 'narrative', + repo: { branch: 'main', root: '/repo' }, + source: { type: 'working-tree' }, + support: [], + title: 'Review', + version: 4, + }, + } satisfies SharedWalkthroughSnapshot; + const keymap = { + ...createDefaultConfig().keymap, + commandBar: 'Ctrl+p', + diffSearch: 'Ctrl+g', + }; + const container = document.createElement('div'); + document.body.append(container); + const root = createRoot(container); + try { await act(async () => { - view.container.querySelector('.sidebar-toggle-button')?.click(); + root.render(); }); - expect( - view.container.querySelector('.app-shell')?.classList.contains('sidebar-collapsed'), - ).toBe(false); - expect( - JSON.parse(window.localStorage.getItem('codiff:web-review-surface-preferences:v1') ?? '{}'), - ).toEqual({ sidebarCollapsed: false }); + await act(async () => { + window.dispatchEvent( + new KeyboardEvent('keydown', { bubbles: true, ctrlKey: true, key: 'g' }), + ); + }); + expect(container.querySelector('.diff-search-panel.visible')).not.toBeNull(); - await using persistedView = await renderReact( - , - ); - await waitFor(() => { - expect( - persistedView.container - .querySelector('.app-shell') - ?.classList.contains('sidebar-collapsed'), - ).toBe(false); + await act(async () => { + window.dispatchEvent( + new KeyboardEvent('keydown', { bubbles: true, ctrlKey: true, key: 'p' }), + ); }); + const commandBar = container.querySelector('.command-bar'); + expect(commandBar?.textContent).toContain('Find in Diffs'); + expect(commandBar?.textContent).not.toContain('Open File in Editor'); + expect(commandBar?.textContent).not.toContain('Open Config File'); } finally { - window.localStorage.clear(); - matchMedia.mockRestore(); + await act(async () => root.unmount()); + container.remove(); } }); -test('a resolved general discussion stays collapsed unless its hash targets the thread', async () => { - let finishResolve: (() => void) | null = null; - const onResolveDiscussion = vi.fn( - () => - new Promise((resolve) => { - finishResolve = resolve; - }), - ); - const resolvedSnapshot = { - ...sharedWalkthroughSnapshot, +test('review threads expose provider provenance and code status navigates to the current diff', async () => { + const firstFile = createChangedFile('src/first.ts'); + const file = createChangedFile('src/thread.ts', { kind: 'pull-request' }); + const scrollIntoView = vi.spyOn(HTMLElement.prototype, 'scrollIntoView'); + const source = { + number: 31, + projectPath: 'example-org/example-repo', + provider: 'gitlab', + type: 'pull-request', + url: 'https://gitlab.example.com/example-org/example-repo/-/merge_requests/31', + } as const; + const snapshot = { + branch: 'feature', + codiffVersion: 'test', + exportedAt: '2026-07-29T00:00:00.000Z', + files: [firstFile, file], + kind: 'codiff-walkthrough-share', + preferences: { + codeFontFamily: 'Fira Code', + codeFontSize: 13, + diffStyle: 'split', + showWhitespace: false, + theme: 'system', + wordWrap: false, + }, repository: { - ...sharedWalkthroughSnapshot.repository, generalComments: [ { - canReply: true, - canResolve: true, comments: [ { - author: { login: 'reviewer' }, - body: 'This conversation is resolved.', - id: 'gitlab:200', - url: 'https://gitlab.example.com/group/project/-/merge_requests/1#note_200', + author: { + login: 'overview-reviewer', + name: 'Overview Reviewer', + url: 'https://gitlab.example.com/overview-reviewer', + }, + body: 'Overview feedback.', + id: 'overview-comment', + submittedAt: '2026-07-29T00:00:00.000Z', + url: `${source.url}#note_1`, }, ], - id: 'general-discussion', - isResolved: true, + id: 'overview-thread', }, ], + root: '/repo', + source, }, - } satisfies SharedWalkthroughSnapshot; - - window.history.replaceState(null, '', '/review'); - await using collapsedView = await renderReact( - , - ); - expect( - collapsedView.container - .querySelector('.resolved-thread-toggle') - ?.getAttribute('aria-expanded'), - ).toBe('false'); - expect(collapsedView.container.querySelector('.resolved-thread-content')).toBeNull(); - - window.history.replaceState(null, '', '/review#general-discussion'); - window.dispatchEvent(new HashChangeEvent('hashchange')); - await waitFor(() => { - expect( - collapsedView.container - .querySelector('.resolved-thread-toggle') - ?.getAttribute('aria-expanded'), - ).toBe('true'); - expect(collapsedView.container.textContent).toContain('This conversation is resolved.'); - }); - - const reopen = [...collapsedView.container.querySelectorAll('button')].find( - (button) => button.textContent === 'Reopen', - ); - await act(async () => reopen?.click()); - expect(onResolveDiscussion).toHaveBeenCalledWith('general-discussion', false); - expect(collapsedView.container.querySelector('.resolved-thread-toggle')).toBeNull(); - expect( - [...collapsedView.container.querySelectorAll('button')].some( - (button) => button.textContent === 'Reply', - ), - ).toBe(false); - - await act(async () => finishResolve?.()); - expect( - [...collapsedView.container.querySelectorAll('button')].some( - (button) => button.textContent === 'Reply', - ), - ).toBe(true); - window.history.replaceState(null, '', '/'); -}); - -test('a resolved inline discussion expands when its note hash is targeted', async () => { - const resolvedSnapshot = { - ...sharedWalkthroughSnapshot, reviewComments: [ { - author: { login: 'reviewer' }, - body: 'This inline conversation is resolved.', - canResolveThread: true, - filePath: 'src/app.ts', - id: 'gitlab:99', - isThreadResolved: true, + author: { + login: 'code-reviewer', + name: 'Code Reviewer', + url: 'https://gitlab.example.com/code-reviewer', + }, + body: 'This line changed later.', + filePath: file.path, + id: 'outdated-comment', + isOutdated: true, lineNumber: 1, - side: 'additions', - threadId: 'line-discussion', - url: 'https://gitlab.example.com/group/project/-/merge_requests/1#note_99', + position: { range: file.sections[0]!.range! }, + sectionId: file.sections[0]!.id, + side: 'additions' as const, + submittedAt: '2026-07-29T00:01:00.000Z', + url: `${source.url}#note_2`, }, ], + version: 1, + walkthrough: { + agent: 'codex', + chapters: [], + focus: 'Review the change.', + generatedAt: '2026-07-29T00:00:00.000Z', + kind: 'narrative', + repo: { branch: 'feature', root: '/repo' }, + source, + support: [], + title: 'Review', + version: 4, + }, } satisfies SharedWalkthroughSnapshot; + const container = document.createElement('div'); + document.body.append(container); + const root = createRoot(container); - window.history.replaceState(null, '', '/review#note_99'); - await using view = await renderReact( - , - ); + try { + await act(async () => { + root.render( + , + ); + }); - await waitFor(() => { + expect(container.textContent).toContain('Overview comments'); + expect(container.textContent).toContain('Inline review comments'); + expect( + [...container.querySelectorAll('[role="tab"]')] + .find((tab) => tab.textContent?.includes('Comments')) + ?.getAttribute('aria-label'), + ).toBe('Comments (2)'); expect( - view.container - .querySelector('.resolved-thread-toggle') + container.querySelector(`a[href="https://gitlab.example.com/overview-reviewer"]`), + ).not.toBeNull(); + const overviewPermalink = container.querySelector( + `a[href="${source.url}#note_1"]`, + ); + expect(overviewPermalink?.textContent).toContain('View on GitLab'); + + const outdatedStatus = [...container.querySelectorAll('span')].find( + ({ textContent }) => textContent === 'Outdated', + ); + expect(outdatedStatus?.getAttribute('role')).toBeNull(); + const inlineEntry = [ + ...container.querySelectorAll('.sidebar-comment-entry'), + ].find((entry) => entry.title === 'This line changed later.'); + expect(inlineEntry).not.toBeUndefined(); + await act(async () => inlineEntry?.click()); + await waitFor(() => expect(container.querySelector('.file-tree-shell')).not.toBeNull()); + await waitFor(() => + expect( + container.querySelector('.codiff-file-header.selected .codiff-file-path')?.textContent, + ).toBe(file.path), + ); + const targetHeader = [...container.querySelectorAll('.codiff-file-header')].find( + (header) => header.textContent?.includes(file.path), + ); + expect( + targetHeader + ?.querySelector('.codiff-header-toggle') ?.getAttribute('aria-expanded'), ).toBe('true'); - expect(view.container.textContent).toContain('This inline conversation is resolved.'); - }); - window.history.replaceState(null, '', '/'); -}); - -test('share viewer shows the complete repository path when there is no repository link', async () => { - const container = document.createElement('div'); - document.body.append(container); - let root: Root | null = null; - await using _resource = { - async [Symbol.asyncDispose]() { - if (root) { - await act(async () => root?.unmount()); - } - container.remove(); - }, - }; - await act(async () => { - root = createRoot(container); - root.render( - , + await waitFor(() => + expect(document.activeElement?.classList.contains('review-comment-thread')).toBe(true), ); - }); - - await waitFor(() => { - expect(container.querySelector('.review-top-bar-repository')).not.toBeNull(); - }); - expect(container.querySelector('.review-top-bar-repository')?.textContent).toBe( - '~/dev/codiff-web', - ); + expect(scrollIntoView).toHaveBeenCalledWith({ block: 'center' }); + } finally { + scrollIntoView.mockRestore(); + await act(async () => root.unmount()); + container.remove(); + } }); test('shared walkthroughs switch between walkthrough and tree review modes', async () => { @@ -437,7 +472,25 @@ test('shared walkthroughs switch between walkthrough and tree review modes', asy root = createRoot(container); root.render( { expect(container.querySelector('.walkthrough-list')).not.toBeNull(); }); - const searchInput = container.querySelector('.sidebar-search'); - expect(searchInput).not.toBeNull(); + const walkthroughArc = container.querySelector('.wt-arc'); + const walkthroughBody = container.querySelector('.wt-hybrid'); + expect(walkthroughArc).not.toBeNull(); + expect(walkthroughBody).not.toBeNull(); + expect(walkthroughBody?.contains(walkthroughArc)).toBe(false); + expect(walkthroughArc?.parentElement).toBe(walkthroughBody?.parentElement); + expect(container.querySelector('.sidebar-search')).toBeNull(); const deleteShare = container.querySelector( 'button[aria-label="Delete shared walkthrough"]', ); @@ -463,8 +521,6 @@ test('shared walkthroughs switch between walkthrough and tree review modes', asy confirmDelete.mockReturnValue(true); await act(async () => deleteShare?.click()); await waitFor(() => expect(onDeleteShare).toHaveBeenCalledOnce()); - expect(searchInput?.placeholder).toBe('Filter files'); - const setInputValue = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set; const tablist = container.querySelector('[role="tablist"]'); expect(tablist?.classList.contains('review-mode-control')).toBe(true); const topBar = tablist?.closest('.review-top-bar'); @@ -510,6 +566,9 @@ test('shared walkthroughs switch between walkthrough and tree review modes', asy ).toBe(true); expect(tabs[0]?.getAttribute('aria-selected')).toBe('false'); expect(tabs[1]?.getAttribute('aria-selected')).toBe('true'); + const searchInput = container.querySelector('.sidebar-search'); + expect(searchInput?.placeholder).toBe('Filter files'); + const setInputValue = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set; const sidebarToggle = container.querySelector( '.review-top-bar .sidebar-toggle-button', ); @@ -561,6 +620,7 @@ test('shared walkthroughs switch between walkthrough and tree review modes', asy await waitFor(() => { expect(container.querySelector('.walkthrough-list')).not.toBeNull(); expect(container.querySelector('.file-tree-shell')).toBeNull(); + expect(container.querySelector('.sidebar-search')).toBeNull(); }); }); diff --git a/core/__tests__/WalkthroughProgress.test.tsx b/core/__tests__/WalkthroughProgress.test.tsx index 8d09a97d..2458e4b8 100644 --- a/core/__tests__/WalkthroughProgress.test.tsx +++ b/core/__tests__/WalkthroughProgress.test.tsx @@ -88,3 +88,79 @@ test('reserves timer space, reveals 3s without shifting, and resets for each sta expect(timer()?.textContent).toBe('3s'); expect(timer()?.classList.contains('visible')).toBe(true); }); + +test('renders format-neutral task progress and aggregate counts', async () => { + const container = document.createElement('div'); + document.body.append(container); + const root = createRoot(container); + + try { + await act(async () => { + root.render( + , + ); + }); + + expect(container.textContent).toContain('1/2'); + expect(container.textContent).toContain('Preparing the second task.'); + expect(container.textContent).toContain('done'); + expect(container.textContent).toContain('preparing'); + expect(container.textContent).toContain('Second task'); + } finally { + await act(async () => root.unmount()); + container.remove(); + } +}); + +test('keeps failed task details visible after generation stops', async () => { + const container = document.createElement('div'); + document.body.append(container); + const root = createRoot(container); + + try { + await act(async () => { + root.render( + , + ); + }); + + expect(container.textContent).toContain('failed'); + expect(container.textContent).toContain('Model response was invalid.'); + } finally { + await act(async () => root.unmount()); + container.remove(); + } +}); diff --git a/core/__tests__/app-command-hooks.test.tsx b/core/__tests__/app-command-hooks.test.tsx index 49e20ccb..217d335b 100644 --- a/core/__tests__/app-command-hooks.test.tsx +++ b/core/__tests__/app-command-hooks.test.tsx @@ -8,7 +8,6 @@ import { useAppCommands } from '../app/hooks/useAppCommands.ts'; import { useAppKeyboardShortcuts } from '../app/hooks/useAppKeyboardShortcuts.ts'; import { createDefaultConfig, defaultKeymap } from '../config/defaults.ts'; import { createReviewCommandTarget } from '../lib/review-command-target.ts'; -import type { RepositoryState } from '../types.ts'; import { createChangedFile } from './helpers/fixtures.ts'; import { renderReact } from './helpers/react.tsx'; @@ -49,16 +48,6 @@ test('app commands register the complete command set and delegate dynamic action ...createDefaultConfig().settings, }, }; - const stateRef = { - current: { - branch: 'main', - files: [file], - generatedAt: 1, - launchPath: '/repo', - root: '/repo', - source, - } satisfies RepositoryState, - }; const viewedRef = { current: { [target.reviewIdentity.key]: target.reviewIdentity.fingerprint, @@ -67,6 +56,7 @@ test('app commands register the complete command set and delegate dynamic action const changeSidebarMode = vi.fn(); const focusFileFilter = vi.fn(); const getReviewCommandTarget = vi.fn(() => target); + const onCopyPendingComments = vi.fn(() => '# Address these Review Notes'); const onOpenDiffSearch = vi.fn(); const onOpenReviewSource = vi.fn(); const onOpenSelectedFile = vi.fn(); @@ -74,14 +64,21 @@ test('app commands register the complete command set and delegate dynamic action const onToggleSidebar = vi.fn(); const onToggleViewed = vi.fn(); const onToggleWordWrap = vi.fn(); + const writeText = vi.fn(async () => {}); + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { writeText }, + }); let commands: ReturnType = []; await using _view = await renderReact( (commands = nextCommands)} options={{ changeSidebarMode, + copyPendingCommentsLabel: 'Copy Review Notes', focusFileFilter, getReviewCommandTarget, + onCopyPendingComments, onOpenDiffSearch, onOpenReviewSource, onOpenSelectedFile, @@ -90,8 +87,6 @@ test('app commands register the complete command set and delegate dynamic action onToggleViewed, onToggleWordWrap, preferencesRef, - reviewCommentsRef: { current: [] }, - stateRef, viewedRef, }} />, @@ -137,6 +132,7 @@ test('app commands register the complete command set and delegate dynamic action command('open-file').execute(); command('toggle-sidebar').execute(); command('toggle-word-wrap').execute(); + command('copy-comments').execute(); command('reload').execute(); command('toggle-viewed').execute(); expect(focusFileFilter).toHaveBeenCalledOnce(); @@ -147,9 +143,13 @@ test('app commands register the complete command set and delegate dynamic action expect(onToggleSidebar).toHaveBeenCalledOnce(); expect(onToggleWordWrap).toHaveBeenCalledOnce(); expect(onRefreshRepository).toHaveBeenCalledOnce(); + expect(onCopyPendingComments).toHaveBeenCalledOnce(); + expect(writeText).toHaveBeenCalledWith('# Address these Review Notes'); expect(command('open-file').description?.()).toBe(file.path); expect(onToggleViewed).toHaveBeenCalledWith(file, true, target.reviewIdentity); expect(command('toggle-word-wrap').description?.()).toBe('Enable Word Wrap'); + expect(command('copy-comments').title).toBe('Copy Review Notes'); + expect(command('copy-comments-and-close').title).toBe('Copy Review Notes and Close'); preferencesRef.current.wordWrap = true; expect(command('toggle-word-wrap').description?.()).toBe('Disable Word Wrap'); }); diff --git a/core/__tests__/app-review-comment-hooks.test.tsx b/core/__tests__/app-review-comment-hooks.test.tsx index dcca9447..c0909576 100644 --- a/core/__tests__/app-review-comment-hooks.test.tsx +++ b/core/__tests__/app-review-comment-hooks.test.tsx @@ -5,7 +5,7 @@ import { act, useRef } from 'react'; import { afterEach, expect, test, vi } from 'vite-plus/test'; import { useAppReviewComments } from '../app/hooks/useAppReviewComments.ts'; -import type { ReviewComment } from '../lib/app-types.ts'; +import type { LocalReviewNote } from '../lib/app-types.ts'; import type { RepositoryState } from '../types.ts'; import { renderReact, waitFor } from './helpers/react.tsx'; @@ -20,21 +20,11 @@ const workingTreeState = { root: '/repo', source: { type: 'working-tree' }, } satisfies RepositoryState; -const pullRequestState = { - ...workingTreeState, - source: { - number: 42, - owner: 'nkzw-tech', - provider: 'github', - repo: 'codiff', - type: 'pull-request', - url: 'https://github.com/nkzw-tech/codiff/pull/42', - }, -} satisfies RepositoryState; -const comment: ReviewComment = { +const localNote: LocalReviewNote = { body: 'Review this', filePath: 'src/app.ts', id: 'comment-1', + kind: 'local-note', lineNumber: 4, sectionId: 'src/app.ts:pull-request', side: 'additions', @@ -51,7 +41,7 @@ function AppReviewCommentsHarness({ }) { const stateRef = useRef(state); const comments = useAppReviewComments({ - isReviewActionDisabled: () => false, + draftKind: state.source.type === 'pull-request' ? 'provider-draft' : 'local-note', onCommentFileChange, stateRef, }); @@ -82,6 +72,7 @@ const renderAppReviewComments = async (state: RepositoryState) => { }; afterEach(() => { + vi.restoreAllMocks(); window.codiff = originalCodiff; }); @@ -95,18 +86,18 @@ test('app review comments request and store assistant replies', async () => { const { getState, onCommentFileChange } = view; await act(async () => { - getState().setReviewComments([comment]); + getState().setReviewComments([localNote]); }); await act(async () => { - getState().askCodex(comment.id); + getState().askCodex(localNote); }); expect(askReviewAssistant).toHaveBeenCalledWith({ comment: { - body: comment.body, - filePath: comment.filePath, - lineNumber: comment.lineNumber, - sectionId: comment.sectionId, - side: comment.side, + body: localNote.body, + filePath: localNote.filePath, + lineNumber: localNote.lineNumber, + sectionId: localNote.sectionId, + side: localNote.side, }, source: workingTreeState.source, }); @@ -118,86 +109,3 @@ test('app review comments request and store assistant replies', async () => { }); expect(onCommentFileChange).toHaveBeenCalledTimes(2); }); - -test('app review comments submit a draft and replace it with the remote comment', async () => { - const submitPullRequestComment = vi.fn(async () => ({ - author: { - login: 'reviewer', - name: 'Reviewer', - }, - body: comment.body, - filePath: comment.filePath, - id: 'remote-comment', - lineNumber: comment.lineNumber, - side: comment.side, - submittedAt: '2026-07-15T00:00:00.000Z', - url: 'https://github.com/nkzw-tech/codiff/pull/42#discussion_r1', - })); - window.codiff = { submitPullRequestComment } as unknown as Window['codiff']; - await using view = await renderAppReviewComments(pullRequestState); - const { getState, onCommentFileChange } = view; - - await act(async () => { - getState().setReviewComments([comment]); - }); - await act(async () => { - getState().submitPullRequestComment(comment.id); - }); - expect(submitPullRequestComment).toHaveBeenCalledWith({ - comment: { - body: comment.body, - filePath: comment.filePath, - lineNumber: comment.lineNumber, - side: comment.side, - }, - source: pullRequestState.source, - }); - await waitFor(() => { - expect(getState().reviewComments).toEqual([ - { - author: { - login: 'reviewer', - name: 'Reviewer', - }, - body: comment.body, - filePath: comment.filePath, - id: 'remote-comment', - isReadOnly: true, - lineNumber: comment.lineNumber, - sectionId: comment.sectionId, - side: comment.side, - submittedAt: '2026-07-15T00:00:00.000Z', - url: 'https://github.com/nkzw-tech/codiff/pull/42#discussion_r1', - }, - ]); - }); - expect(onCommentFileChange).toHaveBeenCalledTimes(2); -}); - -test('app review comments submit and clear pending review drafts', async () => { - const submitPullRequestReview = vi.fn(async () => {}); - window.codiff = { submitPullRequestReview } as unknown as Window['codiff']; - await using view = await renderAppReviewComments(pullRequestState); - const { getState } = view; - - await act(async () => { - getState().setReviewComments([comment]); - }); - await act(async () => { - await getState().submitPullRequestReview('COMMENT'); - }); - expect(submitPullRequestReview).toHaveBeenCalledWith({ - comments: [ - { - body: comment.body, - filePath: comment.filePath, - lineNumber: comment.lineNumber, - side: comment.side, - }, - ], - event: 'COMMENT', - source: pullRequestState.source, - }); - expect(getState().reviewComments).toEqual([]); - expect(getState().pullRequestReviewSubmitting).toBeNull(); -}); diff --git a/core/__tests__/codiff-share-cli.test.ts b/core/__tests__/codiff-share-cli.test.ts index 39bda88d..5880671c 100644 --- a/core/__tests__/codiff-share-cli.test.ts +++ b/core/__tests__/codiff-share-cli.test.ts @@ -1,5 +1,5 @@ import { execFile } from 'node:child_process'; -import { chmod, mkdir, readFile, realpath, writeFile } from 'node:fs/promises'; +import { chmod, mkdir, readFile, writeFile } from 'node:fs/promises'; import { createServer } from 'node:http'; import { createRequire } from 'node:module'; import { join, resolve } from 'node:path'; @@ -25,9 +25,15 @@ const { readRepositoryState } = require('../../electron/git-state.cjs') as { }; const { getSectionWalkthroughHunks } = require('../lib/narrative-walkthrough-diff.cjs') as { getSectionWalkthroughHunks: ( - file: { path: string }, - section: { id: string; patch: string }, - ) => ReadonlyArray<{ id: string }>; + file: { path: string; status?: string }, + section: { + id: string; + kind?: string; + lineCount?: { additions: number; deletions: number }; + loadState?: string; + patch: string; + }, + ) => ReadonlyArray<{ added: number; deleted: number; id: string; kind?: string }>; }; const execFileAsync = promisify(execFile); @@ -57,6 +63,33 @@ const git = (repositoryPath: string, args: ReadonlyArray) => encoding: 'utf8', }); +test('patchless walkthrough hunks retain provider line counts including exact zero', () => { + expect( + getSectionWalkthroughHunks( + { path: 'src/removed.ts', status: 'modified' }, + { + id: 'src/removed.ts:pull-request', + kind: 'pull-request', + lineCount: { additions: 0, deletions: 3767 }, + loadState: 'deferred', + patch: '', + }, + ), + ).toMatchObject([{ added: 0, deleted: 3767, kind: 'synthetic' }]); + expect( + getSectionWalkthroughHunks( + { path: 'src/zero.ts', status: 'modified' }, + { + id: 'src/zero.ts:pull-request', + kind: 'pull-request', + lineCount: { additions: 0, deletions: 0 }, + loadState: 'ready', + patch: '', + }, + ), + ).toMatchObject([{ added: 0, deleted: 0, kind: 'synthetic' }]); +}); + test('headless share uploads the canonical snapshot and prints its URL', async () => { await using directory = await createTemporaryDirectory('codiff-headless-share-'); const repositoryPath = join(directory.path, 'repo'); @@ -165,7 +198,7 @@ test('headless share uploads the canonical snapshot and prints its URL', async ( expect(body.snapshot).toMatchObject({ kind: 'codiff-walkthrough-share', repository: { - root: await realpath(repositoryPath), + root: '[redacted]', source: { type: 'working-tree' }, }, version: 1, @@ -450,8 +483,8 @@ exit 1 expect(stderr).toBe(''); expect(stdout).toBe(`${origin}/w/generated-walkthrough\n`); expect(body.snapshot.repository).toMatchObject({ - root: await realpath(repositoryPath), - source: { ref: head, type: 'commit' }, + root: '[redacted]', + source: { sha: head, type: 'commit' }, title: 'Update example', }); expect(body.snapshot.walkthrough).toMatchObject({ diff --git a/core/__tests__/git-state.test.ts b/core/__tests__/git-state.test.ts index ab7bcdb2..8c721a04 100644 --- a/core/__tests__/git-state.test.ts +++ b/core/__tests__/git-state.test.ts @@ -6,10 +6,14 @@ import { dirname, join } from 'node:path'; import { promisify } from 'node:util'; import { expect, test } from 'vite-plus/test'; import { fileHasVisibleDiff, getDiffLineCount } from '../lib/diff.ts'; +import type { ArtifactFile } from '../lib/review-artifacts.ts'; import type { DiffSection, - DiffSectionContentRequest, + GitSha, RepositoryState, + Revision, + RevisionContentBatchRequest, + RevisionContentBatchResult, ReviewSource, } from '../types.ts'; import { getGitTestEnvironmentForSubprocess, withGitTestEnvironment } from './helpers/git.ts'; @@ -34,10 +38,10 @@ type PullRequestFileContent = { }; type GeneratedFilesModule = { - readGeneratedAttributeStates: ( + readRevisionGeneratedAttributeStates: ( repoRoot: string, paths: ReadonlyArray, - source?: string, + revision: Revision, ) => Promise>; }; @@ -54,37 +58,37 @@ type GitStateModule = { ) => ReadonlyArray; createPullRequestSection: ( pullRequest: { number: number; owner: string; repo: string; url: string }, - file: { filename: string; patch?: string; previous_filename?: string; status: string }, - patch: string, + file: ArtifactFile, oldFile?: PullRequestFileContent, newFile?: PullRequestFileContent, + rangeRefs?: { + base?: string; + contentAttempted?: boolean; + deferContents?: boolean; + head?: string; + }, ) => DiffSection; createPullRequestSource: ( pullRequest: { number: number; owner: string; repo: string; url: string }, metadata: { + base?: { ref?: string }; body?: string | null; head?: { sha?: string }; title?: string; user?: { avatar_url?: string; html_url?: string; login?: string }; }, ) => Extract; - getPullRequestHeadImageSource: ( - pullRequest: { number: number; owner: string; repo: string; url: string }, - metadata: { - head?: { - ref?: string; - repo?: { full_name?: string; name?: string; owner?: { login?: string } } | null; - sha?: string; - }; - }, - ) => { owner: string; ref: string; repo: string }; listRepositoryHistory: ( launchPath: string, limit?: number, source?: ReviewSource, ) => Promise<{ entries: ReadonlyArray; root: string }>; normalizeGitHubPullRequestCommit: (commit: Record) => unknown; - normalizeGitHubReviewComment: (comment: Record) => unknown; + normalizeGitHubReviewComment: ( + comment: Record, + rootComment?: Record, + baseSha?: string, + ) => unknown; normalizePullRequestComment: (comment: Record) => Record; parseGitHubPullRequestUrl: (value: string) => { number: number; @@ -94,18 +98,15 @@ type GitStateModule = { }; parseStatus: (raw: string) => Array; PENDING_REVIEW_COMMENT_ERROR: string; - readDiffSectionContent: ( - launchPath: string, - request: DiffSectionContentRequest, - ) => Promise; - readRepositoryChangeSignature: ( - launchPath: string, - ) => Promise<{ root: string; signature: string }>; readRepositoryState: ( launchPath: string, source?: ReviewSource, - options?: { showWhitespace?: boolean }, + options?: { repositoryRoot?: string; showWhitespace?: boolean }, ) => Promise; + readRevisionContent: ( + launchPath: string, + request: RevisionContentBatchRequest, + ) => Promise; readWalkthroughRepositoryState: ( launchPath: string, source?: ReviewSource, @@ -113,16 +114,12 @@ type GitStateModule = { ) => Promise; readWorkingTreeState: ( launchPath: string, - options?: { eagerContents?: boolean; showWhitespace?: boolean }, + options?: { eagerContents?: boolean; repositoryRoot?: string; showWhitespace?: boolean }, ) => Promise; - resolvePullRequestContentRefs: ( - repoRoot: string, - pullRequest: { number: number; owner: string; repo: string; url: string }, - metadata: { base?: { ref?: string; sha?: string }; head?: { ref?: string; sha?: string } }, - ) => Promise<{ base: string; head: string } | null>; selectUnresolvedReviewComments: ( comments: ReadonlyArray>, resolvedCommentIds: ReadonlySet, + baseSha?: string, ) => Array>; submitPullRequestComment: ( launchPath: string, @@ -131,6 +128,12 @@ type GitStateModule = { body: string; filePath: string; lineNumber: number; + position: { + range: { + base: { label: { kind: 'commit'; text: string }; sha: GitSha }; + head: { label: { kind: 'commit'; text: string }; sha: GitSha }; + }; + }; side: 'additions' | 'deletions'; }; source: Extract; @@ -140,15 +143,31 @@ type GitStateModule = { }; const execFileAsync = promisify(execFile); +const gitSha = (value: string) => value as GitSha; +const readRevisionText = async ( + repo: string, + source: RepositoryState['source'], + path: string, + revision: Revision, +) => { + const key = `${path}:${revision.kind ?? 'commit'}`; + const result = await readRevisionContent(repo, { + generation: 'test', + requests: [{ key, maxBytes: 2 * 1024 * 1024, path, revision }], + source, + }); + const item = result.results[0]; + expect(item?.status).toBe('ready'); + return item?.status === 'ready' ? new TextDecoder().decode(item.value.bytes) : null; +}; const require = createRequire(import.meta.url); -const { readGeneratedAttributeStates } = +const { readRevisionGeneratedAttributeStates } = require('../../electron/generated-files.cjs') as GeneratedFilesModule; const { collectResolvedReviewCommentIds, createPullRequestHistoryFetchRefspecs, createPullRequestSection, createPullRequestSource, - getPullRequestHeadImageSource, listRepositoryHistory, normalizeGitHubPullRequestCommit, normalizeGitHubReviewComment, @@ -156,16 +175,30 @@ const { parseGitHubPullRequestUrl, parseStatus, PENDING_REVIEW_COMMENT_ERROR, - readDiffSectionContent, - readRepositoryChangeSignature, readRepositoryState, + readRevisionContent, readWalkthroughRepositoryState, readWorkingTreeState, - resolvePullRequestContentRefs, selectUnresolvedReviewComments, submitPullRequestComment, validateRepositoryPath, } = require('../../electron/git-state.cjs') as GitStateModule; +const { selectGitHubGeneralCommentThreads } = + require('../../electron/git-state/pull-request.cjs') as { + selectGitHubGeneralCommentThreads: ( + issueComments: ReadonlyArray>, + reviews: ReadonlyArray>, + ) => Array>; + }; +const { + getRepositoryWatcherInitialSnapshot, + readRepositoryWatcherSnapshot: readRepositoryChangeSignature, +} = require('../../electron/repository-watcher.cjs') as { + getRepositoryWatcherInitialSnapshot: ( + state: object, + ) => Promise<{ pathSignatures: Record; root: string }> | undefined; + readRepositoryWatcherSnapshot: (repoRoot: string) => Promise<{ root: string; signature: string }>; +}; const git = async (repo: string, args: ReadonlyArray) => { const { stdout } = await execFileAsync('git', ['-C', repo, ...args], { @@ -209,7 +242,7 @@ process.stdin.setEncoding('utf8'); process.stdin.on('data', (chunk) => { input += chunk; }); process.stdin.on('end', () => { appendFileSync(process.env.CODIFF_GITHUB_TEST_CALLS, JSON.stringify({ args, input }) + '\\n'); - const endpoint = args.find((argument) => argument.startsWith('repos/')) || ''; + const endpoint = args.find((argument) => argument.startsWith('/repos/')) || ''; if (endpoint.endsWith('/comments')) { if (process.env.CODIFF_GITHUB_TEST_MODE === 'success') { process.stdout.write(JSON.stringify({ @@ -228,6 +261,17 @@ process.stdin.on('end', () => { process.exitCode = 1; return; } + if (endpoint.includes('/compare/')) { + process.stdout.write(JSON.stringify({ merge_base_commit: { sha: 'base-sha' } })); + return; + } + if (endpoint.endsWith('/files?per_page=100')) { + process.stdout.write(JSON.stringify([{ + filename: 'src/app.ts', + patch: '@@ -1 +1 @@\\n-old\\n+new\\n', + }])); + return; + } if (endpoint.includes('/reviews?')) { if (process.env.CODIFF_GITHUB_TEST_MODE === 'diagnosis-fails') { process.stderr.write('gh: review lookup failed\\n'); @@ -236,12 +280,15 @@ process.stdin.on('end', () => { } process.stdout.write( process.env.CODIFF_GITHUB_TEST_MODE === 'pending-review' - ? '[[{"id": 7, "state": "PENDING"}]]' - : '[[]]', + ? '[{"id": 7, "state": "PENDING"}]' + : '[]', ); return; } - process.stdout.write(JSON.stringify({ head: { sha: 'head-sha' } })); + process.stdout.write(JSON.stringify({ + base: { sha: 'base-sha' }, + head: { sha: 'head-sha' }, + })); }); `, ); @@ -266,9 +313,16 @@ const pullRequestCommentRequest = { body: 'Keep this comment.', filePath: 'src/app.ts', lineNumber: 1, + position: { + range: { + base: { label: { kind: 'commit' as const, text: 'base' }, sha: gitSha('base-sha') }, + head: { label: { kind: 'commit' as const, text: 'head' }, sha: gitSha('head-sha') }, + }, + }, side: 'additions' as const, }, source: { + headSha: gitSha('head-sha'), provider: 'github' as const, type: 'pull-request' as const, url: 'https://github.com/octo/example/pull/118', @@ -361,10 +415,13 @@ exec git "$@" CODIFF_TEST_ORIGINAL_PATH: process.env.PATH ?? '', PATH: `${wrapperDirectory.path}:${process.env.PATH ?? ''}`, }); - const generatedStates = await readGeneratedAttributeStates( + const generatedStates = await readRevisionGeneratedAttributeStates( repo, ['client.api.ts', 'pnpm-lock.yaml'], - commit, + { + label: { kind: 'commit', text: commit.slice(0, 7) }, + sha: gitSha(commit), + }, ); expect(generatedStates).toEqual( new Map([ @@ -375,6 +432,72 @@ exec git "$@" }); }); +test('generated attributes dispatch index and working-copy revisions independently', async () => { + await withRepo(async (repo) => { + await writeRepoFile(repo, '.gitattributes', '*.api.ts -linguist-generated\n'); + await writeRepoFile(repo, 'client.api.ts', 'export const client = 1;\n'); + await commitAll(repo, 'authored client'); + + await writeRepoFile(repo, '.gitattributes', '*.api.ts linguist-generated\n'); + await git(repo, ['add', '.gitattributes']); + await writeRepoFile(repo, '.gitattributes', '*.api.ts -linguist-generated\n'); + + const [index, workingCopy] = await Promise.all([ + readRevisionGeneratedAttributeStates(repo, ['client.api.ts'], { + kind: 'index', + label: { kind: 'review-marker', text: 'Index' }, + }), + readRevisionGeneratedAttributeStates(repo, ['client.api.ts'], { + kind: 'working-copy', + label: { kind: 'review-marker', text: 'Working copy' }, + }), + ]); + + expect(index.get('client.api.ts')).toBe(true); + expect(workingCopy.get('client.api.ts')).toBe(false); + }); +}); + +test('working-tree ranges represent unborn staged additions with an absent base', async () => { + await withRepo(async (repo) => { + await writeRepoFile(repo, 'first.txt', 'first\n'); + await git(repo, ['add', 'first.txt']); + + const state = await readWorkingTreeState(repo); + const file = state.files.find((candidate) => candidate.path === 'first.txt'); + const section = file?.sections.find((candidate) => candidate.kind === 'staged'); + + expect(section?.range).toMatchObject({ base: null, head: { kind: 'index' } }); + expect(await readRevisionText(repo, state.source, 'first.txt', section!.range!.head!)).toBe( + 'first\n', + ); + }); +}); + +test('working-tree ranges preserve the selected conflict index stage', async () => { + await withRepo(async (repo) => { + await writeRepoFile(repo, 'conflict.txt', 'base\n'); + await commitAll(repo, 'base'); + const main = (await git(repo, ['branch', '--show-current'])).trim(); + await git(repo, ['checkout', '-b', 'other']); + await writeRepoFile(repo, 'conflict.txt', 'other\n'); + await commitAll(repo, 'other'); + await git(repo, ['checkout', main]); + await writeRepoFile(repo, 'conflict.txt', 'ours\n'); + await commitAll(repo, 'ours'); + await git(repo, ['merge', 'other']).catch(() => ''); + + const state = await readWorkingTreeState(repo); + const file = state.files.find((candidate) => candidate.path === 'conflict.txt'); + const section = file?.sections[0]; + + expect(section?.range?.base).toMatchObject({ kind: 'index', stage: 2 }); + expect(await readRevisionText(repo, state.source, 'conflict.txt', section!.range!.base!)).toBe( + 'ours\n', + ); + }); +}); + test('parseStatus reads staged rename paths in porcelain v1 -z order', () => { expect(parseStatus('R new.txt\0old.txt\0')).toEqual([ { @@ -468,7 +591,7 @@ test('readWorkingTreeState compares delete-by-us conflicts against an empty file expect(patchOnlyState.files[0].sections[0].oldFile?.contents).toBe(''); expect(patchOnlyState.files[0].sections[0].newFile?.contents).toBe('theirs\n'); }); -}); +}, 15_000); test('readWorkingTreeState omits unmerged diagnostics when our conflict version is unchanged', async () => { await withRepo(async (repo) => { @@ -583,9 +706,16 @@ const pullRequestFixture = { url: 'https://github.com/nkzw-tech/codiff/pull/7', }; +const pullRequestArtifactFile = ( + path: string, + status: ArtifactFile['status'], + patch: string, +): ArtifactFile => ({ coverage: 'complete', patch, path, status }); + test('createPullRequestSource normalizes non-empty GitHub PR descriptions', () => { expect( createPullRequestSource(pullRequestFixture, { + base: { ref: 'main' }, body: '\n## Intent\n\nShip the focused fix.\n', head: { sha: 'head-sha' }, title: 'Focused fix', @@ -603,6 +733,7 @@ test('createPullRequestSource normalizes non-empty GitHub PR descriptions', () = }, description: '## Intent\n\nShip the focused fix.', provider: 'github', + targetBranch: 'main', title: 'Focused fix', type: 'pull-request', }); @@ -617,10 +748,14 @@ test('createPullRequestSource omits blank GitHub PR descriptions', () => { test('createPullRequestSection renders full contents so pull request diffs can expand context', () => { const section = createPullRequestSection( pullRequestFixture, - { filename: 'src/app.ts', status: 'modified' }, - 'diff --git a/src/app.ts b/src/app.ts\n@@ -1 +1 @@\n-old\n+new\n', + pullRequestArtifactFile( + 'src/app.ts', + 'modified', + 'diff --git a/src/app.ts b/src/app.ts\n@@ -1 +1 @@\n-old\n+new\n', + ), { binary: false, file: { cacheKey: 'base:src/app.ts', contents: 'old\n', name: 'src/app.ts' } }, { binary: false, file: { cacheKey: 'head:src/app.ts', contents: 'new\n', name: 'src/app.ts' } }, + { base: 'a'.repeat(40), head: 'b'.repeat(40) }, ); expect(section.id).toBe('src/app.ts:pull-request:7'); @@ -633,18 +768,61 @@ test('createPullRequestSection renders full contents so pull request diffs can e test('createPullRequestSection falls back to a non-loadable patch section without contents', () => { const section = createPullRequestSection( pullRequestFixture, - { filename: 'src/app.ts', status: 'modified' }, - 'diff --git a/src/app.ts b/src/app.ts\n@@ -1 +1 @@\n-old\n+new\n', + pullRequestArtifactFile( + 'src/app.ts', + 'modified', + 'diff --git a/src/app.ts b/src/app.ts\n@@ -1 +1 @@\n-old\n+new\n', + ), ); expect(section.oldFile).toBeUndefined(); expect(section.newFile).toBeUndefined(); expect(section.loadState).toBe('ready'); - // Pull request contents load up front, so a patch-only fallback is not - // loadable on demand (there is no pull-request section-content loader). expect(section.summary?.canLoad).toBe(false); }); +test('createPullRequestSection keeps provider patches visible while exact contents are deferred', () => { + const patch = 'diff --git a/src/app.ts b/src/app.ts\n@@ -1 +1 @@\n-old\n+new\n'; + const section = createPullRequestSection( + pullRequestFixture, + pullRequestArtifactFile('src/app.ts', 'modified', patch), + undefined, + undefined, + { base: 'a'.repeat(40), deferContents: true, head: 'b'.repeat(40) }, + ); + + expect(section).toMatchObject({ + binary: false, + loadState: 'deferred', + patch, + summary: { canLoad: true }, + }); +}); + +test('createPullRequestSection makes failed provider hydration terminal', () => { + const section = createPullRequestSection( + pullRequestFixture, + { + ...pullRequestArtifactFile('src/app.ts', 'modified', ''), + newObjectId: 'b'.repeat(40), + oldObjectId: 'a'.repeat(40), + }, + undefined, + undefined, + { + base: 'a'.repeat(40), + contentAttempted: true, + head: 'b'.repeat(40), + }, + ); + + expect(section).toMatchObject({ + loadState: 'error', + patch: '', + summary: { canLoad: false }, + }); +}); + test('createPullRequestSection treats the binary marker as a diff line, not patch content', () => { // The patch's content adds a line that contains the binary-marker text. It // must not be mistaken for an actual binary diff. @@ -652,8 +830,7 @@ test('createPullRequestSection treats the binary marker as a diff line, not patc 'diff --git a/re.ts b/re.ts\n@@ -1 +1 @@\n-const a = 1;\n+const re = /Binary files .* differ/;\n'; const section = createPullRequestSection( pullRequestFixture, - { filename: 're.ts', status: 'modified' }, - patch, + pullRequestArtifactFile('re.ts', 'modified', patch), { binary: false, file: { cacheKey: 'old', contents: 'const a = 1;\n', name: 're.ts' } }, { binary: false, @@ -670,8 +847,11 @@ test('createPullRequestSection treats the binary marker as a diff line, not patc test('createPullRequestSection keeps full contents for added files', () => { const section = createPullRequestSection( pullRequestFixture, - { filename: 'new.ts', status: 'added' }, - 'diff --git a/new.ts b/new.ts\n--- /dev/null\n+++ b/new.ts\n@@ -0,0 +1 @@\n+hello\n', + pullRequestArtifactFile( + 'new.ts', + 'added', + 'diff --git a/new.ts b/new.ts\n--- /dev/null\n+++ b/new.ts\n@@ -0,0 +1 @@\n+hello\n', + ), { binary: false, file: { cacheKey: 'base:new.ts:empty', contents: '', name: 'new.ts' } }, { binary: false, file: { cacheKey: 'head:new.ts', contents: 'hello\n', name: 'new.ts' } }, ); @@ -684,8 +864,11 @@ test('createPullRequestSection keeps full contents for added files', () => { test('createPullRequestSection falls back to the patch for binary files', () => { const section = createPullRequestSection( pullRequestFixture, - { filename: 'logo.png', status: 'modified' }, - 'diff --git a/logo.png b/logo.png\nBinary files a/logo.png and b/logo.png differ\n', + pullRequestArtifactFile( + 'logo.png', + 'modified', + 'diff --git a/logo.png b/logo.png\nBinary files a/logo.png and b/logo.png differ\n', + ), ); expect(section.binary).toBe(true); @@ -698,8 +881,7 @@ test('createPullRequestSection falls back to the patch when modified contents fa const patch = 'diff --git a/src/app.ts b/src/app.ts\n@@ -1 +1 @@\n-old\n+new\n'; const section = createPullRequestSection( pullRequestFixture, - { filename: 'src/app.ts', status: 'modified' }, - patch, + pullRequestArtifactFile('src/app.ts', 'modified', patch), { binary: false, file: { cacheKey: 'base:empty', contents: '', name: 'src/app.ts' } }, { binary: false, file: { cacheKey: 'head:empty', contents: '', name: 'src/app.ts' } }, ); @@ -713,115 +895,6 @@ test('createPullRequestSection falls back to the patch when modified contents fa expect(section.summary?.canLoad).toBe(false); }); -test('resolvePullRequestContentRefs resolves the diff against the merge base', async () => { - await withRepo(async (repo) => { - await writeRepoFile(repo, 'file.txt', 'base\n'); - await commitAll(repo, 'base'); - const mergeBase = (await git(repo, ['rev-parse', 'HEAD'])).trim(); - - // The pull request head branches off the merge base. - await writeRepoFile(repo, 'file.txt', 'head change\n'); - await commitAll(repo, 'head'); - const headSha = (await git(repo, ['rev-parse', 'HEAD'])).trim(); - await git(repo, ['update-ref', 'refs/codiff/pull-requests/7/head', headSha]); - - // The base branch advances past the merge base after the PR was opened. - await git(repo, ['checkout', '-q', mergeBase]); - await writeRepoFile(repo, 'file.txt', 'base advanced\n'); - await commitAll(repo, 'base advanced'); - const baseTip = (await git(repo, ['rev-parse', 'HEAD'])).trim(); - await git(repo, ['update-ref', 'refs/codiff/pull-requests/7/base', baseTip]); - - const refs = await resolvePullRequestContentRefs(repo, pullRequestFixture, { - base: { ref: 'main' }, - head: { sha: headSha }, - }); - - expect(refs).toEqual({ base: mergeBase, head: 'refs/codiff/pull-requests/7/head' }); - // The base tip is intentionally not used; only the PR's own changes show. - expect(refs?.base).not.toBe(baseTip); - }); -}); - -test('resolvePullRequestContentRefs refetches when the fetched base no longer matches the base sha', async () => { - await withRepo(async (repo) => { - await writeRepoFile(repo, 'file.txt', 'base\n'); - await commitAll(repo, 'base'); - const mergeBase = (await git(repo, ['rev-parse', 'HEAD'])).trim(); - - await writeRepoFile(repo, 'file.txt', 'head change\n'); - await commitAll(repo, 'head'); - const headSha = (await git(repo, ['rev-parse', 'HEAD'])).trim(); - await git(repo, ['update-ref', 'refs/codiff/pull-requests/7/head', headSha]); - await git(repo, ['checkout', '-q', mergeBase]); - await writeRepoFile(repo, 'file.txt', 'base advanced\n'); - await commitAll(repo, 'base advanced'); - const baseTip = (await git(repo, ['rev-parse', 'HEAD'])).trim(); - await git(repo, ['update-ref', 'refs/codiff/pull-requests/7/base', baseTip]); - - // GitHub reports a base sha that no longer matches the fetched base ref (the - // base branch moved or the pull request was retargeted). Resolution must - // refetch rather than diff against the stale local base; with no remote - // configured here the refetch fails and resolution reports it cannot resolve. - const refs = await resolvePullRequestContentRefs(repo, pullRequestFixture, { - base: { ref: 'main', sha: '0'.repeat(40) }, - head: { sha: headSha }, - }); - - expect(refs).toBeNull(); - }); -}); - -test('getPullRequestHeadImageSource uses fork repository metadata', () => { - expect( - getPullRequestHeadImageSource( - { - number: 25, - owner: 'base-owner', - repo: 'base-repo', - url: 'https://github.com/base-owner/base-repo/pull/25', - }, - { - head: { - repo: { - name: 'fork-repo', - owner: { - login: 'fork-owner', - }, - }, - sha: 'fork-head-sha', - }, - }, - ), - ).toEqual({ - owner: 'fork-owner', - ref: 'fork-head-sha', - repo: 'fork-repo', - }); -}); - -test('getPullRequestHeadImageSource falls back to the base repository PR head ref', () => { - expect( - getPullRequestHeadImageSource( - { - number: 25, - owner: 'base-owner', - repo: 'base-repo', - url: 'https://github.com/base-owner/base-repo/pull/25', - }, - { - head: { - sha: 'fork-head-sha', - }, - }, - ), - ).toEqual({ - owner: 'base-owner', - ref: 'refs/pull/25/head', - repo: 'base-repo', - }); -}); - test('normalizeGitHubReviewComment preserves multi-line ranges', () => { expect( normalizeGitHubReviewComment({ @@ -844,9 +917,179 @@ test('normalizeGitHubReviewComment preserves multi-line ranges', () => { lineNumber: 8, side: 'additions', startLineNumber: 5, + threadId: '1', + }); +}); + +test('normalizeGitHubReviewComment keeps replies in their root thread', () => { + expect( + normalizeGitHubReviewComment({ + body: 'Reply.', + created_at: '2026-05-19T00:00:00Z', + html_url: 'https://github.com/nkzw-tech/codiff/pull/1#discussion_r2', + id: 2, + in_reply_to_id: 1, + line: 8, + path: 'src/file.ts', + side: 'RIGHT', + user: { login: 'reviewer' }, + }), + ).toMatchObject({ id: 'github:2', threadId: '1' }); +}); + +test('selectUnresolvedReviewComments makes replies inherit the root anchor and immutable range', () => { + const baseSha = 'a'.repeat(40); + const rootCommitSha = 'b'.repeat(40); + const comments = [ + { + body: 'Root.', + commit_id: rootCommitSha, + id: 1, + line: 8, + path: 'src/new-name.ts', + side: 'LEFT', + start_line: 5, + start_side: 'LEFT', + user: { login: 'root-reviewer' }, + }, + { + body: 'Reply with misleading coordinates.', + commit_id: 'c'.repeat(40), + id: 2, + in_reply_to_id: 1, + line: 99, + path: 'src/reply-name.ts', + side: 'RIGHT', + user: { login: 'reply-reviewer' }, + }, + ]; + + expect(selectUnresolvedReviewComments(comments, new Set(), baseSha)).toEqual([ + expect.objectContaining({ + filePath: 'src/new-name.ts', + lineNumber: 8, + position: { + range: { + base: expect.objectContaining({ sha: baseSha }), + head: expect.objectContaining({ sha: rootCommitSha }), + }, + }, + side: 'deletions', + startLineNumber: 5, + threadId: '1', + }), + expect.objectContaining({ + body: 'Reply with misleading coordinates.', + filePath: 'src/new-name.ts', + lineNumber: 8, + side: 'deletions', + startLineNumber: 5, + threadId: '1', + }), + ]); +}); + +test('normalizeGitHubReviewComment uses original commits and coordinates for outdated roots', () => { + const baseSha = 'a'.repeat(40); + const originalCommitSha = 'b'.repeat(40); + const normalized = normalizeGitHubReviewComment( + { + body: 'This belongs to the old side.', + commit_id: 'c'.repeat(40), + id: 7, + line: null, + original_commit_id: originalCommitSha, + original_line: 12, + original_start_line: 10, + path: 'src/renamed.ts', + side: 'LEFT', + start_side: 'LEFT', + user: { login: 'reviewer' }, + }, + undefined, + baseSha, + ); + + expect(normalized).toMatchObject({ + filePath: 'src/renamed.ts', + isOutdated: true, + lineNumber: 12, + position: { + range: { + base: { sha: baseSha }, + head: { sha: originalCommitSha }, + }, + }, + side: 'deletions', + startLineNumber: 10, }); }); +test('normalizeGitHubReviewComment anchors file comments to the review commit', () => { + const baseSha = 'a'.repeat(40); + const headSha = 'b'.repeat(40); + expect( + normalizeGitHubReviewComment( + { + body: 'Review the whole file.', + commit_id: headSha, + id: 41, + path: 'src/file.ts', + subject_type: 'file', + user: { login: 'reviewer' }, + }, + undefined, + baseSha, + ), + ).toMatchObject({ + anchor: 'file', + position: { + range: { + base: { sha: baseSha }, + head: { sha: headSha }, + }, + }, + }); +}); + +test('selectGitHubGeneralCommentThreads includes issue comments and submitted review bodies', () => { + expect( + selectGitHubGeneralCommentThreads( + [ + { + body: 'Issue-level context.', + created_at: '2026-05-18T00:00:00Z', + html_url: 'https://github.test/issue-comment', + id: 1, + user: { login: 'issue-author' }, + }, + ], + [ + { + body: 'Submitted review summary.', + html_url: 'https://github.test/review', + id: 2, + state: 'COMMENTED', + submitted_at: '2026-05-19T00:00:00Z', + user: { login: 'review-author' }, + }, + { body: 'Draft review.', id: 3, state: 'PENDING', user: { login: 'draft-author' } }, + ], + ), + ).toEqual([ + { + comments: [expect.objectContaining({ body: 'Issue-level context.', id: 'github:issue:1' })], + id: 'github:issue:1', + }, + { + comments: [ + expect.objectContaining({ body: 'Submitted review summary.', id: 'github:review:2' }), + ], + id: 'github:review:2', + }, + ]); +}); + test('normalizeGitHubReviewComment flags comments anchored to outdated lines', () => { expect( normalizeGitHubReviewComment({ @@ -979,9 +1222,9 @@ test('normalizeGitHubPullRequestCommit reads GitHub PR commit metadata', () => { author: 'PR Author', committedAt: Date.parse('2026-05-22T12:34:56Z'), gravatarUrl: 'https://avatars.githubusercontent.com/u/1?v=4', - parents: ['parent-sha'], - ref: 'commit-sha', + parentShas: ['parent-sha'], scope: 'pull-request', + sha: 'commit-sha', subject: 'Feature commit', }); }); @@ -1006,6 +1249,40 @@ test('normalizePullRequestComment uses the start side for ranged comments', () = }); }); +test('normalizes GitHub file comment payloads without local draft metadata', () => { + expect( + normalizePullRequestComment({ + anchor: 'file', + body: 'Consider the whole file.', + filePath: 'src/file.ts', + localDraftId: 'draft-1', + }), + ).toEqual({ + body: 'Consider the whole file.', + path: 'src/file.ts', + subject_type: 'file', + }); +}); + +test('normalizes submitted GitHub file comments', () => { + expect( + normalizeGitHubReviewComment({ + body: 'Consider the whole file.', + created_at: '2026-08-04T00:00:00Z', + html_url: 'https://github.com/nkzw-tech/codiff/pull/1#discussion_r41', + id: 41, + path: 'src/file.ts', + subject_type: 'file', + user: { login: 'reviewer' }, + }), + ).toMatchObject({ + anchor: 'file', + body: 'Consider the whole file.', + filePath: 'src/file.ts', + id: 'github:41', + }); +}); + test('pull request comments explain when a pending GitHub review blocks submission', async () => { await withFakeGitHub('pending-review', async (repo, readCalls) => { await expect(submitPullRequestComment(repo, pullRequestCommentRequest)).rejects.toThrow( @@ -1068,6 +1345,7 @@ test('readWorkingTreeState separates staged and unstaged modifications', async ( await writeRepoFile(repo, 'file.txt', 'two\n'); await git(repo, ['add', 'file.txt']); await writeRepoFile(repo, 'file.txt', 'three\n'); + const head = (await git(repo, ['rev-parse', 'HEAD'])).trim(); const state = await readWorkingTreeState(repo); @@ -1080,9 +1358,64 @@ test('readWorkingTreeState separates staged and unstaged modifications', async ( expect(state.files[0].sections[0].newFile?.contents).toBe('two\n'); expect(state.files[0].sections[1].oldFile?.contents).toBe('two\n'); expect(state.files[0].sections[1].newFile?.contents).toBe('three\n'); + expect(state.files[0].sections[0].range).toMatchObject({ + base: { sha: head }, + head: { kind: 'index' }, + }); + expect(state.files[0].sections[1].range).toMatchObject({ + base: { kind: 'index' }, + head: { kind: 'working-copy' }, + }); }); }); +test.sequential('readWorkingTreeState resolves one HEAD for every staged section', async () => { + await withRepo(async (repo) => { + await writeRepoFile(repo, 'alpha.txt', 'alpha initial\n'); + await writeRepoFile(repo, 'beta.txt', 'beta initial\n'); + await commitAll(repo, 'initial commit'); + const head = (await git(repo, ['rev-parse', 'HEAD'])).trim(); + + await writeRepoFile(repo, 'alpha.txt', 'alpha staged\n'); + await writeRepoFile(repo, 'beta.txt', 'beta staged\n'); + await git(repo, ['add', 'alpha.txt', 'beta.txt']); + await writeRepoFile(repo, 'alpha.txt', 'alpha unstaged\n'); + await writeRepoFile(repo, 'beta.txt', 'beta unstaged\n'); + + const tracePath = join(repo, '.git', 'working-tree-head-trace.jsonl'); + let state: RepositoryState; + { + using _environment = createTemporaryEnvironment({ GIT_TRACE2_EVENT: tracePath }); + state = await readWorkingTreeState(repo); + } + + const headResolutionCommands = readFileSync(tracePath, 'utf8') + .trim() + .split('\n') + .filter(Boolean) + .map((line) => JSON.parse(line) as { argv?: ReadonlyArray; event?: string }) + .flatMap(({ argv, event }) => { + if (event !== 'start' || !argv || argv.at(-1) !== 'HEAD^{commit}') { + return []; + } + const revParseIndex = argv.indexOf('rev-parse'); + return revParseIndex >= 0 ? [argv.slice(revParseIndex)] : []; + }); + expect(headResolutionCommands).toEqual([['rev-parse', '--verify', '--quiet', 'HEAD^{commit}']]); + + const stagedSections = state.files.flatMap((file) => + file.sections.filter((section) => section.kind === 'staged'), + ); + expect(stagedSections).toHaveLength(2); + expect( + stagedSections.map((section) => { + const base = section.range?.base; + return base && 'sha' in base ? base.sha : undefined; + }), + ).toEqual([head, head]); + }); +}, 15_000); + test('readRepositoryState uses hidden-whitespace patches for patch-only working tree files', async () => { await withRepo(async (repo) => { await writeRepoFile(repo, 'src/code.ts', 'const value = 1;\n'); @@ -1139,33 +1472,59 @@ test('readRepositoryState and history handle fresh repositories', async () => { }); }); -test('readWalkthroughRepositoryState falls back to HEAD only for a clean implicit source', () => - withRepo(async (repo) => { - await writeRepoFile(repo, 'example.txt', 'before\n'); - await commitAll(repo, 'initial commit'); - await writeRepoFile(repo, 'example.txt', 'after\n'); - await commitAll(repo, 'update example'); - - const cleanState = await readWalkthroughRepositoryState(repo); - expect(cleanState.source).toMatchObject({ type: 'commit' }); - expect(cleanState.commitMetadata?.subject).toBe('update example'); +test('readRepositoryState reuses an already resolved startup root', async () => { + await withRepo(async (repo) => { + await writeRepoFile(repo, 'notes/todo.txt', 'write tests\n'); - const explicitWorkingTreeState = await readWalkthroughRepositoryState(repo, { - type: 'working-tree', + const state = await readRepositoryState(join(repo, 'missing'), undefined, { + repositoryRoot: repo, }); - expect(explicitWorkingTreeState.source).toEqual({ type: 'working-tree' }); - expect(explicitWorkingTreeState.files).toEqual([]); - await writeRepoFile(repo, 'example.txt', 'local\n'); - const dirtyState = await readWalkthroughRepositoryState(repo); - expect(dirtyState.source).toEqual({ type: 'working-tree' }); - })); + expect(state.root).toBe(repo); + expect(state.files.map((file) => file.path)).toEqual(['notes/todo.txt']); + }); +}); + +test( + 'readWalkthroughRepositoryState falls back to HEAD only for a clean implicit source', + () => + withRepo(async (repo) => { + await writeRepoFile(repo, 'example.txt', 'before\n'); + await commitAll(repo, 'initial commit'); + await writeRepoFile(repo, 'example.txt', 'after\n'); + await commitAll(repo, 'update example'); + + const cleanState = await readWalkthroughRepositoryState(repo); + expect(cleanState.source).toMatchObject({ type: 'commit' }); + expect(cleanState.commitMetadata?.subject).toBe('update example'); + + const explicitWorkingTreeState = await readWalkthroughRepositoryState(repo, { + type: 'working-tree', + }); + expect(explicitWorkingTreeState.source).toEqual({ type: 'working-tree' }); + expect(explicitWorkingTreeState.files).toEqual([]); + + await writeRepoFile(repo, 'example.txt', 'local\n'); + const dirtyState = await readWalkthroughRepositoryState(repo); + expect(dirtyState.source).toEqual({ type: 'working-tree' }); + }), + 15_000, +); test('readWalkthroughRepositoryState keeps a fresh repository on the working tree', () => withRepo(async (repo) => { const state = await readWalkthroughRepositoryState(repo); + const initialSnapshot = getRepositoryWatcherInitialSnapshot(state); + expect(state.source).toEqual({ type: 'working-tree' }); expect(state.files).toEqual([]); + if (!initialSnapshot) { + throw new Error('Expected a fresh working tree to retain a watcher snapshot.'); + } + await expect(initialSnapshot).resolves.toMatchObject({ + pathSignatures: {}, + root: repo, + }); })); test('readWalkthroughRepositoryState preserves nested launch paths', () => @@ -1213,9 +1572,9 @@ test('readRepositoryState reports commit metadata for root commits', async () => if (!metadata) { throw new Error('Expected commit metadata.'); } - expect(metadata.ref).toBe(commit); + expect(metadata.sha).toBe(commit); expect(metadata.subject).toBe('initial commit'); - expect(metadata.parents).toEqual([]); + expect(metadata.parentShas).toEqual([]); expect(metadata.stats).toEqual({ additions: 2, binaryFiles: 0, @@ -1261,7 +1620,7 @@ test('readRepositoryState reports commit body, trailers, refs, and rename stats' if (!metadata) { throw new Error('Expected commit metadata.'); } - expect(metadata.ref).toBe(commit); + expect(metadata.sha).toBe(commit); expect(metadata.body).toBe('Detailed comment.'); expect(metadata.body).not.toContain('Co-authored-by'); expect(metadata.trailers).toEqual([ @@ -1346,8 +1705,8 @@ test('readRepositoryState opens branch refs as current branch diffs against the expect(source.ref).toBe(baseBranch); expect(source.type).toBe('branch-diff'); - expect(source.baseRef).toBeTruthy(); - expect(source.headRef).toBeTruthy(); + expect(source.baseSha).toBeTruthy(); + expect(source.headSha).toBeTruthy(); expect(state.files.map((file) => file.path)).toEqual(['file.txt']); expect(state.files[0].sections[0].oldFile?.contents).toBe('base\n'); expect(state.files[0].sections[0].newFile?.contents).toBe('feature two\n'); @@ -1359,24 +1718,51 @@ test('readRepositoryState opens branch refs as current branch diffs against the await writeRepoFile(repo, 'file.txt', 'feature three\n'); await commitAll(repo, 'feature three'); - const [section, staleHistory] = await Promise.all([ - readDiffSectionContent(repo, { - force: true, - kind: state.files[0].sections[0].kind, - path: 'file.txt', - source, - }), + const range = state.files[0].sections[0].range!; + const [oldContents, newContents, staleHistory] = await Promise.all([ + readRevisionText(repo, source, 'file.txt', range.base!), + readRevisionText(repo, source, 'file.txt', range.head!), listRepositoryHistory(repo, 10, source), ]); - expect(section.oldFile?.contents).toBe('base\n'); - expect(section.newFile?.contents).toBe('feature two\n'); + expect(oldContents).toBe('base\n'); + expect(newContents).toBe('feature two\n'); expect(staleHistory.entries.map((entry) => (entry as { subject: string }).subject)).toEqual([ 'feature two', 'feature one', ]); }); -}); +}, 15_000); + +test('readRepositoryState retains watcher snapshots through branch working-tree composition', async () => { + await withRepo(async (repo) => { + await writeRepoFile(repo, 'file.txt', 'base\n'); + await commitAll(repo, 'initial commit'); + const baseBranch = (await git(repo, ['rev-parse', '--abbrev-ref', 'HEAD'])).trim(); + + await git(repo, ['checkout', '-b', 'feature']); + await writeRepoFile(repo, 'file.txt', 'feature\n'); + await commitAll(repo, 'feature change'); + await writeRepoFile(repo, 'file.txt', 'local change\n'); + + const state = await readRepositoryState(repo, { + ref: baseBranch, + type: 'branch-working-tree', + }); + const initialSnapshot = getRepositoryWatcherInitialSnapshot(state); + + expect(state.source.type).toBe('branch-working-tree'); + if (!initialSnapshot) { + throw new Error('Expected the composed repository state to retain a watcher snapshot.'); + } + await expect(initialSnapshot).resolves.toMatchObject({ + pathSignatures: { + 'file.txt': expect.any(String), + }, + root: repo, + }); + }); +}, 30_000); test('readRepositoryState reports missing branch refs clearly', async () => { await withRepo(async (repo) => { @@ -1552,15 +1938,14 @@ test('readWorkingTreeState defers large untracked text files and loads them on d expect(section?.newFile).toBeUndefined(); expect(section?.patch).toBe(''); - const loadedSection = await readDiffSectionContent(repo, { - force: true, - kind: 'unstaged', - path: 'large.txt', - }); + const loadedContents = await readRevisionText( + repo, + state.source, + 'large.txt', + section!.range!.head!, + ); - expect(loadedSection.loadState).toBe('ready'); - expect(loadedSection.newFile?.contents).toBe(contents); - expect(loadedSection.patch).toContain('new file mode'); + expect(loadedContents).toBe(contents); }); }); @@ -1781,6 +2166,7 @@ test('readRepositoryState reads commit diffs from short hashes', async () => { await writeRepoFile(repo, 'new.txt', 'created\n'); await commitAll(repo, 'second commit'); const commit = (await git(repo, ['rev-parse', 'HEAD'])).trim(); + const parent = (await git(repo, ['rev-parse', 'HEAD^'])).trim(); const shortCommit = commit.slice(0, 8); const state = await readRepositoryState(repo, { @@ -1789,7 +2175,7 @@ test('readRepositoryState reads commit diffs from short hashes', async () => { }); expect(state.source).toEqual({ - ref: commit, + sha: commit, type: 'commit', }); expect(state.files.map((file) => file.path).sort()).toEqual(['file.txt', 'new.txt']); @@ -1797,6 +2183,10 @@ test('readRepositoryState reads commit diffs from short hashes', async () => { expect(state.files.find((file) => file.path === 'file.txt')?.sections[0].patch).toContain( '+two', ); + expect(state.files.find((file) => file.path === 'file.txt')?.sections[0].range).toMatchObject({ + base: { sha: parent }, + head: { sha: commit }, + }); }); }); @@ -1823,7 +2213,7 @@ test('readRepositoryState reads merge commits against the first parent', async ( expect(state.files[0].sections[0].newFile?.contents).toBe('feature\n'); expect(state.files[0].sections[0].patch).toContain('+feature'); }); -}); +}, 15_000); test('reads commit diffs for many changed files', async () => { await withRepo(async (repo) => { @@ -1899,51 +2289,50 @@ test('readRepositoryState defers medium committed files and loads them on demand expect(section?.newFile).toBeUndefined(); expect(section?.patch).toBe(''); - const loadedSection = await readDiffSectionContent(repo, { - force: true, - kind: 'commit', - path: 'large.txt', - source: { - ref: commit, - type: 'commit', - }, - }); + const loadedContents = await readRevisionText( + repo, + state.source, + 'large.txt', + section!.range!.head!, + ); - expect(loadedSection.loadState).toBe('ready'); - expect(loadedSection.newFile?.contents).toBe(contents); - expect(loadedSection.patch).toContain('+large committed line'); + expect(loadedContents).toBe(contents); }); -}); +}, 15_000); test('readRepositoryState rejects non-repository launch paths', async () => { await using directory = await createTemporaryDirectory('codiff-not-a-repo-'); await expect(readRepositoryState(directory.path)).rejects.toThrow(/not a git repository/i); }); -test('readRepositoryState builds a diff for a base...head range', () => - withRepo(async (repo) => { - await writeRepoFile(repo, 'keep.txt', 'base\n'); - await commitAll(repo, 'first'); - await git(repo, ['branch', 'base']); - await writeRepoFile(repo, 'keep.txt', 'base\nmore\n'); - await writeRepoFile(repo, 'added.txt', 'new file\n'); - await commitAll(repo, 'second'); - await git(repo, ['branch', 'head']); - // A commit made only on base must not appear in base...head (merge-base diff). - await git(repo, ['checkout', 'base']); - await writeRepoFile(repo, 'base-only.txt', 'base only\n'); - await commitAll(repo, 'base-only'); - - const state = await readRepositoryState(repo, { - base: 'base', - head: 'head', - symmetric: true, - type: 'range', - }); - - expect(state.source).toEqual({ base: 'base', head: 'head', symmetric: true, type: 'range' }); - expect(state.files.map((file) => file.path).sort()).toEqual(['added.txt', 'keep.txt']); - const added = state.files.find((file) => file.path === 'added.txt'); - expect(added?.status).toBe('added'); - expect(added?.sections[0]?.patch).toContain('new file'); - })); +test( + 'readRepositoryState builds a diff for a base...head range', + () => + withRepo(async (repo) => { + await writeRepoFile(repo, 'keep.txt', 'base\n'); + await commitAll(repo, 'first'); + await git(repo, ['branch', 'base']); + await writeRepoFile(repo, 'keep.txt', 'base\nmore\n'); + await writeRepoFile(repo, 'added.txt', 'new file\n'); + await commitAll(repo, 'second'); + await git(repo, ['branch', 'head']); + // A commit made only on base must not appear in base...head (merge-base diff). + await git(repo, ['checkout', 'base']); + await writeRepoFile(repo, 'base-only.txt', 'base only\n'); + await commitAll(repo, 'base-only'); + + const state = await readRepositoryState(repo, { + base: 'base', + head: 'head', + symmetric: true, + type: 'range', + }); + + expect(state.source).toEqual({ base: 'base', head: 'head', symmetric: true, type: 'range' }); + expect(state.files.map((file) => file.path).sort()).toEqual(['added.txt', 'keep.txt']); + const added = state.files.find((file) => file.path === 'added.txt'); + expect(added?.status).toBe('added'); + expect(added?.sections[0]?.patch).toContain('new file'); + }), + 15_000, +); diff --git a/core/__tests__/gitlab.test.ts b/core/__tests__/gitlab.test.ts index ffda0904..4b256fdb 100644 --- a/core/__tests__/gitlab.test.ts +++ b/core/__tests__/gitlab.test.ts @@ -4,6 +4,7 @@ import { createRequire } from 'node:module'; import { join } from 'node:path'; import { promisify } from 'node:util'; import { describe, expect, test } from 'vite-plus/test'; +import type { SubmitPullRequestReviewResult } from '../types.ts'; import { createTemporaryDirectory, createTemporaryEnvironment } from './helpers/resources.ts'; const require = createRequire(import.meta.url); @@ -16,8 +17,11 @@ type GitLabPosition = Record & { const { createGitLabPosition, createMergeRequestFetchRefspecs, + createMergeRequestSource, + normalizeGitLabGeneralDiscussion, normalizeGitLabReviewComment, parseGitLabMergeRequestUrl, + readMergeRequestReviewComments, submitMergeRequestComment, submitMergeRequestReview, } = require('../../electron/git-state/merge-request.cjs') as { @@ -30,11 +34,28 @@ const { mergeRequest: Record, metadata: Record, ) => ReadonlyArray; + createMergeRequestSource: ( + mergeRequest: Record, + metadata: Record, + ) => Record; + normalizeGitLabGeneralDiscussion: ( + discussion: Record, + url: string, + ) => Record | null; normalizeGitLabReviewComment: ( note: Record, url: string, + threadId?: string, + rootNote?: Record, ) => Record | null; parseGitLabMergeRequestUrl: (url: string) => Record; + readMergeRequestReviewComments: ( + launchPath: string, + source: Record, + ) => Promise<{ + generalComments: ReadonlyArray>; + reviewComments: ReadonlyArray>; + }>; submitMergeRequestComment: ( launchPath: string, request: { @@ -50,7 +71,7 @@ const { event: 'APPROVE' | 'COMMENT' | 'REQUEST_CHANGES'; source: Record; }, - ) => Promise; + ) => Promise; }; const execFileAsync = promisify(execFile); @@ -95,6 +116,19 @@ process.stdin.on('end', () => { ); return; } + if (endpoint.endsWith('/discussions?per_page=100')) { + process.stdout.write(JSON.stringify([{ + id: 'discussion-from-provider', + notes: [{ + author: { username: 'reviewer' }, + body: 'Loaded discussion comment.', + created_at: '2026-07-08T00:00:00Z', + id: 47, + position: { new_line: 12, new_path: 'src/new.ts', old_path: 'src/old.ts' }, + }], + }])); + return; + } if (endpoint.endsWith('/discussions/discussion%2Fwith%20spaces/notes')) { process.stdout.write(JSON.stringify({ author: { username: 'reviewer' }, @@ -104,6 +138,10 @@ process.stdin.on('end', () => { })); return; } + if (endpoint.endsWith('/draft_notes')) { + process.stdout.write(JSON.stringify({ id: 101 })); + return; + } if (endpoint.endsWith('/merge_requests/23')) { process.stdout.write(JSON.stringify({ diff_refs: { base_sha: 'base', head_sha: 'head', start_sha: 'start' }, @@ -176,6 +214,24 @@ describe('GitLab merge requests', () => { ]); }); + test('preserves the GitLab target branch in the review source', () => { + expect( + createMergeRequestSource( + { + host: 'gitlab.example.com', + number: 23, + projectPath: 'group/project', + url: 'https://gitlab.example.com/group/project/-/merge_requests/23', + }, + { + sha: 'head-sha', + target_branch: 'main', + title: 'Focused fix', + }, + ), + ).toMatchObject({ provider: 'gitlab', targetBranch: 'main', type: 'pull-request' }); + }); + test('builds single-line and ranged GitLab diff positions', () => { const metadata = { diff_refs: { @@ -289,6 +345,7 @@ describe('GitLab merge requests', () => { }, }, 'https://gitlab.example.com/group/project/-/merge_requests/23', + 'discussion-44', ), ).toMatchObject({ author: { login: 'reviewer' }, @@ -297,6 +354,7 @@ describe('GitLab merge requests', () => { id: 'gitlab:44', lineNumber: 12, side: 'additions', + threadId: 'discussion-44', url: 'https://gitlab.example.com/group/project/-/merge_requests/23#note_44', }); }); @@ -327,39 +385,193 @@ describe('GitLab merge requests', () => { }); }); + test('makes GitLab replies inherit the root position, range, and thread identity', () => { + const root = { + author: { username: 'root-reviewer' }, + body: 'Root.', + created_at: '2026-06-17T00:00:00Z', + id: 44, + position: { + base_sha: 'a'.repeat(40), + head_sha: 'b'.repeat(40), + line_range: { + end: { new_line: 14, old_line: null, type: 'new' }, + start: { new_line: null, old_line: 10, type: 'old' }, + }, + new_line: 14, + new_path: 'src/new-name.ts', + old_line: 10, + old_path: 'src/old-name.ts', + }, + resolvable: true, + }; + const reply = { + author: { username: 'reply-reviewer' }, + body: 'Reply with misleading coordinates.', + created_at: '2026-06-18T00:00:00Z', + id: 45, + position: { + new_line: 99, + new_path: 'src/reply.ts', + old_path: 'src/reply.ts', + }, + }; + + expect( + normalizeGitLabReviewComment( + reply, + 'https://gitlab.example.com/group/project/-/merge_requests/23', + 'discussion-44', + root, + ), + ).toMatchObject({ + body: 'Reply with misleading coordinates.', + canResolveThread: true, + filePath: 'src/new-name.ts', + lineNumber: 14, + position: { + range: { + base: { sha: 'a'.repeat(40) }, + head: { sha: 'b'.repeat(40) }, + }, + }, + side: 'additions', + startLineNumber: 10, + startSide: 'deletions', + threadId: 'discussion-44', + }); + }); + + test('uses original GitLab positions for outdated old-side and file comments', () => { + const url = 'https://gitlab.example.com/group/project/-/merge_requests/23'; + const oldSide = normalizeGitLabReviewComment( + { + author: { username: 'reviewer' }, + body: 'Historical old-side comment.', + created_at: '2026-06-17T00:00:00Z', + id: 46, + original_position: { + base_sha: 'a'.repeat(40), + head_sha: 'b'.repeat(40), + new_path: 'src/new-name.ts', + old_line: 7, + old_path: 'src/old-name.ts', + }, + }, + url, + ); + const file = normalizeGitLabReviewComment( + { + author: { username: 'reviewer' }, + body: 'Historical file comment.', + created_at: '2026-06-17T00:00:00Z', + id: 47, + original_position: { + base_sha: 'a'.repeat(40), + head_sha: 'b'.repeat(40), + new_path: 'src/new-name.ts', + old_path: 'src/old-name.ts', + position_type: 'file', + }, + }, + url, + ); + + expect(oldSide).toMatchObject({ + filePath: 'src/new-name.ts', + isOutdated: true, + lineNumber: 7, + side: 'deletions', + }); + expect(file).toMatchObject({ + anchor: 'file', + filePath: 'src/new-name.ts', + isOutdated: true, + }); + }); + + test('maps positionless GitLab discussions to overview threads', () => { + expect( + normalizeGitLabGeneralDiscussion( + { + id: 'overview-thread', + notes: [ + { + author: { username: 'author' }, + body: 'Overview root.', + created_at: '2026-06-17T00:00:00Z', + id: 50, + resolvable: true, + }, + { + author: { username: 'reply-author' }, + body: 'Overview reply.', + created_at: '2026-06-18T00:00:00Z', + id: 51, + }, + ], + }, + 'https://gitlab.example.com/group/project/-/merge_requests/23', + ), + ).toMatchObject({ + canResolve: true, + comments: [ + { body: 'Overview root.', id: 'gitlab:50' }, + { body: 'Overview reply.', id: 'gitlab:51' }, + ], + id: 'overview-thread', + }); + }); + test('submits GitLab reviews with paginated diffs and JSON request bodies', async () => { await withFakeGitLab(async (repo, readCalls) => { const source = { + headSha: 'head', provider: 'gitlab', type: 'pull-request', url: 'https://gitlab.example.com/group/project/-/merge_requests/23', }; - await submitMergeRequestReview(repo, { - body: 'Looks good.', - comments: [ - { - body: 'Keep this explicit.', - filePath: 'src/new.ts', - lineNumber: 12, - side: 'additions', - }, - ], - event: 'APPROVE', - source, - }); - await submitMergeRequestReview(repo, { - comments: [], - event: 'REQUEST_CHANGES', - source, - }); await expect( submitMergeRequestReview(repo, { + body: 'Looks good.', + comments: [ + { + body: 'Keep this explicit.', + filePath: 'src/new.ts', + lineNumber: 12, + localDraftId: 'draft-1', + position: { + range: { + base: { label: { kind: 'commit', text: 'base' }, sha: 'base' }, + head: { label: { kind: 'commit', text: 'head' }, sha: 'head' }, + }, + }, + side: 'additions', + }, + ], + event: 'APPROVE', + source, + }), + ).resolves.toEqual({ status: 'submitted', submittedDraftIds: ['draft-1'] }); + await expect( + submitMergeRequestReview(repo, { + comments: [], + event: 'REQUEST_CHANGES', + source, + }), + ).resolves.toEqual({ status: 'submitted', submittedDraftIds: [] }); + await expect( + submitMergeRequestReview(repo, { + body: 'Neutral review summary.', comments: [], event: 'COMMENT', source, }), - ).rejects.toThrow('GitLab merge request reviews do not support COMMENT.'); + ).resolves.toEqual({ + status: 'submitted', + submittedDraftIds: [], + }); const calls = await readCalls(); const requestsWithBodies = calls.filter((call) => call.input); @@ -386,6 +598,34 @@ describe('GitLab merge requests', () => { 'Looks good.\n\n/submit_review approve', '/submit_review request_changes', ]); + const neutralPublication = calls.find((call) => + call.args.at(-1)?.endsWith('/draft_notes/bulk_publish'), + ); + expect(JSON.parse(neutralPublication?.input || '')).toEqual({ + note: 'Neutral review summary.', + reviewer_state: 'reviewed', + }); + }); + }); + + test('loads GitLab discussion IDs as provider thread identity', async () => { + await withFakeGitLab(async (repo) => { + await expect( + readMergeRequestReviewComments(repo, { + provider: 'gitlab', + type: 'pull-request', + url: 'https://gitlab.example.com/group/project/-/merge_requests/23', + }), + ).resolves.toEqual({ + generalComments: [], + reviewComments: [ + expect.objectContaining({ + body: 'Loaded discussion comment.', + id: 'gitlab:47', + threadId: 'discussion-from-provider', + }), + ], + }); }); }); @@ -416,7 +656,7 @@ describe('GitLab merge requests', () => { const [call] = await readCalls(); expect(call.args.at(-1)).toBe( - 'projects/group%2Fproject/merge_requests/23/discussions/discussion%2Fwith%20spaces/notes', + '/projects/group%2Fproject/merge_requests/23/discussions/discussion%2Fwith%20spaces/notes', ); expect(call.args).toContain('Content-Type: application/json'); expect(JSON.parse(call.input)).toEqual({ body: 'Reply in the existing discussion.' }); @@ -456,7 +696,7 @@ if [ "$GITLAB_TOKEN" != 'from-login-shell' ]; then echo 'To get started with GitLab CLI, please run: glab auth login.' >&2 exit 4 fi -printf '%s' '{}' +printf '%s' '[]' `, ); await Promise.all([chmod(fakeShell, 0o755), chmod(fakeGlab, 0o755)]); @@ -507,7 +747,7 @@ if [ "$GITLAB_TOKEN" != 'from-process' ]; then echo 'Expected the process GITLAB_TOKEN to win, got:' "$GITLAB_TOKEN" >&2 exit 4 fi -printf '%s' '{}' +printf '%s' '[]' `, ); await Promise.all([chmod(fakeShell, 0o755), chmod(fakeGlab, 0o755)]); diff --git a/core/__tests__/helpers/fixtures.ts b/core/__tests__/helpers/fixtures.ts index 3eb87f1c..b21a5faf 100644 --- a/core/__tests__/helpers/fixtures.ts +++ b/core/__tests__/helpers/fixtures.ts @@ -1,4 +1,4 @@ -import type { ChangedFile } from '../../types.ts'; +import type { ChangedFile, DiffRange, GitSha } from '../../types.ts'; type ChangedFileOptions = { fingerprint?: string; @@ -7,6 +7,27 @@ type ChangedFileOptions = { status?: ChangedFile['status']; }; +const commitRevision = (character: string) => ({ + label: { kind: 'commit' as const, text: character.repeat(7) }, + sha: character.repeat(40) as GitSha, +}); + +const rangeForKind = (kind: ChangedFile['sections'][number]['kind']): DiffRange => + kind === 'unstaged' + ? { + base: { kind: 'index', label: { kind: 'review-marker', text: 'Index' } }, + head: { + kind: 'working-copy', + label: { kind: 'review-marker', text: 'Working copy' }, + }, + } + : kind === 'staged' + ? { + base: commitRevision('a'), + head: { kind: 'index', label: { kind: 'review-marker', text: 'Index' } }, + } + : { base: commitRevision('a'), head: commitRevision('b') }; + export const createChangedFile = ( path: string, { @@ -25,6 +46,7 @@ export const createChangedFile = ( id: `${path}:${kind}`, kind, patch: patch ?? `diff --git a/${path} b/${path}\n@@ -1 +1 @@\n-old\n+new\n`, + range: rangeForKind(kind), }, ], status, diff --git a/core/__tests__/helpers/react.tsx b/core/__tests__/helpers/react.tsx index e88b4ce3..d82edee8 100644 --- a/core/__tests__/helpers/react.tsx +++ b/core/__tests__/helpers/react.tsx @@ -5,24 +5,32 @@ export const renderReact = async (element: ReactNode) => { const container = document.createElement('div'); document.body.append(container); const root = createRoot(container); + let cleanedUp = false; await act(async () => { root.render(element); }); + const cleanup = async () => { + if (cleanedUp) { + return; + } + cleanedUp = true; + await act(async () => { + root.unmount(); + }); + container.remove(); + }; + return { + cleanup, container, rerender: async (nextElement: ReactNode) => { await act(async () => { root.render(nextElement); }); }, - async [Symbol.asyncDispose]() { - await act(async () => { - root.unmount(); - }); - container.remove(); - }, + [Symbol.asyncDispose]: cleanup, }; }; diff --git a/core/__tests__/narrative-walkthrough-view.test.ts b/core/__tests__/narrative-walkthrough-view.test.ts index 43ec1715..88043e10 100644 --- a/core/__tests__/narrative-walkthrough-view.test.ts +++ b/core/__tests__/narrative-walkthrough-view.test.ts @@ -17,6 +17,7 @@ import { getUncoveredWalkthroughFiles, getWalkthroughRunNote, isWalkthroughCommittable, + resolveWalkthroughFileLineItems, resolveWalkthroughHunkFile, resolveWalkthroughHunkRuns, walkthroughItemPaths, @@ -24,11 +25,14 @@ import { } from '../lib/narrative-walkthrough.ts'; import type { ChangedFile, + GitSha, NarrativeWalkthrough, WalkthroughHunk, WalkthroughHunkGroup, } from '../types.ts'; +const gitSha = (value: string) => value as GitSha; + const hunk = ({ added, additionEnd, @@ -518,7 +522,96 @@ test('uncovered walkthrough files keep visible non-hunk sections in support fall 'public/logo.png:staged', ]); expect(getUncoveredWalkthroughFileLineItems(files, view, false)).toEqual([ - { added: 0, deleted: 0, path: 'public/logo.png' }, + { added: 0, deleted: 0, diffAvailable: false, path: 'public/logo.png' }, + ]); +}); + +test('synthetic walkthrough counts prefer exact content and preserve unknown and exact zero', () => { + const synthetic = (path: string) => + hunk({ + added: 0, + deleted: 0, + display: path, + id: `${path}:pull-request:h1`, + kind: 'synthetic', + path, + sectionId: `${path}:pull-request`, + status: 'modified', + }); + const files: ReadonlyArray = [ + { + fingerprint: 'exact', + path: 'src/exact.ts', + sections: [ + { + binary: false, + id: 'src/exact.ts:pull-request', + kind: 'pull-request', + lineCount: { additions: 50, deletions: 50 }, + loadState: 'ready', + newFile: { contents: 'new\nkept', name: 'src/exact.ts' }, + oldFile: { contents: 'old\nkept', name: 'src/exact.ts' }, + patch: '', + }, + ], + status: 'modified', + }, + { + fingerprint: 'provider', + path: 'src/provider.ts', + sections: [ + { + binary: false, + id: 'src/provider.ts:pull-request', + kind: 'pull-request', + lineCount: { additions: 0, deletions: 3767 }, + loadState: 'deferred', + patch: '', + }, + ], + status: 'modified', + }, + { + fingerprint: 'zero', + path: 'src/zero.ts', + sections: [ + { + binary: false, + id: 'src/zero.ts:pull-request', + kind: 'pull-request', + lineCount: { additions: 0, deletions: 0 }, + loadState: 'deferred', + patch: '', + }, + ], + status: 'modified', + }, + { + fingerprint: 'unknown', + path: 'src/unknown.ts', + sections: [ + { + binary: false, + id: 'src/unknown.ts:pull-request', + kind: 'pull-request', + loadState: 'error', + patch: '', + }, + ], + status: 'modified', + }, + ]; + + expect( + resolveWalkthroughFileLineItems( + files.map((file) => synthetic(file.path)), + files, + ), + ).toEqual([ + { added: 1, deleted: 1, path: 'src/exact.ts' }, + { added: 0, deleted: 3767, path: 'src/provider.ts' }, + { added: 0, deleted: 0, path: 'src/zero.ts' }, + { added: 0, deleted: 0, diffAvailable: false, path: 'src/unknown.ts' }, ]); }); @@ -654,7 +747,7 @@ test('working-tree walkthroughs are committable even without commit seed text', const committedReview: NarrativeWalkthrough = { ...walkthrough(), commit: {}, - source: { ref: 'HEAD', type: 'commit' }, + source: { sha: gitSha('HEAD'), type: 'commit' }, }; expect(isWalkthroughCommittable(wt)).toBe(true); diff --git a/core/__tests__/reload-selection.test.ts b/core/__tests__/reload-selection.test.ts index e95d63ac..cd0528ef 100644 --- a/core/__tests__/reload-selection.test.ts +++ b/core/__tests__/reload-selection.test.ts @@ -14,7 +14,15 @@ import { haveReloadedFilesChanged, writeReloadSelection, } from '../lib/reload-selection.ts'; -import type { ChangedFile, GitFileStatus, RepositoryState, ReviewSource } from '../types.ts'; +import type { + ChangedFile, + GitFileStatus, + GitSha, + RepositoryState, + ReviewSource, +} from '../types.ts'; + +const gitSha = (value: string) => value as GitSha; beforeEach(() => { window.sessionStorage.clear(); @@ -56,8 +64,8 @@ test('reload selection preserves the branch diff source without a selected file' const currentState = { ...state([]), source: { - baseRef: 'base123', - headRef: 'head123', + baseSha: gitSha('base123'), + headSha: gitSha('head123'), ref: 'main', type: 'branch-diff', }, @@ -72,8 +80,8 @@ test('reload selection preserves the branch diff source without a selected file' test('reload selection preserves history source for the current source', () => { const branchSource = { - baseRef: 'base123', - headRef: 'head123', + baseSha: gitSha('base123'), + headSha: gitSha('head123'), ref: 'main', type: 'branch-diff', } satisfies ReviewSource; @@ -87,7 +95,7 @@ test('reload selection preserves history source for the current source', () => { expect( getReloadHistorySource(selection, { ...currentState, - source: { ref: 'abc1234', type: 'commit' }, + source: { sha: gitSha('abc1234'), type: 'commit' }, }), ).toBeNull(); }); @@ -103,7 +111,7 @@ test('reload selection preserves the commit view for the current source', () => expect( getReloadMainMode(selection, { ...currentState, - source: { ref: 'abc1234', type: 'commit' }, + source: { sha: gitSha('abc1234'), type: 'commit' }, }), ).toBeNull(); }); @@ -163,7 +171,7 @@ test('reload selection is ignored when it belongs to another repository source', const workingTreeState = state([changedFile]); const commitState = { ...workingTreeState, - source: { ref: 'abc1234', type: 'commit' }, + source: { sha: gitSha('abc1234'), type: 'commit' }, } satisfies RepositoryState; writeReloadSelection(workingTreeState, changedFile.path); diff --git a/core/__tests__/repository-refresh.test.ts b/core/__tests__/repository-refresh.test.ts new file mode 100644 index 00000000..176e3e1c --- /dev/null +++ b/core/__tests__/repository-refresh.test.ts @@ -0,0 +1,175 @@ +import { expect, test } from 'vite-plus/test'; +import { hasReviewedCodeChanged, reconcileRepositoryRefresh } from '../lib/repository-refresh.ts'; +import type { GitSha, RepositoryState } from '../types.ts'; +import { createChangedFile } from './helpers/fixtures.ts'; + +const gitSha = (character: string) => character.repeat(40) as GitSha; +const stateFor = ( + files: RepositoryState['files'], + source: RepositoryState['source'] = { type: 'working-tree' }, +): RepositoryState => ({ + branch: 'main', + files, + generatedAt: 1, + launchPath: '/repo', + root: '/repo', + source, +}); + +test('reconciles changed paths, selection, collapsed state, and walkthrough refresh together', () => { + const previousState = stateFor([ + createChangedFile('src/changed.ts', { fingerprint: 'before', patch: 'before' }), + createChangedFile('src/unchanged.ts', { fingerprint: 'stable' }), + createChangedFile('src/removed.ts'), + ]); + const nextState = stateFor([ + createChangedFile('src/changed.ts', { fingerprint: 'after', patch: 'after' }), + createChangedFile('src/unchanged.ts', { fingerprint: 'stable' }), + ]); + + expect( + reconcileRepositoryRefresh({ + collapsed: new Set(['src/changed.ts', 'src/unchanged.ts']), + historySource: null, + mainMode: 'review', + nextState, + previousState, + selectedPath: 'src/removed.ts', + }), + ).toEqual({ + changedPaths: new Set(['src/changed.ts']), + collapsed: new Set(['src/unchanged.ts']), + historySource: null, + mainMode: 'review', + selectedPath: 'src/changed.ts', + walkthroughNeedsRefresh: true, + }); +}); + +test('repairs commit mode after the working tree becomes empty', () => { + const previousState = stateFor([createChangedFile('src/committed.ts')]); + const nextState = stateFor([]); + + expect( + reconcileRepositoryRefresh({ + collapsed: new Set(), + historySource: null, + mainMode: 'commit', + nextState, + previousState, + selectedPath: 'src/committed.ts', + }), + ).toMatchObject({ mainMode: 'review', selectedPath: null }); +}); + +test('retains the active History scope when refreshed state has no replacement', () => { + const historySource = { + baseSha: gitSha('a'), + headSha: gitSha('b'), + ref: 'feature', + type: 'branch-diff', + } as const; + const previousState = stateFor([], { + ...historySource, + type: 'branch-working-tree', + }); + const nextState = stateFor([], { type: 'working-tree' }); + + expect( + reconcileRepositoryRefresh({ + collapsed: new Set(), + historySource, + mainMode: 'review', + nextState, + previousState, + selectedPath: null, + }).historySource, + ).toEqual(historySource); +}); + +test('compares canonical reviewed code without provider order or revision metadata', () => { + const original = createChangedFile('src/reviewed.ts', { + fingerprint: 'provider-state-a', + patch: 'diff --git a/src/reviewed.ts b/src/reviewed.ts\n@@ -1 +1 @@\n-old\n+new\n', + }); + const withRange = { + ...original, + sections: original.sections.map((section) => ({ + ...section, + range: { + base: { + label: { kind: 'commit' as const, text: 'base' }, + sha: gitSha('a'), + }, + head: { + label: { kind: 'commit' as const, text: 'head' }, + sha: gitSha('b'), + }, + }, + })), + }; + const refreshed = { + ...withRange, + fingerprint: 'provider-state-b', + generated: true, + sections: withRange.sections.map((section) => ({ + ...section, + id: 'provider-order-2', + kind: 'pull-request' as const, + loadState: 'ready' as const, + newFile: { cacheKey: 'new-head:path', contents: 'new\n', name: 'src/reviewed.ts' }, + oldFile: { cacheKey: 'old-base:path', contents: 'old\n', name: 'src/reviewed.ts' }, + range: { + base: { + label: { kind: 'commit' as const, text: 'rebased' }, + sha: gitSha('c'), + }, + head: { + label: { kind: 'commit' as const, text: 'updated' }, + sha: gitSha('d'), + }, + }, + summary: { canLoad: false, reason: 'Hydrated after refresh.' }, + })), + }; + const other = createChangedFile('src/other.ts'); + + expect(hasReviewedCodeChanged([withRange, other], [other, refreshed])).toBe(false); +}); + +test('detects reviewed patch, blob, path, rename, and status changes', () => { + const file = createChangedFile('src/reviewed.ts'); + const binary = { + ...file, + sections: file.sections.map((section) => ({ + ...section, + binary: true, + patch: '', + summary: { fingerprint: 'blob-a', reason: 'Binary file changed.' }, + })), + }; + + expect( + hasReviewedCodeChanged( + [file], + [createChangedFile('src/reviewed.ts', { patch: `${file.sections[0].patch}+another\n` })], + ), + ).toBe(true); + expect( + hasReviewedCodeChanged( + [binary], + [ + { + ...binary, + sections: binary.sections.map((section) => ({ + ...section, + summary: { fingerprint: 'blob-b', reason: 'Different provider wording.' }, + })), + }, + ], + ), + ).toBe(true); + expect(hasReviewedCodeChanged([file], [{ ...file, path: 'src/moved.ts' }])).toBe(true); + expect(hasReviewedCodeChanged([file], [{ ...file, oldPath: 'src/old.ts' }])).toBe(true); + expect(hasReviewedCodeChanged([file], [{ ...file, status: 'renamed' }])).toBe(true); +}); diff --git a/core/__tests__/repository-review-bootstrap.test.ts b/core/__tests__/repository-review-bootstrap.test.ts new file mode 100644 index 00000000..8daf2146 --- /dev/null +++ b/core/__tests__/repository-review-bootstrap.test.ts @@ -0,0 +1,178 @@ +import { expect, test } from 'vite-plus/test'; +import type { ReloadSelection } from '../lib/reload-selection.ts'; +import { + resolveReloadSourceForLaunch, + resolveRepositoryReviewBootstrap, +} from '../lib/repository-review-bootstrap.ts'; +import type { + CodiffLaunchOptions, + GitSha, + RepositoryState, + ResolvedReviewSource, +} from '../types.ts'; +import { createChangedFile } from './helpers/fixtures.ts'; + +const gitSha = (character: string) => character.repeat(40) as GitSha; +const launchOptions = { + repositoryPathProvided: true, + walkthrough: false, +} satisfies CodiffLaunchOptions; + +const stateFor = ( + source: ResolvedReviewSource, + files: RepositoryState['files'] = [], +): RepositoryState => ({ + branch: 'main', + files, + generatedAt: 1, + launchPath: '/repo', + root: '/repo', + source, +}); + +const selectionFor = ( + state: RepositoryState, + overrides: Partial = {}, +): ReloadSelection => ({ + files: state.files.map(({ fingerprint, path, status }) => ({ fingerprint, path, status })), + mainMode: 'review', + root: state.root, + selectedPath: state.files[0]?.path ?? null, + source: state.source, + ...overrides, +}); + +test('resolves clean and changed working-tree startup modes', () => { + const cleanState = stateFor({ type: 'working-tree' }); + expect( + resolveRepositoryReviewBootstrap({ + launchOptions, + reloadSelection: null, + state: cleanState, + }), + ).toMatchObject({ + historySource: null, + mainMode: 'review', + selectedPath: null, + sidebarMode: 'history', + source: { type: 'working-tree' }, + }); + + const changedState = stateFor({ type: 'working-tree' }, [ + createChangedFile('src/changed.ts', { fingerprint: 'after' }), + ]); + const previous = selectionFor(changedState, { + files: [{ fingerprint: 'before', path: 'src/changed.ts', status: 'modified' }], + }); + const bootstrap = resolveRepositoryReviewBootstrap({ + launchOptions, + reloadSelection: previous, + state: changedState, + }); + expect(bootstrap.sidebarMode).toBe('tree'); + expect(bootstrap.reloadDeltaPaths).toEqual(new Set(['src/changed.ts'])); +}); + +test('restores a valid working-tree commit view and one-shot instant scroll target', () => { + const state = stateFor({ type: 'working-tree' }, [ + createChangedFile('src/first.ts'), + createChangedFile('src/restored.ts'), + ]); + const bootstrap = resolveRepositoryReviewBootstrap({ + launchOptions, + reloadSelection: selectionFor(state, { + mainMode: 'commit', + selectedPath: 'src/restored.ts', + }), + state, + }); + + expect(bootstrap.mainMode).toBe('commit'); + expect(bootstrap.selectedPath).toBe('src/restored.ts'); + expect(bootstrap.initialScrollTarget).toEqual({ + behavior: 'instant', + path: 'src/restored.ts', + request: 1, + }); +}); + +test('rejects commit mode for empty and non-working-tree sources', () => { + const emptyState = stateFor({ type: 'working-tree' }); + expect( + resolveRepositoryReviewBootstrap({ + launchOptions, + reloadSelection: selectionFor(emptyState, { mainMode: 'commit' }), + state: emptyState, + }).mainMode, + ).toBe('review'); + + const commitState = stateFor({ sha: gitSha('c'), type: 'commit' }, [ + createChangedFile('src/commit.ts'), + ]); + expect( + resolveRepositoryReviewBootstrap({ + launchOptions, + reloadSelection: selectionFor(commitState, { mainMode: 'commit' }), + state: commitState, + }).mainMode, + ).toBe('review'); +}); + +test('restores branch History scope for branch-diff and branch-working-tree reviews', () => { + const branchSource = { + baseSha: gitSha('a'), + headSha: gitSha('b'), + ref: 'feature', + type: 'branch-diff', + } as const; + const branchState = stateFor(branchSource, [createChangedFile('src/branch.ts')]); + const workingTreeSource = { ...branchSource, type: 'branch-working-tree' } as const; + + expect( + resolveRepositoryReviewBootstrap({ + launchOptions, + reloadSelection: selectionFor(branchState, { historySource: branchSource }), + state: branchState, + }).historySource, + ).toEqual(branchSource); + expect( + resolveRepositoryReviewBootstrap({ + launchOptions, + reloadSelection: selectionFor(stateFor(workingTreeSource), { + historySource: branchSource, + source: workingTreeSource, + }), + state: stateFor(workingTreeSource), + }).historySource, + ).toEqual(branchSource); +}); + +test('lets an explicit launch source override stale reload state', () => { + const state = stateFor({ type: 'working-tree' }); + const selection = selectionFor(state); + expect( + resolveReloadSourceForLaunch(selection, { + ...launchOptions, + source: { ref: 'abc', type: 'commit' }, + }), + ).toBeUndefined(); + expect(resolveReloadSourceForLaunch(selection, launchOptions)).toEqual({ + type: 'working-tree', + }); +}); + +test('walkthrough-file startup selects Walkthrough without forcing regeneration', () => { + const state = stateFor({ type: 'working-tree' }, [ + createChangedFile('src/walkthrough.ts', { fingerprint: 'after' }), + ]); + const bootstrap = resolveRepositoryReviewBootstrap({ + launchOptions: { ...launchOptions, walkthroughFile: '/tmp/walkthrough.json' }, + reloadSelection: selectionFor(state, { + files: [{ fingerprint: 'before', path: 'src/walkthrough.ts', status: 'modified' }], + }), + state, + }); + + expect(bootstrap.sidebarMode).toBe('walkthrough'); + expect(bootstrap.forceInitialWalkthrough).toBe(false); +}); diff --git a/core/__tests__/review-artifacts.test.ts b/core/__tests__/review-artifacts.test.ts new file mode 100644 index 00000000..79b7e9ec --- /dev/null +++ b/core/__tests__/review-artifacts.test.ts @@ -0,0 +1,556 @@ +import { expect, test } from 'vite-plus/test'; +import { + createCommitArtifactRequestKey, + createFileBlobArtifactRequestKey, + createReviewArtifactRun, + validateCommitArtifact, + validateRangeArtifact, + validateReviewArtifactRangeResult, + validateStackSnapshot, + type ReviewArtifactProject, + type ReviewArtifactProvenance, + type ReviewArtifactSource, + type CommitArtifactRequest, + type ReviewArtifactRangeRequest, +} from '../lib/review-artifacts.ts'; +import type { GitSha, ReviewCommitSummary } from '../types.ts'; + +const sha = (value: string) => value.repeat(40) as GitSha; +const project: ReviewArtifactProject = { + host: 'GitLab.Example.com', + project: 'group/project with spaces', + provider: 'gitlab', +}; +const provenance: ReviewArtifactProvenance = { kind: 'gitlab-api', project }; + +const commit = (value: string, parentShas: ReadonlyArray): ReviewCommitSummary => ({ + authoredAt: '2026-01-01T00:00:00.000Z', + authorName: 'Ada', + parentShas, + sha: sha(value), + shortSha: value.repeat(7), + subject: `Commit ${value}`, +}); + +test('complete artifacts reject files without complete change data', () => { + expect(() => + validateCommitArtifact({ + commitSha: sha('b'), + coverage: 'complete', + files: [{ coverage: 'truncated', path: 'src/app.ts', status: 'modified' }], + parentSha: sha('a'), + provenance, + }), + ).toThrow('cannot be complete'); + expect(() => + validateRangeArtifact({ + baseSha: sha('a'), + coverage: 'complete', + files: [{ coverage: 'complete', path: 'src/app.ts', status: 'modified' }], + headSha: sha('b'), + provenance, + }), + ).toThrow('without a patch or exact object/mode metadata'); +}); + +test('Range Artifacts preserve explicit incomplete evidence without calling it complete', () => { + const artifact = validateRangeArtifact({ + baseSha: sha('a'), + coverage: 'truncated', + files: [], + headSha: sha('b'), + incompleteReason: "The provider response reached Codiff's range budget.", + provenance, + }); + expect(artifact.incompleteReason).toContain('range budget'); + + expect(() => + validateRangeArtifact({ + baseSha: sha('a'), + coverage: 'complete', + files: [], + headSha: sha('b'), + incompleteReason: 'This must remain incomplete.', + provenance, + }), + ).toThrow('incomplete-evidence reason'); +}); + +test('Stack Snapshots are parent-first and end at the declared head', () => { + const first = commit('b', [sha('a')]); + const head = commit('c', [first.sha]); + expect( + validateStackSnapshot({ + baseSha: sha('a'), + commits: [first, head], + coverage: 'complete', + headSha: head.sha, + provenance, + }).commits, + ).toEqual([first, head]); + expect(() => + validateStackSnapshot({ + baseSha: sha('a'), + commits: [head, first], + coverage: 'complete', + headSha: head.sha, + provenance, + }), + ).toThrow(); +}); + +test('one Artifact Run deduplicates overlapping and warm immutable reads', async () => { + const base = sha('a'); + const first = sha('b'); + const head = sha('c'); + const commitCalls: Array> = []; + const blobCalls: Array> = []; + let rangeCalls = 0; + const artifact = (commitSha: GitSha) => ({ + commitSha, + coverage: 'complete' as const, + files: [], + parentSha: commitSha === first ? base : first, + provenance, + }); + const source: ReviewArtifactSource = { + readBlobs: async (objectIds) => { + blobCalls.push(objectIds); + return new Map( + objectIds.map((objectId) => [ + objectId, + { bytes: new Uint8Array([1, 2, 3]), objectId, provenance }, + ]), + ); + }, + readCommitArtifacts: async (commits) => { + commitCalls.push(commits); + await Promise.resolve(); + return new Map( + commits.map(({ commitSha, parentSha }) => { + const request = { commitSha, parentSha }; + return [createCommitArtifactRequestKey(request), { ...artifact(commitSha), parentSha }]; + }), + ); + }, + readStackAndRange: async () => { + rangeCalls += 1; + return { + range: { baseSha: base, coverage: 'complete', files: [], headSha: head, provenance }, + stack: { + baseSha: base, + commits: [commit('b', [base]), commit('c', [first])], + coverage: 'complete', + headSha: head, + provenance, + }, + }; + }, + }; + const run = createReviewArtifactRun(source); + + const [left, right, firstRange, secondRange] = await Promise.all([ + run.readCommitArtifacts( + [ + { commitSha: first, parentSha: base }, + { commitSha: head, parentSha: first }, + ], + run.signal, + ), + run.readCommitArtifacts([{ commitSha: head, parentSha: first }], run.signal), + run.readStackAndRange({ headSha: head, requestedBaseSha: base }, run.signal), + run.readStackAndRange({ headSha: head, requestedBaseSha: base }, run.signal), + ]); + await run.readCommitArtifacts( + [ + { commitSha: first, parentSha: base }, + { commitSha: head, parentSha: first }, + ], + run.signal, + ); + await Promise.all([ + run.readBlobs(['blob-a', 'blob-b'], run.signal), + run.readBlobs(['blob-b'], run.signal), + ]); + await run.readBlobs(['blob-a'], run.signal); + + expect([...left]).toHaveLength(2); + expect([...right]).toHaveLength(1); + expect(left.get(createCommitArtifactRequestKey({ commitSha: first, parentSha: base }))).toEqual( + expect.objectContaining({ commitSha: first, parentSha: base }), + ); + expect(firstRange).toBe(secondRange); + expect(commitCalls).toEqual([ + [ + { commitSha: first, parentSha: base }, + { commitSha: head, parentSha: first }, + ], + ]); + expect(blobCalls).toEqual([['blob-a', 'blob-b']]); + expect(rangeCalls).toBe(1); + expect(run.diagnostics()).toMatchObject({ + acquired: { + blobs: { 'blob-a': 1, 'blob-b': 1 }, + commits: { [`${first}:${base}`]: 1, [`${head}:${first}`]: 1 }, + stackAndRanges: { [`${base}:${head}`]: 1 }, + }, + sourceCalls: { blobs: 1, commits: 1, stackAndRanges: 1 }, + }); +}); + +test('one bulk read preserves the same merge commit relative to two parents', async () => { + const merge = sha('m'); + const firstParent = sha('a'); + const secondParent = sha('b'); + const requests = [ + { commitSha: merge, parentSha: firstParent }, + { commitSha: merge, parentSha: secondParent }, + ]; + const source: ReviewArtifactSource = { + readBlobs: async () => new Map(), + readCommitArtifacts: async (commits) => + new Map( + commits.map((request) => [ + createCommitArtifactRequestKey(request), + { + commitSha: request.commitSha, + coverage: 'complete' as const, + files: [], + parentSha: request.parentSha, + provenance, + }, + ]), + ), + readStackAndRange: async () => { + throw new Error('unused'); + }, + }; + const run = createReviewArtifactRun(source); + const artifacts = await run.readCommitArtifacts(requests, run.signal); + + expect(artifacts).toHaveLength(2); + expect(artifacts.get(createCommitArtifactRequestKey(requests[0]!))?.parentSha).toBe(firstParent); + expect(artifacts.get(createCommitArtifactRequestKey(requests[1]!))?.parentSha).toBe(secondParent); + expect(run.diagnostics().acquired.commits).toEqual({ + [createCommitArtifactRequestKey(requests[0]!)]: 1, + [createCommitArtifactRequestKey(requests[1]!)]: 1, + }); +}); + +test('rejects a Commit Artifact returned under the wrong request coordinate', async () => { + const request = { commitSha: sha('c'), parentSha: sha('a') }; + const source: ReviewArtifactSource = { + readBlobs: async () => new Map(), + readCommitArtifacts: async () => + new Map([ + [ + createCommitArtifactRequestKey(request), + { + commitSha: request.commitSha, + coverage: 'complete' as const, + files: [], + parentSha: sha('b'), + provenance, + }, + ], + ]), + readStackAndRange: async () => { + throw new Error('unused'); + }, + }; + const run = createReviewArtifactRun(source); + + await expect(run.readCommitArtifacts([request], run.signal)).rejects.toThrow( + 'returned different coordinates', + ); +}); + +test('uses the canonical root request key for root Commit Artifacts', async () => { + const request = { commitSha: sha('r'), parentSha: null }; + const key = createCommitArtifactRequestKey(request); + const source: ReviewArtifactSource = { + readBlobs: async () => new Map(), + readCommitArtifacts: async () => + new Map([ + [ + key, + { + commitSha: request.commitSha, + coverage: 'complete' as const, + files: [], + parentSha: null, + provenance, + }, + ], + ]), + readStackAndRange: async () => { + throw new Error('unused'); + }, + }; + const run = createReviewArtifactRun(source); + + expect((await run.readCommitArtifacts([request], run.signal)).get(key)?.parentSha).toBeNull(); + expect(key.endsWith(':root')).toBe(true); +}); + +test('artifact range results distinguish requested and effective bases without sharing request-local cache entries', async () => { + const requestedBase = sha('a'); + const equivalentSelector = sha('b'); + const effectiveBase = sha('c'); + const head = sha('d'); + const requests: Array = []; + const source: ReviewArtifactSource = { + readBlobs: async () => new Map(), + readCommitArtifacts: async () => new Map(), + readStackAndRange: async (request) => { + requests.push(request); + return { + range: { + baseSha: effectiveBase, + coverage: 'complete', + files: [], + headSha: request.headSha, + provenance, + }, + stack: { + baseSha: effectiveBase, + commits: [commit('d', [effectiveBase])], + coverage: 'complete', + headSha: request.headSha, + provenance, + }, + }; + }, + }; + const run = createReviewArtifactRun(source); + + const first = await run.readStackAndRange( + { headSha: head, requestedBaseSha: requestedBase }, + run.signal, + ); + expect( + await run.readStackAndRange({ headSha: head, requestedBaseSha: requestedBase }, run.signal), + ).toBe(first); + await run.readStackAndRange({ headSha: head, requestedBaseSha: equivalentSelector }, run.signal); + + expect(first.range.baseSha).toBe(effectiveBase); + expect(first.stack.baseSha).toBe(effectiveBase); + expect(requests).toEqual([ + { headSha: head, requestedBaseSha: requestedBase }, + { headSha: head, requestedBaseSha: equivalentSelector }, + ]); + expect(run.diagnostics().acquired.stackAndRanges).toEqual({ + [`${equivalentSelector}:${head}`]: 1, + [`${requestedBase}:${head}`]: 1, + }); +}); + +test('artifact range results reject mismatched endpoints, substituted heads, and provenance', () => { + const requestedBase = sha('a'); + const effectiveBase = sha('b'); + const head = sha('c'); + const result = () => ({ + range: { + baseSha: effectiveBase, + coverage: 'complete' as const, + files: [], + headSha: head, + provenance, + }, + stack: { + baseSha: effectiveBase, + commits: [commit('c', [effectiveBase])], + coverage: 'complete' as const, + headSha: head, + provenance, + }, + }); + const request = { headSha: head, requestedBaseSha: requestedBase }; + + expect(validateReviewArtifactRangeResult(request, result()).range.baseSha).toBe(effectiveBase); + expect(() => + validateReviewArtifactRangeResult(request, { + ...result(), + stack: { ...result().stack, baseSha: requestedBase }, + }), + ).toThrow('different endpoints'); + expect(() => + validateReviewArtifactRangeResult(request, { + ...result(), + range: { ...result().range, headSha: requestedBase }, + stack: { + ...result().stack, + commits: [commit('a', [effectiveBase])], + headSha: requestedBase, + }, + }), + ).toThrow('substituted a different head'); + expect(() => + validateReviewArtifactRangeResult(request, { + ...result(), + stack: { + ...result().stack, + provenance: { ...provenance, project: { ...project, project: 'other/project' } }, + }, + }), + ).toThrow('different provider projects'); +}); + +test('same-commit artifact requests remain empty same-commit results', () => { + const same = sha('a'); + const request = { headSha: same, requestedBaseSha: same }; + expect(() => + validateReviewArtifactRangeResult(request, { + range: { baseSha: same, coverage: 'complete', files: [], headSha: same, provenance }, + stack: { baseSha: same, commits: [], coverage: 'complete', headSha: same, provenance }, + }), + ).not.toThrow(); + expect(() => + validateReviewArtifactRangeResult(request, { + range: { baseSha: same, coverage: 'complete', files: [], headSha: same, provenance }, + stack: { + baseSha: same, + commits: [commit('a', [])], + coverage: 'complete', + headSha: same, + provenance, + }, + }), + ).toThrow('same-commit artifact request'); +}); + +test('Artifact Runs propagate cancellation to cooperative in-flight commit reads', async () => { + let commitCalls = 0; + const source: ReviewArtifactSource = { + readBlobs: async () => new Map(), + readCommitArtifacts: async (_commits, signal) => { + commitCalls += 1; + await new Promise((resolve, reject) => { + signal.addEventListener( + 'abort', + () => reject(Object.assign(new Error('canceled'), { name: 'AbortError' })), + { once: true }, + ); + if (commitCalls > 1) { + resolve(undefined); + } + }); + return new Map(); + }, + readStackAndRange: async () => { + throw new Error('unused'); + }, + }; + const controller = new AbortController(); + const run = createReviewArtifactRun(source, { signal: controller.signal }); + const pending = run.readCommitArtifacts([{ commitSha: sha('d'), parentSha: null }], run.signal); + controller.abort(); + + await expect(pending).rejects.toMatchObject({ name: 'AbortError' }); + expect(run.signal.aborted).toBe(true); + expect(commitCalls).toBe(1); +}); + +test('Artifact Runs reject signal-ignoring stack and range results resolved after cancellation', async () => { + const base = sha('a'); + const head = sha('b'); + let resolveRange!: ( + value: Awaited>, + ) => void; + const source: ReviewArtifactSource = { + readBlobs: async () => new Map(), + readCommitArtifacts: async () => new Map(), + readStackAndRange: async () => + new Promise((resolve) => { + resolveRange = resolve; + }), + }; + const controller = new AbortController(); + const run = createReviewArtifactRun(source, { signal: controller.signal }); + const pending = run.readStackAndRange({ headSha: head, requestedBaseSha: base }, run.signal); + controller.abort(); + resolveRange({ + range: { baseSha: base, coverage: 'complete', files: [], headSha: head, provenance }, + stack: { + baseSha: base, + commits: [commit('b', [base])], + coverage: 'complete', + headSha: head, + provenance, + }, + }); + + await expect(pending).rejects.toMatchObject({ name: 'AbortError' }); + expect(run.signal.aborted).toBe(true); + expect(run.diagnostics()).toMatchObject({ + acquired: { stackAndRanges: { [`${base}:${head}`]: 1 } }, + sourceCalls: { stackAndRanges: 1 }, + }); +}); + +test('Artifact Runs deduplicate path-resolved Blob Artifacts and reuse their object IDs', async () => { + const request = { maxBytes: 16, path: 'src/app.ts', ref: sha('a') }; + const objectId = sha('f'); + let fileCalls = 0; + let objectCalls = 0; + const source: ReviewArtifactSource = { + readBlobs: async () => { + objectCalls += 1; + return new Map(); + }, + readCommitArtifacts: async () => new Map(), + readFileBlobs: async (requests) => { + fileCalls += 1; + return new Map( + requests.map((value) => [ + createFileBlobArtifactRequestKey(value), + { bytes: new TextEncoder().encode('artifact'), objectId, provenance }, + ]), + ); + }, + readStackAndRange: async () => { + throw new Error('unused'); + }, + }; + const run = createReviewArtifactRun(source); + + const [first, concurrent] = await Promise.all([ + run.readFileBlobs([request], run.signal), + run.readFileBlobs([request], run.signal), + ]); + const warm = await run.readFileBlobs([request], run.signal); + const byObjectId = await run.readBlobs([objectId], run.signal); + + expect(first.get(createFileBlobArtifactRequestKey(request))?.objectId).toBe(objectId); + expect(concurrent).toEqual(first); + expect(warm).toEqual(first); + expect(byObjectId.get(objectId)?.objectId).toBe(objectId); + expect(fileCalls).toBe(1); + expect(objectCalls).toBe(0); + expect(run.diagnostics().acquired.blobs[`file:${request.ref}:${request.path}`]).toBe(1); +}); + +test('Artifact Runs do not reacquire an explicit source miss', async () => { + let commitCalls = 0; + const source: ReviewArtifactSource = { + readBlobs: async () => new Map(), + readCommitArtifacts: async () => { + commitCalls += 1; + return new Map(); + }, + readStackAndRange: async () => { + throw new Error('unused'); + }, + }; + const run = createReviewArtifactRun(source); + const missing = sha('e'); + + expect( + await run.readCommitArtifacts([{ commitSha: missing, parentSha: null }], run.signal), + ).toHaveLength(0); + expect( + await run.readCommitArtifacts([{ commitSha: missing, parentSha: null }], run.signal), + ).toHaveLength(0); + expect(commitCalls).toBe(1); + expect(run.diagnostics().acquired.commits[`${missing}:root`]).toBe(1); +}); diff --git a/core/__tests__/review-command-target.test.ts b/core/__tests__/review-command-target.test.ts index 7664424e..3a38c719 100644 --- a/core/__tests__/review-command-target.test.ts +++ b/core/__tests__/review-command-target.test.ts @@ -3,7 +3,7 @@ import { createReviewCommandTarget, resolveReviewCommandTarget, } from '../lib/review-command-target.ts'; -import type { ChangedFile, ReviewSource } from '../types.ts'; +import type { ChangedFile, GitSha, ResolvedReviewSource, ReviewSource } from '../types.ts'; const file = (path: string): ChangedFile => ({ fingerprint: `${path}:1`, @@ -58,7 +58,10 @@ test('review command target falls back to selected path outside active walkthrou test('review command target ignores stale active target from another source', () => { const selectedFile = file('src/first.ts'); - const currentSource = { ref: 'HEAD', type: 'commit' } satisfies ReviewSource; + const currentSource = { + sha: 'HEAD' as GitSha, + type: 'commit', + } satisfies ResolvedReviewSource; const staleSource = { type: 'working-tree' } satisfies ReviewSource; const target = resolveReviewCommandTarget({ diff --git a/core/__tests__/review-comment-hooks.test.tsx b/core/__tests__/review-comment-hooks.test.tsx index 31b37178..096987fb 100644 --- a/core/__tests__/review-comment-hooks.test.tsx +++ b/core/__tests__/review-comment-hooks.test.tsx @@ -5,17 +5,18 @@ import { act, useState } from 'react'; import { expect, test, vi } from 'vite-plus/test'; import { useReviewCommentDrafts } from '../app/hooks/useReviewCommentDrafts.ts'; -import type { ReviewComment } from '../lib/app-types.ts'; +import type { LocalReviewNote, ReviewComment } from '../lib/app-types.ts'; import { renderReact } from './helpers/react.tsx'; type ReviewCommentDrafts = ReturnType & { comments: ReadonlyArray; }; -const createComment = (id: string, overrides: Partial = {}): ReviewComment => ({ +const createComment = (id: string, overrides: Partial = {}): LocalReviewNote => ({ body: '', filePath: 'src/app.ts', id, + kind: 'local-note', lineNumber: 1, sectionId: 'src/app.ts:unstaged', side: 'additions', @@ -37,6 +38,7 @@ function ReviewCommentDraftsHarness({ const state = useReviewCommentDrafts({ canCreateComment, comments, + draftKind: 'local-note', onCommentFileChange, setComments, }); @@ -86,7 +88,7 @@ test('review comment drafts create, update, focus, and delete local comments', a }, }; const comment = createComment('ignored'); - const { body: _body, id: _id, ...location } = comment; + const { body: _body, id: _id, kind: _kind, ...location } = comment; await act(async () => { getState().createComment(location); }); @@ -95,6 +97,7 @@ test('review comment drafts create, update, focus, and delete local comments', a ...location, body: '', id: '00000000-0000-4000-8000-000000000001', + kind: 'local-note', }, ]); expect(getState().focusCommentId).toBe('00000000-0000-4000-8000-000000000001'); @@ -127,7 +130,7 @@ test('review comment drafts focus an existing empty comment at the same location }); const { getState, onCommentFileChange } = view; - const { body: _body, id: _id, ...location } = existing; + const { body: _body, id: _id, kind: _kind, ...location } = existing; await act(async () => { getState().createComment(location); }); @@ -166,7 +169,7 @@ test('review comment drafts preserve active text when reusing another empty draf filePath: 'src/new.ts', sectionId: 'src/new.ts:unstaged', }); - const { body: _body, id: _id, ...location } = next; + const { body: _body, id: _id, kind: _kind, ...location } = next; await act(async () => { getState().createComment(location); }); @@ -175,6 +178,7 @@ test('review comment drafts preserve active text when reusing another empty draf ...location, body: '', id: '00000000-0000-4000-8000-000000000002', + kind: 'local-note', }); expect(onCommentFileChange).toHaveBeenNthCalledWith(1, 'src/old.ts'); expect(onCommentFileChange).toHaveBeenNthCalledWith(2, 'src/new.ts'); @@ -187,7 +191,7 @@ test('review comment drafts can disable comment creation', async () => { const { getState, onCommentFileChange } = view; const comment = createComment('ignored'); - const { body: _body, id: _id, ...location } = comment; + const { body: _body, id: _id, kind: _kind, ...location } = comment; await act(async () => { getState().createComment(location); }); diff --git a/core/__tests__/review-comment-target.test.ts b/core/__tests__/review-comment-target.test.ts new file mode 100644 index 00000000..569bfd7f --- /dev/null +++ b/core/__tests__/review-comment-target.test.ts @@ -0,0 +1,182 @@ +import { expect, test } from 'vite-plus/test'; +import { + resolveProviderCommentTarget, + resolveShareCommentTarget, +} from '../lib/review-comment-target.ts'; +import type { ChangedFile, DiffRange, GitSha } from '../types.ts'; + +const range = (base: string, head: string): DiffRange => ({ + base: { + label: { kind: 'commit', text: base.slice(0, 7) }, + sha: base as GitSha, + }, + head: { + label: { kind: 'commit', text: head.slice(0, 7) }, + sha: head as GitSha, + }, +}); + +const file = (target: DiffRange): ChangedFile => ({ + fingerprint: 'src/a.ts', + path: 'src/a.ts', + sections: [ + { + binary: false, + id: 'src/a.ts:target', + kind: 'pull-request', + patch: + 'diff --git a/src/a.ts b/src/a.ts\n--- a/src/a.ts\n+++ b/src/a.ts\n@@ -1 +1 @@\n-old\n+new\n', + range: target, + }, + ], + status: 'modified', +}); + +test('enables an exact current-review line target', () => { + const target = range('a'.repeat(40), 'b'.repeat(40)); + const displayed = file(target); + expect( + resolveProviderCommentTarget({ + canonicalFiles: [displayed], + file: displayed, + lineNumber: 1, + section: displayed.sections[0], + showWhitespace: false, + side: 'additions', + }), + ).toEqual({ position: { range: target }, status: 'enabled' }); +}); + +test('fails closed when the selected file is absent from the target diff', () => { + const target = range('a'.repeat(40), 'b'.repeat(40)); + const displayed = file(target); + expect( + resolveProviderCommentTarget({ + anchor: 'file', + canonicalFiles: [], + file: displayed, + section: displayed.sections[0], + showWhitespace: false, + }), + ).toEqual({ reason: 'file-not-in-target', status: 'read-only' }); +}); + +test('fails closed when the selected section has no immutable range', () => { + const target = range('a'.repeat(40), 'b'.repeat(40)); + const displayed = file(target); + expect( + resolveProviderCommentTarget({ + anchor: 'file', + canonicalFiles: [displayed], + file: displayed, + section: { ...displayed.sections[0]!, range: undefined }, + showWhitespace: false, + }), + ).toEqual({ reason: 'missing-target-range', status: 'read-only' }); +}); + +test('fails closed when an inline anchor is absent from the canonical target', () => { + const target = range('a'.repeat(40), 'b'.repeat(40)); + const displayed = file(target); + expect( + resolveProviderCommentTarget({ + canonicalFiles: [displayed], + file: displayed, + lineNumber: 99, + section: displayed.sections[0], + showWhitespace: false, + side: 'additions', + }), + ).toEqual({ reason: 'anchor-not-in-target', status: 'read-only' }); +}); + +test('validates cross-side range endpoints against their own sides', () => { + const target = range('a'.repeat(40), 'b'.repeat(40)); + const displayed = file(target); + expect( + resolveProviderCommentTarget({ + canonicalFiles: [displayed], + file: displayed, + lineNumber: 1, + section: displayed.sections[0], + showWhitespace: false, + side: 'additions', + startLineNumber: 1, + startSide: 'deletions', + }), + ).toEqual({ position: { range: target }, status: 'enabled' }); +}); + +test('fails closed when active files contain ambiguous range evidence', () => { + const target = range('a'.repeat(40), 'b'.repeat(40)); + const displayed = file(target); + expect( + resolveProviderCommentTarget({ + anchor: 'file', + canonicalFiles: [displayed, { ...displayed, fingerprint: 'duplicate' }], + file: displayed, + section: displayed.sections[0], + showWhitespace: false, + }), + ).toEqual({ reason: 'ambiguous-target-range', status: 'read-only' }); +}); + +test('falls back to a legacy section ID for an unpositioned shared snapshot', () => { + const target = range('a'.repeat(40), 'b'.repeat(40)); + const displayed = file(target); + const section = { ...displayed.sections[0]!, range: undefined }; + const legacyFile = { ...displayed, sections: [section] }; + + expect( + resolveShareCommentTarget({ + anchor: 'file', + displayedFiles: [legacyFile], + file: legacyFile, + section, + showWhitespace: false, + }), + ).toEqual({ sectionId: section.id, status: 'enabled' }); +}); + +test('accepts working-tree pseudo-revisions for shared snapshot positions', () => { + const target: DiffRange = { + base: { kind: 'index', label: { kind: 'review-marker', text: 'Index' } }, + head: { + kind: 'working-copy', + label: { kind: 'review-marker', text: 'Working copy' }, + }, + }; + const displayed = file(target); + + expect( + resolveShareCommentTarget({ + displayedFiles: [displayed], + file: displayed, + lineNumber: 1, + section: displayed.sections[0], + showWhitespace: false, + side: 'additions', + }), + ).toEqual({ position: { range: target }, status: 'enabled' }); +}); + +test('rejects pseudo-revisions at the provider target boundary', () => { + const target: DiffRange = { + base: { kind: 'index', label: { kind: 'review-marker', text: 'Index' } }, + head: { + kind: 'working-copy', + label: { kind: 'review-marker', text: 'Working copy' }, + }, + }; + const displayed = file(target); + + expect( + resolveProviderCommentTarget({ + anchor: 'file', + canonicalFiles: [displayed], + file: displayed, + section: displayed.sections[0], + showWhitespace: false, + }), + ).toEqual({ reason: 'non-commit-target', status: 'read-only' }); +}); diff --git a/core/__tests__/review-comments.test.ts b/core/__tests__/review-comments.test.ts index dcdfb5af..331cf46c 100644 --- a/core/__tests__/review-comments.test.ts +++ b/core/__tests__/review-comments.test.ts @@ -1,26 +1,73 @@ import { expect, test } from 'vite-plus/test'; -import type { ReviewComment } from '../lib/app-types.ts'; +import type { + ProviderCommentDraft, + ProviderInlineComment, + ShareCommentDraft, +} from '../lib/app-types.ts'; import { findReusableReviewCommentDraft, getPendingPullRequestReviewComments, getReviewCommentsFromState, getVisibleReviewComments, mergeReviewComments, - toSubmittedReviewComment, - toPullRequestReviewComment, + resolveReviewCommentSection, + toProviderCommentSubmission, + toProviderSubmittedReviewComment, + toPullRequestExistingReviewComment, + toRenderedSubmittedReviewComment, + toShareCommentSubmission, } from '../lib/review-comments.ts'; -import type { RepositoryState } from '../types.ts'; +import type { GitSha, RepositoryState } from '../types.ts'; -const createReviewComment = (overrides: Partial): ReviewComment => ({ +const providerPosition = { + range: { + base: { label: { kind: 'commit' as const, text: 'base' }, sha: 'a'.repeat(40) as GitSha }, + head: { label: { kind: 'commit' as const, text: 'head' }, sha: 'b'.repeat(40) as GitSha }, + }, +}; + +const createProviderDraft = ( + overrides: Partial = {}, +): ProviderCommentDraft => ({ body: 'A comment.', filePath: 'src/a.ts', id: 'github:1', + kind: 'provider-draft', lineNumber: 5, + position: providerPosition, sectionId: 'src/a.ts:pull-request:1', side: 'additions', ...overrides, }); +const createShareDraft = (overrides: Partial = {}): ShareCommentDraft => ({ + body: 'A shared comment.', + filePath: 'src/a.ts', + id: 'share:1', + kind: 'share-draft', + lineNumber: 5, + sectionId: 'src/a.ts:unstaged', + side: 'additions', + ...overrides, +}); + +const createProviderComment = ( + overrides: Partial = {}, +): ProviderInlineComment => ({ + author: { login: 'reviewer' }, + body: 'A provider comment.', + destination: 'provider', + filePath: 'src/a.ts', + id: 'github:remote', + isReadOnly: true, + kind: 'submitted-comment', + lineNumber: 5, + position: providerPosition, + resolvedSectionId: 'src/a.ts:pull-request:1', + side: 'additions', + ...overrides, +}); + const createPullRequestState = (): RepositoryState => ({ branch: null, files: [ @@ -32,7 +79,9 @@ const createPullRequestState = (): RepositoryState => ({ binary: false, id: 'src/a.ts:pull-request:1', kind: 'pull-request', - patch: '', + patch: + 'diff --git a/src/a.ts b/src/a.ts\n@@ -1,6 +1,6 @@\n one\n two\n three\n four\n five\n six\n', + range: providerPosition.range, }, ], status: 'modified', @@ -48,6 +97,7 @@ const createPullRequestState = (): RepositoryState => ({ id: 'github:1', isOutdated: true, lineNumber: 5, + position: providerPosition, side: 'additions', }, { @@ -56,6 +106,7 @@ const createPullRequestState = (): RepositoryState => ({ filePath: 'src/a.ts', id: 'github:2', lineNumber: 6, + position: providerPosition, side: 'additions', }, ], @@ -70,11 +121,57 @@ test('getReviewCommentsFromState carries the outdated flag through to review com const comments = getReviewCommentsFromState(createPullRequestState()); expect(comments).toHaveLength(2); + expect( + comments.every( + (comment) => comment.kind === 'submitted-comment' && comment.destination === 'provider', + ), + ).toBe(true); expect(comments.find((comment) => comment.id === 'github:1')?.isOutdated).toBe(true); expect(comments.find((comment) => comment.id === 'github:2')?.isOutdated).toBeUndefined(); }); -test('getReviewCommentsFromState hydrates shared comments on their exact working-tree section', () => { +test('retains unresolved provider comments with their original persisted coordinates', () => { + const state = createPullRequestState(); + state.reviewComments = [ + { + author: { login: 'reviewer' }, + body: 'The original line is no longer present.', + canDelete: true, + canEdit: true, + filePath: 'src/a.ts', + id: 'github:unresolved', + isOutdated: true, + lineNumber: 999, + position: providerPosition, + side: 'additions', + submittedAt: '2026-08-01T00:00:00.000Z', + threadId: 'thread-unresolved', + url: 'https://github.com/example/repo/pull/1#discussion_r1', + }, + ]; + + const comments = getReviewCommentsFromState(state); + expect(comments).toEqual([ + expect.objectContaining({ + body: 'The original line is no longer present.', + canDelete: true, + canEdit: true, + destination: 'provider', + filePath: 'src/a.ts', + id: 'github:unresolved', + isOutdated: true, + lineNumber: 999, + position: providerPosition, + threadId: 'thread-unresolved', + url: 'https://github.com/example/repo/pull/1#discussion_r1', + }), + ]); + expect(comments[0]).not.toHaveProperty('resolvedSectionId'); + expect(getVisibleReviewComments(comments, false)).toEqual([]); + expect(getVisibleReviewComments(comments, true)).toEqual(comments); +}); + +test('getReviewCommentsFromState derives a working-tree section from line coordinates', () => { const state = createPullRequestState(); state.source = { type: 'working-tree' }; state.files = [ @@ -85,13 +182,15 @@ test('getReviewCommentsFromState hydrates shared comments on their exact working binary: false, id: 'src/a.ts:staged', kind: 'staged', - patch: '', + patch: + 'diff --git a/src/a.ts b/src/a.ts\n--- a/src/a.ts\n+++ b/src/a.ts\n@@ -1 +1 @@\n-old\n+new', }, { binary: false, id: 'src/a.ts:unstaged', kind: 'unstaged', - patch: '', + patch: + 'diff --git a/src/a.ts b/src/a.ts\n--- a/src/a.ts\n+++ b/src/a.ts\n@@ -5 +5 @@\n-old\n+new', }, ], }, @@ -108,20 +207,90 @@ test('getReviewCommentsFromState hydrates shared comments on their exact working }, ]; - expect(getReviewCommentsFromState(state)).toEqual([ + expect(getReviewCommentsFromState(state, 'share')).toEqual([ expect.objectContaining({ body: 'Persist this shared walkthrough comment.', + destination: 'share', id: 'shared:1', isReadOnly: true, + resolvedSectionId: 'src/a.ts:unstaged', sectionId: 'src/a.ts:unstaged', }), ]); }); +test('getReviewCommentsFromState prefers a persisted range over matching line coordinates', () => { + const state = createPullRequestState(); + state.source = { type: 'working-tree' }; + const head = { + label: { kind: 'commit' as const, text: 'current-head' }, + sha: 'a'.repeat(40) as GitSha, + }; + const index = { + kind: 'index' as const, + label: { kind: 'review-marker' as const, text: 'Index' }, + }; + const workingCopy = { + kind: 'working-copy' as const, + label: { kind: 'review-marker' as const, text: 'Working copy' }, + }; + state.files = [ + { + ...state.files[0]!, + sections: [ + { + binary: false, + id: 'src/a.ts:staged', + kind: 'staged', + patch: + 'diff --git a/src/a.ts b/src/a.ts\n--- a/src/a.ts\n+++ b/src/a.ts\n@@ -5 +5 @@\n-old\n+staged', + range: { base: head, head: index }, + }, + { + binary: false, + id: 'src/a.ts:unstaged', + kind: 'unstaged', + patch: + 'diff --git a/src/a.ts b/src/a.ts\n--- a/src/a.ts\n+++ b/src/a.ts\n@@ -5 +5 @@\n-staged\n+unstaged', + range: { base: index, head: workingCopy }, + }, + ], + }, + ]; + const persistedIndex = { + ...index, + label: { kind: 'review-marker' as const, text: 'Persisted index label' }, + }; + const persistedWorkingCopy = { + ...workingCopy, + label: { kind: 'review-marker' as const, text: 'Persisted working copy label' }, + }; + state.reviewComments = [ + { + author: { login: 'reviewer' }, + body: 'Keep this unstaged.', + filePath: 'src/a.ts', + id: 'shared:range', + lineNumber: 5, + position: { range: { base: persistedIndex, head: persistedWorkingCopy } }, + side: 'additions', + }, + ]; + + expect(getReviewCommentsFromState(state, 'share')).toEqual([ + expect.objectContaining({ + destination: 'share', + id: 'shared:range', + position: { range: { base: persistedIndex, head: persistedWorkingCopy } }, + resolvedSectionId: 'src/a.ts:unstaged', + }), + ]); +}); + test('getPendingPullRequestReviewComments includes an unflushed active draft', () => { const comments = [ - createReviewComment({ body: '', id: 'draft' }), - createReviewComment({ body: 'Already flushed.', id: 'ready', lineNumber: 6 }), + createProviderDraft({ body: '', id: 'draft' }), + createProviderDraft({ body: 'Already flushed.', id: 'ready', lineNumber: 6 }), ]; expect( @@ -136,7 +305,7 @@ test('getPendingPullRequestReviewComments includes an unflushed active draft', ( }); test('getPendingPullRequestReviewComments replaces a stale flushed draft', () => { - const comments = [createReviewComment({ body: 'Old text.', id: 'draft' })]; + const comments = [createProviderDraft({ body: 'Old text.', id: 'draft' })]; expect( getPendingPullRequestReviewComments(comments, { @@ -147,7 +316,7 @@ test('getPendingPullRequestReviewComments replaces a stale flushed draft', () => }); test('getPendingPullRequestReviewComments respects an emptied active draft', () => { - const comments = [createReviewComment({ body: 'Old text.', id: 'draft' })]; + const comments = [createProviderDraft({ body: 'Old text.', id: 'draft' })]; expect( getPendingPullRequestReviewComments(comments, { @@ -167,7 +336,7 @@ test('getPendingPullRequestReviewComments ignores drafts outside the current rev }); test('getPendingPullRequestReviewComments excludes comments being submitted individually', () => { - const comment = createReviewComment({ body: 'Already submitting.', id: 'draft' }); + const comment = createProviderDraft({ body: 'Already submitting.', id: 'draft' }); expect( getPendingPullRequestReviewComments([{ ...comment, remoteSubmit: { status: 'submitting' } }]), @@ -175,8 +344,8 @@ test('getPendingPullRequestReviewComments excludes comments being submitted indi }); test('findReusableReviewCommentDraft preserves an active draft with unflushed content', () => { - const activeDraft = createReviewComment({ body: '', id: 'active' }); - const reusableDraft = createReviewComment({ body: '', id: 'reusable', lineNumber: 6 }); + const activeDraft = createProviderDraft({ body: '', id: 'active' }); + const reusableDraft = createProviderDraft({ body: '', id: 'reusable', lineNumber: 6 }); expect( findReusableReviewCommentDraft([activeDraft, reusableDraft], { @@ -187,7 +356,7 @@ test('findReusableReviewCommentDraft preserves an active draft with unflushed co }); test('findReusableReviewCommentDraft returns no draft when the only empty draft has content', () => { - const activeDraft = createReviewComment({ body: '', id: 'active' }); + const activeDraft = createProviderDraft({ body: '', id: 'active' }); expect( findReusableReviewCommentDraft([activeDraft], { @@ -197,12 +366,11 @@ test('findReusableReviewCommentDraft returns no draft when the only empty draft ).toBeUndefined(); }); -test('findReusableReviewCommentDraft skips read-only drafts and reuses whitespace-only drafts', () => { - const readOnlyDraft = createReviewComment({ body: '', id: 'readonly', isReadOnly: true }); - const activeDraft = createReviewComment({ body: '', id: 'active' }); +test('findReusableReviewCommentDraft reuses whitespace-only provider drafts', () => { + const activeDraft = createProviderDraft({ body: '', id: 'active' }); expect( - findReusableReviewCommentDraft([readOnlyDraft, activeDraft], { + findReusableReviewCommentDraft([activeDraft], { body: ' ', id: activeDraft.id, }), @@ -254,6 +422,7 @@ test('getReviewCommentsFromState preserves file-level GitLab anchors', () => { body: 'Review the file as a whole.', filePath: 'src/a.ts', id: 'gitlab:file', + position: providerPosition, }, ]; @@ -264,15 +433,16 @@ test('getReviewCommentsFromState preserves file-level GitLab anchors', () => { filePath: 'src/a.ts', id: 'gitlab:file', isReadOnly: true, - sectionId: 'src/a.ts:pull-request:1', + position: providerPosition, + resolvedSectionId: 'src/a.ts:pull-request:1', }), ]); }); test('getVisibleReviewComments hides outdated comments unless they are shown', () => { const comments = [ - createReviewComment({ id: 'github:1', isOutdated: true }), - createReviewComment({ id: 'github:2' }), + createProviderComment({ id: 'github:1', isOutdated: true }), + createProviderComment({ id: 'github:2' }), ]; expect(getVisibleReviewComments(comments, false).map((comment) => comment.id)).toEqual([ @@ -285,63 +455,204 @@ test('getVisibleReviewComments hides outdated comments unless they are shown', ( }); test('getVisibleReviewComments keeps user-authored comments that are never outdated', () => { - const comments = [createReviewComment({ id: 'draft', isReadOnly: false })]; + const comments = [createProviderDraft({ id: 'draft' })]; expect(getVisibleReviewComments(comments, false)).toHaveLength(1); }); -test('serializes file-level thread replies without inventing line metadata', () => { - expect( - toPullRequestReviewComment( - createReviewComment({ - anchor: 'file', - body: 'Reply in the existing discussion.', - lineNumber: undefined, - side: undefined, - threadId: 'discussion-1', - }), - ), - ).toEqual({ +test('serializes provider replies without requiring position metadata', () => { + const submission = toProviderCommentSubmission( + createProviderDraft({ + anchor: 'file', + body: 'Reply in the existing discussion.', + lineNumber: undefined, + position: undefined, + side: undefined, + threadId: 'discussion-1', + }), + ); + + expect(submission).toEqual({ anchor: 'file', body: 'Reply in the existing discussion.', filePath: 'src/a.ts', + localDraftId: 'github:1', threadId: 'discussion-1', }); + expect( + toProviderSubmittedReviewComment( + { + author: { login: 'reviewer' }, + body: submission.body, + filePath: submission.filePath, + id: 'github:reply', + threadId: submission.threadId, + }, + submission, + ), + ).not.toHaveProperty('position'); }); -test('serializes section identity only for shared walkthrough comments', () => { - const comment = createReviewComment({ body: 'Persist this comment.' }); +test('omits UI-only section identity but preserves exact provider positions', () => { + const position = { + range: { + base: { label: { kind: 'commit' as const, text: 'a' }, sha: 'a'.repeat(40) as GitSha }, + head: { label: { kind: 'commit' as const, text: 'b' }, sha: 'b'.repeat(40) as GitSha }, + }, + }; + const comment = createProviderDraft({ body: 'Persist this comment.', position }); - expect(toPullRequestReviewComment(comment)).not.toHaveProperty('sectionId'); - expect(toPullRequestReviewComment(comment, { includeSectionId: true })).toMatchObject({ - sectionId: 'src/a.ts:pull-request:1', + expect(toProviderCommentSubmission(comment)).not.toHaveProperty('sectionId'); + expect(toProviderCommentSubmission(comment)).toMatchObject({ + localDraftId: comment.id, + position, + }); +}); + +test('rejects provider submissions that contain pseudo-revisions', () => { + const comment = createProviderDraft(); + const invalid = { + ...comment, + position: { + range: { + base: { kind: 'index' as const, label: { kind: 'review-marker' as const, text: 'Index' } }, + head: { + kind: 'working-copy' as const, + label: { kind: 'review-marker' as const, text: 'Working copy' }, + }, + }, + }, + } as unknown as ProviderCommentDraft; + + expect(() => toProviderCommentSubmission(invalid)).toThrow( + 'Provider comments require an exact immutable commit position.', + ); +}); + +test('reports ambiguous persisted ranges instead of selecting an arbitrary section', () => { + const state = createPullRequestState(); + const position = { + range: { + base: { label: { kind: 'commit' as const, text: 'a' }, sha: 'a'.repeat(40) as GitSha }, + head: { label: { kind: 'commit' as const, text: 'b' }, sha: 'b'.repeat(40) as GitSha }, + }, + }; + state.files[0]!.sections = [ + { ...state.files[0]!.sections[0]!, id: 'first', range: position.range }, + { ...state.files[0]!.sections[0]!, id: 'second', range: position.range }, + ]; + const comment = { ...state.reviewComments![0]!, position }; + + expect(resolveReviewCommentSection(state.files[0]!, comment, false)).toEqual({ + candidateSectionIds: ['first', 'second'], + kind: 'ambiguous', + strategy: 'position', }); }); -test('keeps a submitted shared comment visible until the matching snapshot comment arrives', () => { - const draft = createReviewComment({ +test('uses durable positions and preserves section IDs only for legacy shared comments', () => { + const legacy = createShareDraft({ sectionId: 'src/a.ts:unstaged' }); + expect(toShareCommentSubmission(legacy)).toMatchObject({ + sectionId: 'src/a.ts:unstaged', + }); + + const positioned = { + ...legacy, + position: { + range: { + base: { label: { kind: 'commit' as const, text: 'a' }, sha: 'a'.repeat(40) as GitSha }, + head: { label: { kind: 'commit' as const, text: 'b' }, sha: 'b'.repeat(40) as GitSha }, + }, + }, + }; + expect(toShareCommentSubmission(positioned)).toMatchObject({ + position: positioned.position, + }); + expect(toShareCommentSubmission(positioned)).not.toHaveProperty('sectionId'); +}); + +test('accepts index and working-copy revisions in shared comment positions', () => { + const position = { + range: { + base: { kind: 'index' as const, label: { kind: 'review-marker' as const, text: 'Index' } }, + head: { + kind: 'working-copy' as const, + label: { kind: 'review-marker' as const, text: 'Working copy' }, + }, + }, + }; + + expect(toShareCommentSubmission(createShareDraft({ position }))).toMatchObject({ position }); +}); + +test('converts submitted drafts into provider comments with durable positions', () => { + const position = { + range: { + base: { label: { kind: 'commit' as const, text: 'a' }, sha: 'a'.repeat(40) as GitSha }, + head: { label: { kind: 'commit' as const, text: 'b' }, sha: 'b'.repeat(40) as GitSha }, + }, + }; + const draft = createProviderDraft({ id: 'draft-comment', + position, remoteSubmit: { status: 'submitting' }, }); - const submitted = toSubmittedReviewComment( - { - author: { login: 'ada', name: 'Ada Lovelace' }, - body: draft.body, - canDelete: true, - canEdit: true, - filePath: draft.filePath, - id: 'persisted-comment', - lineNumber: draft.lineNumber, - sectionId: draft.sectionId, - side: draft.side, - submittedAt: '2026-07-16T12:00:00.000Z', - threadId: 'persisted-thread', - }, + const submission = toProviderCommentSubmission(draft); + const submitted = toRenderedSubmittedReviewComment( + toProviderSubmittedReviewComment( + { + author: { login: 'ada', name: 'Ada Lovelace' }, + body: draft.body, + canDelete: true, + canEdit: true, + filePath: draft.filePath, + id: 'persisted-comment', + lineNumber: draft.lineNumber, + side: draft.side, + submittedAt: '2026-07-16T12:00:00.000Z', + threadId: 'persisted-thread', + }, + submission, + ), draft, ); - expect(mergeReviewComments([], [submitted])).toEqual([submitted]); + expect(submitted.position).toEqual(position); + expect(submitted.destination).toBe('provider'); + expect(submitted.resolvedSectionId).toBe(draft.sectionId); + expect(mergeReviewComments([submitted], [])).toEqual([submitted]); const snapshotComment = { ...submitted, body: 'Canonical server comment.' }; - expect(mergeReviewComments([snapshotComment], [submitted])).toEqual([snapshotComment]); + expect(mergeReviewComments([snapshotComment], [])).toEqual([snapshotComment]); +}); + +test('round trips provider and share provenance independently', () => { + const providerComment = createProviderComment(); + const providerState = createPullRequestState(); + providerState.reviewComments = [toPullRequestExistingReviewComment(providerComment)]; + expect(getReviewCommentsFromState(providerState)[0]).toMatchObject({ + destination: 'provider', + position: providerPosition, + }); + expect(getReviewCommentsFromState(providerState)[0]).not.toHaveProperty('sectionId'); + + const shareComment = { + author: { login: 'ada' }, + body: 'Legacy shared feedback.', + destination: 'share' as const, + filePath: 'src/a.ts', + id: 'share:submitted', + isReadOnly: true as const, + kind: 'submitted-comment' as const, + lineNumber: 5, + resolvedSectionId: 'src/a.ts:pull-request:1', + sectionId: 'src/a.ts:pull-request:1', + side: 'additions' as const, + }; + const shareState = createPullRequestState(); + shareState.reviewComments = [toPullRequestExistingReviewComment(shareComment)]; + expect(getReviewCommentsFromState(shareState, 'share')[0]).toMatchObject({ + destination: 'share', + sectionId: 'src/a.ts:pull-request:1', + }); }); diff --git a/core/__tests__/review-commit-stack.test.ts b/core/__tests__/review-commit-stack.test.ts new file mode 100644 index 00000000..06ec61a6 --- /dev/null +++ b/core/__tests__/review-commit-stack.test.ts @@ -0,0 +1,48 @@ +import { expect, test } from 'vite-plus/test'; +import { orderReviewCommitStack, validateReviewCommitStack } from '../lib/review-commit-stack.ts'; +import type { GitSha } from '../types.ts'; + +const gitSha = (value: string) => value as GitSha; +type StackCommit = { + authoredAt: string; + parentShas: ReadonlyArray; + sha: GitSha; +}; +const stackCommit = ( + sha: string, + authoredAt: string, + parentShas: ReadonlyArray = [], +): StackCommit => ({ authoredAt, parentShas, sha: gitSha(sha) }); + +test('normalizes reversed linear commit input and validates canonical stacks', () => { + const first = stackCommit('a', '2026-01-01T00:00:00.000Z', [gitSha('base')]); + const second = stackCommit('b', '2026-01-02T00:00:00.000Z', [first.sha]); + const third = stackCommit('c', '2026-01-03T00:00:00.000Z', [second.sha]); + const ordered = orderReviewCommitStack([third, second, first]); + + expect(ordered.map(({ sha }) => sha)).toEqual(['a', 'b', 'c']); + expect(validateReviewCommitStack(ordered)).toBe(ordered); + expect(() => validateReviewCommitStack([third, second, first])).toThrow( + 'not parent-before-child', + ); +}); + +test('orders merge parents before children and parallel roots deterministically', () => { + const earlier = stackCommit('a', '2026-01-01T00:00:00.000Z', [gitSha('external')]); + const later = stackCommit('b', '2026-01-01T00:00:00.000Z', [gitSha('external')]); + const merge = stackCommit('c', '2026-01-02T00:00:00.000Z', [later.sha, earlier.sha]); + + expect(orderReviewCommitStack([merge, later, earlier]).map(({ sha }) => sha)).toEqual([ + 'a', + 'b', + 'c', + ]); +}); + +test('rejects duplicate and cyclic commit graphs', () => { + const duplicate = stackCommit('a', '2026-01-01T00:00:00.000Z'); + expect(() => orderReviewCommitStack([duplicate, { ...duplicate }])).toThrow('duplicate SHA a'); + const first = stackCommit('a', '2026-01-01T00:00:00.000Z', [gitSha('b')]); + const second = stackCommit('b', '2026-01-02T00:00:00.000Z', [gitSha('a')]); + expect(() => orderReviewCommitStack([first, second])).toThrow('contains a cycle'); +}); diff --git a/core/__tests__/review-content.test.ts b/core/__tests__/review-content.test.ts new file mode 100644 index 00000000..d5a11b66 --- /dev/null +++ b/core/__tests__/review-content.test.ts @@ -0,0 +1,219 @@ +import { expect, test, vi } from 'vite-plus/test'; +import { + createReviewContentRun, + decodeImageRevision, + decodeTextRevision, +} from '../lib/review-content.ts'; +import type { + ChangedFile, + GitSha, + ResolvedRevisionBytes, + RevisionContentBatchRequest, +} from '../types.ts'; +import { createChangedFile } from './helpers/fixtures.ts'; + +const gitSha = (character: string) => character.repeat(40) as GitSha; + +const createTransport = () => + vi.fn(async (request: RevisionContentBatchRequest) => ({ + results: request.requests.map((item) => { + const coordinate = + item.revision.kind === 'working-copy' + ? 'working-copy' + : item.revision.kind === 'index' + ? `index:${item.revision.stage ?? 0}` + : `commit:${item.revision.sha[0]}`; + const bytes = new TextEncoder().encode(`${coordinate}:${item.path}`); + return { + key: item.key, + status: 'ready' as const, + value: { + bytes, + cacheKey: item.key, + path: item.path, + provenance: 'native-git' as const, + size: bytes.byteLength, + }, + }; + }), + })); + +test('resolves commit, index, and working-copy ranges without source-kind dispatch', async () => { + const transport = createTransport(); + const run = createReviewContentRun({ + generation: 'generation-1', + source: { type: 'working-tree' }, + transport, + }); + const staged = createChangedFile('src/staged.ts', { kind: 'staged' }); + const unstaged = createChangedFile('src/unstaged.ts'); + + const [stagedFiles, unstagedFiles] = await run.resolveSectionContentsBatch([ + { file: staged, section: staged.sections[0]! }, + { file: unstaged, section: unstaged.sections[0]! }, + ]); + + expect(stagedFiles.oldFile?.contents).toBe('commit:a:src/staged.ts'); + expect(stagedFiles.newFile.contents).toBe('index:0:src/staged.ts'); + expect(unstagedFiles.oldFile?.contents).toBe('index:0:src/unstaged.ts'); + expect(unstagedFiles.newFile.contents).toBe('working-copy:src/unstaged.ts'); + expect(transport).toHaveBeenCalledOnce(); + expect(transport.mock.calls[0]![0].requests).toHaveLength(4); +}); + +test('keeps renamed, added, and deleted sides aligned with their revision coordinates', async () => { + const transport = createTransport(); + const run = createReviewContentRun({ + generation: 'generation-1', + source: { sha: gitSha('b'), type: 'commit' }, + transport, + }); + const renamed: ChangedFile = { + ...createChangedFile('src/new.ts', { kind: 'commit', status: 'renamed' }), + oldPath: 'src/old.ts', + }; + const added = createChangedFile('src/added.ts', { kind: 'commit', status: 'added' }); + const deleted = createChangedFile('src/deleted.ts', { kind: 'commit', status: 'deleted' }); + + const [renamedFiles, addedFiles, deletedFiles] = await run.resolveSectionContentsBatch([ + { file: renamed, section: renamed.sections[0]! }, + { file: added, section: added.sections[0]! }, + { file: deleted, section: deleted.sections[0]! }, + ]); + + expect(renamedFiles.oldFile?.contents).toBe('commit:a:src/old.ts'); + expect(renamedFiles.newFile.contents).toBe('commit:b:src/new.ts'); + expect(addedFiles.oldFile).toBeNull(); + expect(addedFiles.newFile.contents).toBe('commit:b:src/added.ts'); + expect(deletedFiles.oldFile?.contents).toBe('commit:a:src/deleted.ts'); + expect(deletedFiles.newFile.contents).toBe(''); + expect(transport.mock.calls[0]![0].requests).toHaveLength(4); +}); + +test('joins concurrent consumers and caches immutable and mutable reads inside one run', async () => { + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + const baseTransport = createTransport(); + const transport = vi.fn(async (request: RevisionContentBatchRequest) => { + await gate; + return baseTransport(request); + }); + const run = createReviewContentRun({ + generation: 'fingerprint-1', + source: { type: 'working-tree' }, + transport, + }); + const file = createChangedFile('src/shared.ts'); + const first = run.resolveSectionContents(file, file.sections[0]!); + const second = run.resolveSectionContents(file, file.sections[0]!); + release(); + + await expect(Promise.all([first, second])).resolves.toHaveLength(2); + await run.resolveSectionContents(file, file.sections[0]!); + expect(transport).toHaveBeenCalledOnce(); + expect(run.diagnostics()).toMatchObject({ cacheHits: 4, sourceCalls: 1 }); + + const nextRun = createReviewContentRun({ + generation: 'fingerprint-2', + source: { type: 'working-tree' }, + transport, + }); + await nextRun.resolveSectionContents(file, file.sections[0]!); + expect(transport).toHaveBeenCalledTimes(2); +}); + +test('preserves index conflict stages and supports an absent unborn base', async () => { + const transport = createTransport(); + const run = createReviewContentRun({ + generation: 'conflict', + source: { type: 'working-tree' }, + transport, + }); + const conflict = createChangedFile('src/conflict.ts'); + const conflictSection = { + ...conflict.sections[0]!, + range: { + base: { + kind: 'index' as const, + label: { kind: 'review-marker' as const, text: 'Ours' }, + stage: 2 as const, + }, + head: { + kind: 'working-copy' as const, + label: { kind: 'review-marker' as const, text: 'Working copy' }, + }, + }, + }; + const unborn = createChangedFile('src/first.ts', { kind: 'staged', status: 'added' }); + const unbornSection = { + ...unborn.sections[0]!, + range: { + base: null, + head: { kind: 'index' as const, label: { kind: 'review-marker' as const, text: 'Index' } }, + }, + }; + + await run.resolveSectionContentsBatch([ + { file: conflict, section: conflictSection }, + { file: unborn, section: unbornSection }, + ]); + + const requests = transport.mock.calls[0]![0].requests; + expect(requests).toEqual( + expect.arrayContaining([ + expect.objectContaining({ revision: expect.objectContaining({ kind: 'index', stage: 2 }) }), + ]), + ); + expect(requests.filter((request) => request.path === unborn.path)).toHaveLength(1); +}); + +test('decodes text and image policies from the same byte result', () => { + const bytes = Uint8Array.from([0x89, 0x50, 0x4e, 0x47]); + const value: ResolvedRevisionBytes = { + bytes, + cacheKey: 'png', + path: 'image.png', + provenance: 'native-git', + size: bytes.byteLength, + }; + + expect(decodeImageRevision(value, 'image.png')).toMatchObject({ + dataUrl: expect.stringMatching(/^data:image\/png;base64,/), + mimeType: 'image/png', + }); + expect(() => decodeImageRevision(value, 'image.svg')).toThrow('Unsupported image file type'); + expect(() => + decodeTextRevision({ ...value, bytes: Uint8Array.from([0]) }, 'file.bin', 'empty'), + ).toThrow('the file is binary'); + expect(decodeTextRevision(null, 'empty.ts', 'empty-key')).toEqual({ + cacheKey: 'empty-key', + contents: '', + name: 'empty.ts', + }); +}); + +test('aborts late content results when a run is replaced', async () => { + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + const transport = vi.fn(async (request: RevisionContentBatchRequest) => { + await gate; + return createTransport()(request); + }); + const run = createReviewContentRun({ + generation: 'old', + source: { sha: gitSha('b'), type: 'commit' }, + transport, + }); + const file = createChangedFile('src/late.ts', { kind: 'commit' }); + const read = run.resolveSectionContents(file, file.sections[0]!); + const error = new Error('Replaced'); + error.name = 'AbortError'; + run.abort(error); + release(); + + await expect(read).rejects.toMatchObject({ name: 'AbortError' }); +}); diff --git a/core/__tests__/review-context-expansion.test.ts b/core/__tests__/review-context-expansion.test.ts new file mode 100644 index 00000000..326f13a7 --- /dev/null +++ b/core/__tests__/review-context-expansion.test.ts @@ -0,0 +1,40 @@ +import { expect, test } from 'vite-plus/test'; +import { + getReviewContextExpansionState, + reviewContextExpansionDigest, + reviewContextExpansionProjectionKey, +} from '../lib/review-context-expansion.ts'; + +test('progressive context expansion accumulates independently per separator', () => { + let state = getReviewContextExpansionState(undefined, 1, 'up', 100, false); + state = getReviewContextExpansionState(state, 1, 'down', 100, false); + state = getReviewContextExpansionState(state, 2, 'both', 100, false); + + expect([...state.entries()]).toEqual([ + [1, { fromEnd: 100, fromStart: 100 }], + [2, { fromEnd: 100, fromStart: 100 }], + ]); +}); + +test('show all records unbounded expansion without changing other separators', () => { + const state = getReviewContextExpansionState( + getReviewContextExpansionState(undefined, 1, 'up', 100, false), + 1, + 'both', + 100, + true, + ); + + expect(state.get(1)).toEqual({ + fromEnd: Number.POSITIVE_INFINITY, + fromStart: Number.POSITIVE_INFINITY, + }); + expect(reviewContextExpansionDigest(state)).toBe('1:Infinity:Infinity'); +}); + +test('projection identity follows rendered code, not post-generation materialization', () => { + const key = reviewContextExpansionProjectionKey('target', 'file-a', 'section-a'); + expect(key).toBe(reviewContextExpansionProjectionKey('target', 'file-a', 'section-a')); + expect(key).not.toBe(reviewContextExpansionProjectionKey('version', 'file-a', 'section-a')); + expect(key).not.toBe(reviewContextExpansionProjectionKey('target', 'file-b', 'section-a')); +}); diff --git a/core/__tests__/review-history.test.ts b/core/__tests__/review-history.test.ts new file mode 100644 index 00000000..7c42cb3d --- /dev/null +++ b/core/__tests__/review-history.test.ts @@ -0,0 +1,23 @@ +import { expect, test } from 'vite-plus/test'; +import { diffRange, shaForRevision } from '../lib/review-history.ts'; +import type { GitSha, Revision } from '../types.ts'; + +const gitSha = (value: string) => value as GitSha; + +test('keeps revision SHA identity separate from labels and non-commit markers', () => { + const base: Revision = { + label: { kind: 'commit', text: 'base' }, + sha: gitSha('a'.repeat(40)), + }; + const head: Revision = { + label: { kind: 'commit', text: 'head' }, + sha: gitSha('b'.repeat(40)), + }; + const range = diffRange(base, head); + + expect(shaForRevision(base)).toBe(base.sha); + expect(range.head?.label.text).toBe('head'); + expect(() => + shaForRevision({ kind: 'index', label: { kind: 'review-marker', text: 'Index' } }), + ).toThrow('Expected a commit revision'); +}); diff --git a/core/__tests__/review-source-codec.test.ts b/core/__tests__/review-source-codec.test.ts new file mode 100644 index 00000000..e935f8f2 --- /dev/null +++ b/core/__tests__/review-source-codec.test.ts @@ -0,0 +1,92 @@ +import { expect, test } from 'vite-plus/test'; +import { + decodeResolvedReviewSource, + decodeReviewSource, + formatResolvedSourceIdentity, + formatReviewSourceIdentity, +} from '../lib/review-source-codec.ts'; + +test('decodes and formats every local review source kind', () => { + const values = [ + { type: 'working-tree' }, + { ref: 'HEAD~2', type: 'commit' }, + { ref: 'main', type: 'branch' }, + { baseSha: 'A'.repeat(40), headSha: 'B'.repeat(40), ref: 'main', type: 'branch-diff' }, + { + baseSha: 'A'.repeat(40), + headSha: 'B'.repeat(40), + ref: 'main', + type: 'branch-working-tree', + }, + { base: 'main', head: 'feature', symmetric: true, type: 'range' }, + ] as const; + + expect(values.map((value) => formatReviewSourceIdentity(decodeReviewSource(value)!))).toEqual([ + 'working-tree', + 'commit:HEAD~2', + 'branch:main', + `branch-diff:main:${'a'.repeat(40)}:${'b'.repeat(40)}`, + `branch-working-tree:main:${'a'.repeat(40)}:${'b'.repeat(40)}`, + 'range:main...feature', + ]); +}); + +test('distinguishes unresolved and resolved revision identities', () => { + const unresolvedCommit = decodeReviewSource({ ref: 'HEAD', type: 'commit' })!; + const resolvedCommit = decodeResolvedReviewSource({ sha: 'A'.repeat(40), type: 'commit' })!; + const unresolvedBranchWorkingTree = decodeReviewSource({ + ref: 'main', + type: 'branch-working-tree', + })!; + + expect(formatReviewSourceIdentity(unresolvedCommit)).toBe('commit:HEAD'); + expect(formatResolvedSourceIdentity(resolvedCommit)).toBe(`commit:${'a'.repeat(40)}`); + expect(formatReviewSourceIdentity(unresolvedBranchWorkingTree)).toBe( + 'branch-working-tree:main:unresolved', + ); + expect( + decodeReviewSource({ baseSha: 'a'.repeat(40), ref: 'main', type: 'branch-working-tree' }), + ).toBeNull(); +}); + +test('canonicalizes GitHub and GitLab review URLs and changes exact identity at a new head', () => { + const github = decodeReviewSource({ + type: 'pull-request', + url: 'https://GitHub.com/NKZW-Tech/Codiff.git/pull/8/changes#r42', + })!; + const gitlab = decodeReviewSource({ + type: 'pull-request', + url: 'https://gitlab.example.com/Group/Subgroup/Project.git/-/merge_requests/9/diffs', + })!; + const firstHead = decodeResolvedReviewSource({ ...github, headSha: 'A'.repeat(40) })!; + const secondHead = decodeResolvedReviewSource({ ...github, headSha: 'B'.repeat(40) })!; + + expect(github).toMatchObject({ + number: 8, + owner: 'NKZW-Tech', + projectPath: 'NKZW-Tech/Codiff', + provider: 'github', + repo: 'Codiff', + url: 'https://github.com/NKZW-Tech/Codiff/pull/8', + }); + expect(formatReviewSourceIdentity(github)).toBe( + 'pull-request:github:github.com:nkzw-tech/codiff#8', + ); + expect(formatReviewSourceIdentity(gitlab)).toBe( + 'pull-request:gitlab:gitlab.example.com:group/subgroup/project#9', + ); + expect(formatReviewSourceIdentity(firstHead)).toBe(formatReviewSourceIdentity(secondHead)); + expect(formatResolvedSourceIdentity(firstHead)).not.toBe( + formatResolvedSourceIdentity(secondHead), + ); +}); + +test('rejects malformed source coordinates', () => { + expect(decodeReviewSource(null)).toBeNull(); + expect(decodeReviewSource({ type: 'commit' })).toBeNull(); + expect(decodeReviewSource({ base: 'main', head: 'feature', type: 'range' })).toBeNull(); + expect( + decodeReviewSource({ type: 'pull-request', url: 'https://example.com/review/1' }), + ).toBeNull(); + expect(decodeResolvedReviewSource({ ref: 'main', type: 'branch' })).toBeNull(); +}); diff --git a/core/__tests__/source-description.test.ts b/core/__tests__/source-description.test.ts new file mode 100644 index 00000000..54b62a96 --- /dev/null +++ b/core/__tests__/source-description.test.ts @@ -0,0 +1,106 @@ +import { expect, test } from 'vite-plus/test'; +import { buildSourceDescriptionModel } from '../lib/source-description.ts'; +import type { CommitMetadata, GitSha } from '../types.ts'; + +const gitSha = (character: string) => character.repeat(40) as GitSha; +const person = { + date: '2026-08-06T00:00:00.000Z', + email: 'ada@example.com', + name: 'Ada Lovelace', +}; +const commitMetadata = { + author: person, + body: '', + committer: person, + files: [], + parentShas: [], + refs: [], + sha: gitSha('a'), + shortSha: 'aaaaaaa', + signature: { status: 'unsigned' }, + stats: { + additions: 0, + binaryFiles: 0, + deletions: 0, + files: 0, + renamedFiles: 0, + }, + subject: 'Preserve commit context', + trailers: [], +} satisfies CommitMetadata; + +test('builds commit, missing, title-only, bodyless, and editable source-description models', () => { + expect( + buildSourceDescriptionModel({ + commitMetadata, + source: { sha: gitSha('a'), type: 'commit' }, + }), + ).toMatchObject({ + author: { displayName: 'Ada Lovelace', title: 'ada@example.com' }, + body: '', + defaultCollapsed: false, + kind: 'commit', + label: 'Commit', + title: 'Preserve commit context', + }); + + expect( + buildSourceDescriptionModel({ + commitMetadata: null, + source: { + provider: 'github', + type: 'pull-request', + url: 'https://github.com/example/repo/pull/1', + }, + }), + ).toBeNull(); + + expect( + buildSourceDescriptionModel({ + commitMetadata: null, + source: { + provider: 'gitlab', + title: 'Title only', + type: 'pull-request', + url: 'https://gitlab.example.com/example/repo/-/merge_requests/1', + }, + }), + ).toMatchObject({ + body: '', + defaultCollapsed: false, + kind: 'pull-request', + label: 'MR description', + title: 'Title only', + }); + + expect( + buildSourceDescriptionModel({ + commitMetadata: null, + source: { + canEditDescription: true, + description: '', + provider: 'github', + title: 'Editable title', + type: 'pull-request', + url: 'https://github.com/example/repo/pull/2', + }, + }), + ).toMatchObject({ allowsBodyEdit: true, defaultCollapsed: false }); +}); + +test('keys provider models to immutable head identity', () => { + const source = { + description: 'Body', + headSha: gitSha('a'), + provider: 'github' as const, + title: 'Title', + type: 'pull-request' as const, + url: 'https://github.com/example/repo/pull/3', + }; + const first = buildSourceDescriptionModel({ commitMetadata: null, source }); + const second = buildSourceDescriptionModel({ + commitMetadata: null, + source: { ...source, headSha: gitSha('b') }, + }); + expect(first?.identity).not.toBe(second?.identity); +}); diff --git a/core/__tests__/useAppWalkthrough.test.tsx b/core/__tests__/useAppWalkthrough.test.tsx index 736e299a..50f174fd 100644 --- a/core/__tests__/useAppWalkthrough.test.tsx +++ b/core/__tests__/useAppWalkthrough.test.tsx @@ -6,10 +6,17 @@ import { act } from 'react'; import { expect, test, vi } from 'vite-plus/test'; import { useAppWalkthrough } from '../app/hooks/useAppWalkthrough.ts'; import { createDefaultConfig } from '../config/defaults.ts'; -import type { NarrativeWalkthrough, RepositoryState, WalkthroughProgressEvent } from '../types.ts'; +import type { + GitSha, + NarrativeWalkthrough, + RepositoryState, + WalkthroughProgressEvent, +} from '../types.ts'; import { createChangedFile } from './helpers/fixtures.ts'; import { renderReact, waitFor } from './helpers/react.tsx'; +const gitSha = (value: string) => value as GitSha; + type AppWalkthroughController = ReturnType; const walkthrough: NarrativeWalkthrough = { @@ -106,6 +113,7 @@ test('walkthrough controller lazily generates, refreshes, and transitions modes' })); await using view = await renderWalkthroughController({ codiff: { + cancelNarrativeWalkthrough: vi.fn(async () => {}), getNarrativeWalkthrough, onWalkthroughProgress: vi.fn(() => () => {}), }, @@ -153,7 +161,7 @@ test('walkthrough controller lazily generates, refreshes, and transitions modes' test('walkthrough controller routes progress, commit APIs, and sharing through current state', async () => { let onProgress: ((progress: WalkthroughProgressEvent) => void) | null = null; const createWalkthroughCommit = vi.fn(async () => ({ - hash: 'abc123', + sha: gitSha('abc123'), status: 'committed' as const, })); const updateWalkthroughCommitMessage = vi.fn(async () => ({ @@ -168,6 +176,7 @@ test('walkthrough controller routes progress, commit APIs, and sharing through c const state = createRepositoryState(); await using view = await renderWalkthroughController({ codiff: { + cancelNarrativeWalkthrough: vi.fn(async () => {}), createWalkthroughCommit, onWalkthroughProgress: vi.fn((callback) => { onProgress = callback; @@ -187,17 +196,36 @@ test('walkthrough controller routes progress, commit APIs, and sharing through c }); expect(getController().walkthroughProgress.phase).toBe('agent-generation'); expect(getController().walkthroughProgress.stageRevision).toBe(1); + await act(async () => { + onProgress?.({ + generation: { + completed: 1, + phase: 'generating-units', + summary: 'Completed the first task.', + total: 2, + units: [ + { id: 'first', label: 'First task', status: 'ready' }, + { id: 'second', label: 'Second task', status: 'pending' }, + ], + }, + }); + }); + expect(getController().walkthroughProgress).toMatchObject({ + generation: { completed: 1, phase: 'generating-units', total: 2 }, + phase: 'agent-generation', + stageRevision: 1, + }); await act(async () => { await getController().commitWalkthrough({ body: 'Body', paths: ['src/app.ts'], - source: { ref: 'old', type: 'commit' }, + source: { sha: gitSha('old'), type: 'commit' }, subject: 'Subject', }); await getController().updateWalkthroughCommitMessage({ body: 'Body', paths: ['src/app.ts'], - source: { ref: 'old', type: 'commit' }, + source: { sha: gitSha('old'), type: 'commit' }, subject: 'Subject', }); }); diff --git a/core/__tests__/walkthrough-generation-tasks.test.ts b/core/__tests__/walkthrough-generation-tasks.test.ts new file mode 100644 index 00000000..9322ad71 --- /dev/null +++ b/core/__tests__/walkthrough-generation-tasks.test.ts @@ -0,0 +1,204 @@ +import { expect, test, vi } from 'vite-plus/test'; +import { + runWalkthroughGenerationTasks, + type WalkthroughGenerationTask, +} from '../lib/walkthrough-generation-tasks.ts'; +import type { GenerationMetadata, GenerationProfile } from '../types.ts'; + +const profile: GenerationProfile = { + agent: 'codex', + authoringVersion: 'test-v1', + modelCandidates: ['primary', 'fallback'], +}; + +const metadata = (model = 'primary'): GenerationMetadata => ({ + agent: profile.agent, + generatedAt: '2026-08-04T00:00:00.000Z', + model, + profile, +}); + +const task = ( + id: string, + run: WalkthroughGenerationTask['run'], +): WalkthroughGenerationTask => ({ + id, + identity: id, + label: `${id} task`, + profile, + run, + semanticInput: { prompt: `Explain ${id}.` }, +}); + +test('retains successes so retry invokes only failed tasks', async () => { + const firstRun = vi.fn(async ({ semanticInput }: { semanticInput: { prompt: string } }) => { + if (semanticInput.prompt.includes('second')) { + throw new Error('model unavailable'); + } + return { generationMetadata: metadata(), output: semanticInput.prompt }; + }); + const tasks = [task('first', firstRun), task('second', firstRun)]; + + const first = await runWalkthroughGenerationTasks({ tasks }); + + expect(first.status).toBe('failed'); + expect(first.components).toHaveLength(1); + if (first.status !== 'failed') { + return; + } + expect(first.failures).toEqual([ + { error: 'model unavailable', identity: 'second', label: 'second task' }, + ]); + + const retry = vi.fn(async ({ semanticInput }: { semanticInput: { prompt: string } }) => ({ + generationMetadata: metadata(), + output: semanticInput.prompt, + })); + const second = await runWalkthroughGenerationTasks({ + reusableComponents: first.components, + tasks: [task('first', retry), task('second', retry)], + }); + + expect(second.status).toBe('ready'); + expect(retry).toHaveBeenCalledTimes(1); + expect(retry.mock.calls[0]?.[0].semanticInput).toEqual({ prompt: 'Explain second.' }); +}); + +test('bounds concurrent model work while preserving task order', async () => { + let active = 0; + let maximumActive = 0; + const pending: Array<() => void> = []; + const run = vi.fn( + ({ semanticInput }: { semanticInput: { prompt: string } }) => + new Promise<{ generationMetadata: GenerationMetadata; output: string }>((resolve) => { + active += 1; + maximumActive = Math.max(maximumActive, active); + pending.push(() => { + active -= 1; + resolve({ generationMetadata: metadata(), output: semanticInput.prompt }); + }); + }), + ); + const generation = runWalkthroughGenerationTasks({ + concurrency: 3, + tasks: ['first', 'second', 'third', 'fourth'].map((id) => task(id, run)), + }); + + await vi.waitFor(() => expect(pending).toHaveLength(3)); + pending[0]!(); + await vi.waitFor(() => expect(pending).toHaveLength(4)); + pending.slice(1).forEach((complete) => complete()); + const result = await generation; + + expect(maximumActive).toBe(3); + expect(result.status).toBe('ready'); + expect(result.components.map((component) => component.identity)).toEqual([ + 'first', + 'second', + 'third', + 'fourth', + ]); +}); + +test('cancellation prevents queued tasks from starting and suppresses readiness', async () => { + const controller = new AbortController(); + const started: Array = []; + const run = vi.fn( + ({ semanticInput }: { semanticInput: { prompt: string } }) => + new Promise<{ generationMetadata: GenerationMetadata; output: string }>(() => { + started.push(semanticInput.prompt); + }), + ); + const generation = runWalkthroughGenerationTasks({ + concurrency: 1, + signal: controller.signal, + tasks: [task('first', run), task('second', run)], + }); + await vi.waitFor(() => expect(started).toEqual(['Explain first.'])); + + controller.abort(new Error('The review changed.')); + const result = await generation; + + expect(result).toMatchObject({ reason: 'The review changed.', status: 'cancelled' }); + expect(started).toEqual(['Explain first.']); +}); + +test('reports generic unit progress without topology-specific fields', async () => { + const progress = vi.fn(); + + const result = await runWalkthroughGenerationTasks({ + onProgress: progress, + tasks: [ + task('narrative', async () => ({ + generationMetadata: metadata('fallback'), + output: 'Ready', + })), + ], + }); + + expect(result.status).toBe('ready'); + expect(progress.mock.calls.map(([event]) => event.phase)).toEqual([ + 'preparing', + 'generating', + 'generating', + 'combining', + ]); + expect(progress.mock.calls.at(-1)?.[0]).toMatchObject({ completed: 1, total: 1 }); +}); + +test('does not reuse a component with invalid successful-call metadata', async () => { + const run = vi.fn(async () => ({ generationMetadata: metadata(), output: 'fresh' })); + const reusableComponents = [ + { + generationMetadata: metadata('outside-policy'), + identity: 'narrative', + output: 'stale', + profile, + semanticInput: { prompt: 'Explain narrative.' }, + }, + ]; + + const result = await runWalkthroughGenerationTasks({ + reusableComponents, + tasks: [task('narrative', run)], + }); + + expect(result.status).toBe('ready'); + expect(run).toHaveBeenCalledTimes(1); + expect(result.components.at(-1)?.output).toBe('fresh'); +}); + +test('reuses only exact semantic inputs and preserves declared task order', async () => { + const run = vi.fn(async ({ semanticInput }: { semanticInput: { prompt: string } }) => ({ + generationMetadata: metadata(), + output: `fresh: ${semanticInput.prompt}`, + })); + const reusableComponents = [ + { + generationMetadata: metadata(), + identity: 'second', + output: 'cached second', + profile, + semanticInput: { prompt: 'Explain second.' }, + }, + { + generationMetadata: metadata(), + identity: 'first', + output: 'stale first', + profile, + semanticInput: { prompt: 'Explain an older first.' }, + }, + ]; + + const result = await runWalkthroughGenerationTasks({ + reusableComponents, + tasks: [task('first', run), task('second', run)], + }); + + expect(result.status).toBe('ready'); + expect(run).toHaveBeenCalledTimes(1); + expect(result.components.map(({ identity, output }) => ({ identity, output }))).toEqual([ + { identity: 'first', output: 'fresh: Explain first.' }, + { identity: 'second', output: 'cached second' }, + ]); +}); diff --git a/core/app/RepositoryReviewHost.tsx b/core/app/RepositoryReviewHost.tsx new file mode 100644 index 00000000..20219c70 --- /dev/null +++ b/core/app/RepositoryReviewHost.tsx @@ -0,0 +1,2039 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import type { CodiffConfig } from '../config/types.ts'; +import { HISTORY_PAGE_SIZE } from '../lib/app-constants.ts'; +import { + type RepositoryLoadError, + type ReviewComment, + type ReviewIdentity, + type SourceSession, +} from '../lib/app-types.ts'; +import { + getDiffSectionLineCount, + isPatchOnlyDiffSection, + shouldLoadDiffSectionContents, +} from '../lib/diff.ts'; +import { sortFiles } from '../lib/files.ts'; +import { + getChangedPaths, + haveChangedFiles, + writeReloadSelection, +} from '../lib/reload-selection.ts'; +import { reconcileRepositoryRefresh } from '../lib/repository-refresh.ts'; +import type { RepositoryReviewBootstrap } from '../lib/repository-review-bootstrap.ts'; +import { resolveReviewCommandTarget } from '../lib/review-command-target.ts'; +import { diffRangesMatch } from '../lib/review-comment-target.ts'; +import { + getReviewCommentsFromState, + isProviderReviewCommentPosition, + isReviewDraft, + mergeReviewComments, + reviewCommentRegionSectionPrefix, + toProviderSubmittedReviewComment, + toPullRequestExistingReviewComment, +} from '../lib/review-comments.ts'; +import { + createReviewContentRun, + type ReviewContentRun, + type ReviewContentTransport, +} from '../lib/review-content.ts'; +import { getFileReviewIdentity } from '../lib/review-identity.ts'; +import { + getHistorySource, + getRefreshSource, + getRepositoryLoadError, + getSourceKey, + getSourceLabel, + getSourceRevisionKey, + supportsLazyDiffContent, + usesViewedFileState, +} from '../lib/source.ts'; +import { readViewed, writeViewed } from '../lib/viewed.ts'; +import { + buildSharedReviewSnapshot, + ReviewSurface, + type ProviderReviewOutcome, + type ReviewMode, + type ReviewSurfaceCapabilities, + type ReviewSurfaceCommandBridge, + type ReviewWalkthroughStatus, +} from '../ReviewSurface.tsx'; +import type { + ChangedFile, + CodiffLaunchOptions, + CodiffPreferences, + CodiffUpdateStatus, + GitIdentity, + HistoryEntry, + NarrativeWalkthrough, + NarrativeWalkthroughResult, + OpenReviewSourceKind, + PullRequestExistingReviewComment, + RepositoryState, + ReviewSource, + DiffSection, +} from '../types.ts'; +import { OpenReviewSourceDialog } from './components/OpenReviewSourceDialog.tsx'; +import { OpenReviewSourceMenu } from './components/OpenReviewSourceMenu.tsx'; +import { + RepositoryChangeBanner, + RepositoryLoadErrorPanel, + RepositoryRefreshBanner, + type RepositoryRefreshStatus, + ReviewCommentsLoadBanner, + ReviewSourceLoading, + UpdatePill, + WalkthroughOutdatedBanner, +} from './components/Panels.tsx'; +import { WalkthroughProgress } from './components/walkthrough/WalkthroughProgress.tsx'; +import type { WalkthroughFileError } from './components/WalkthroughFileError.tsx'; +import { useAppCommands } from './hooks/useAppCommands.ts'; +import { useAppReviewComments } from './hooks/useAppReviewComments.ts'; +import { useAppWalkthrough } from './hooks/useAppWalkthrough.ts'; +import { useDocumentAppearance } from './hooks/useDocumentAppearance.ts'; +import { useReviewFileState } from './hooks/useReviewState.ts'; + +const portableSurfaceCommandIds = new Set([ + 'diff-search', + 'file-filter', + 'sidebar-history', + 'sidebar-tree', + 'sidebar-walkthrough', + 'toggle-sidebar', + 'toggle-word-wrap', +]); +const defaultReviewCommentsPrefix = '# Address these Review Comments'; + +const createDiffContentRequestOwner = () => { + const requestIds = new Set(); + let revisionRequest = 0; + + const readRevisionContent: ReviewContentTransport = (request) => { + revisionRequest += 1; + const requestId = `revision-content:${revisionRequest}`; + requestIds.add(requestId); + return window.codiff + .readRevisionContent({ ...request, requestId }) + .finally(() => requestIds.delete(requestId)); + }; + + return { + add: (requestId: string) => requestIds.add(requestId), + cancelAll: () => { + for (const requestId of requestIds) { + window.codiff.cancelDiffContentRequest(requestId); + } + requestIds.clear(); + }, + delete: (requestId: string) => requestIds.delete(requestId), + readRevisionContent, + }; +}; + +type ReviewAuthoringMode = 'local-notes' | 'provider-comments' | 'read-only'; + +const getProviderMutationDestination = ( + source: RepositoryState['source'], +): 'github' | 'gitlab' | null => + source.type === 'pull-request' && (source.provider === 'github' || source.provider === 'gitlab') + ? source.provider + : null; + +const getReviewAuthoringMode = (source: RepositoryState['source']): ReviewAuthoringMode => + getProviderMutationDestination(source) + ? 'provider-comments' + : source.type === 'pull-request' + ? 'read-only' + : 'local-notes'; + +const getPendingCommentsLabel = (mode: ReviewAuthoringMode) => + mode === 'provider-comments' ? 'Copy Pending Review Comments' : 'Copy Review Notes'; + +const getPendingCommentsPrefix = (mode: ReviewAuthoringMode, configuredPrefix: string) => + configuredPrefix === defaultReviewCommentsPrefix + ? mode === 'provider-comments' + ? '# Address these Pending Review Comments' + : '# Address these Review Notes' + : configuredPrefix; + +const toPullRequestReviewEvent = ( + outcome: ProviderReviewOutcome, +): 'APPROVE' | 'COMMENT' | 'REQUEST_CHANGES' => { + switch (outcome) { + case 'approve': + return 'APPROVE'; + case 'comment': + return 'COMMENT'; + case 'request-changes': + return 'REQUEST_CHANGES'; + } +}; + +const createPlaceholderWalkthrough = ( + state: RepositoryState, + title: string, + agent: NarrativeWalkthrough['agent'], +): NarrativeWalkthrough => ({ + agent, + chapters: [], + focus: 'Generate a walkthrough to review this change in narrative order.', + generatedAt: new Date(state.generatedAt).toISOString(), + kind: 'narrative', + repo: { branch: state.branch, root: state.root }, + source: state.source, + support: [], + title, + version: 4, +}); + +const getFailedSectionLoadState = (section: DiffSection): DiffSection => + isPatchOnlyDiffSection(section) + ? { + ...section, + summary: { + canLoad: false, + reason: 'Codiff could not load full file context.', + }, + } + : { + ...section, + loadState: 'error', + summary: { + canLoad: false, + reason: 'Codiff could not load this file.', + }, + }; + +const hydrateSectionFromContents = ( + file: ChangedFile, + section: DiffSection, + contents: Awaited>, +): DiffSection => { + const { summary: _summary, ...rest } = section; + const loadedSection: DiffSection = { + ...rest, + binary: false, + loadState: 'ready', + newFile: contents.newFile, + oldFile: contents.oldFile ?? undefined, + }; + const lineCount = getDiffSectionLineCount(file, loadedSection); + return lineCount.countable + ? { + ...loadedSection, + lineCount: { additions: lineCount.additions, deletions: lineCount.deletions }, + } + : loadedSection; +}; + +type ReviewContentHydrationRequest = { file: ChangedFile; section: DiffSection }; + +const hydrateReviewContentRequests = async ( + run: ReviewContentRun, + requests: ReadonlyArray, +): Promise< + ReadonlyArray<{ + contents?: Awaited>; + request: ReviewContentHydrationRequest; + }> +> => { + if (requests.length === 0) { + return []; + } + try { + const contents = await run.resolveSectionContentsBatch(requests); + return requests.map((request, index) => ({ contents: contents[index]!, request })); + } catch { + if (requests.length === 1) { + return [{ request: requests[0]! }]; + } + const midpoint = Math.ceil(requests.length / 2); + const [left, right] = await Promise.all([ + hydrateReviewContentRequests(run, requests.slice(0, midpoint)), + hydrateReviewContentRequests(run, requests.slice(midpoint)), + ]); + return [...left, ...right]; + } +}; + +const getReviewContentRunKey = (state: RepositoryState) => + `${state.root}:${getSourceRevisionKey(state.source)}:${state.files + .map((file) => `${file.path}:${file.fingerprint}`) + .join('|')}`; +const getPreferencesFromConfig = ({ settings }: CodiffConfig): CodiffPreferences => ({ + ...settings, +}); + +const getCollapsedViewedPaths = ( + files: ReadonlyArray, + viewedFiles: Readonly>, +) => + new Set( + files.filter((file) => viewedFiles[file.path] === file.fingerprint).map((file) => file.path), + ); + +const mergeStateReviewComments = ( + state: RepositoryState, + currentComments: ReadonlyArray, +) => mergeReviewComments(getReviewCommentsFromState(state), currentComments.filter(isReviewDraft)); + +const getReviewCommentRegionKey = (comment: PullRequestExistingReviewComment) => { + const position = comment.position; + if (!position || !isProviderReviewCommentPosition(position)) { + return null; + } + return `${encodeURIComponent(comment.filePath)}:${position.range.base.sha}:${position.range.head.sha}`; +}; + +const createReviewCommentRegionFile = ( + state: RepositoryState, + comment: PullRequestExistingReviewComment, +): ChangedFile | null => { + const key = getReviewCommentRegionKey(comment); + const position = comment.position; + if ( + !key || + !position || + (comment.anchor !== 'file' && (comment.lineNumber == null || comment.side == null)) + ) { + return null; + } + const currentFile = state.files.find((file) => file.path === comment.filePath); + if (currentFile?.sections.some((section) => diffRangesMatch(section.range, position.range))) { + return null; + } + + const id = `${reviewCommentRegionSectionPrefix}${key}`; + return { + fingerprint: id, + ...(currentFile?.oldPath ? { oldPath: currentFile.oldPath } : {}), + path: comment.filePath, + sections: [ + { + binary: false, + id, + kind: 'pull-request', + loadState: 'deferred', + patch: '', + range: position.range, + summary: { + canLoad: true, + reason: 'Loading the exact code region for this review thread.', + }, + }, + ], + status: currentFile?.status ?? 'modified', + }; +}; + +const mergeReviewCommentRegionFiles = ( + files: ReadonlyArray, + regions: ReadonlyArray, +) => { + const remaining = new Map(regions.map((file) => [file.path, file])); + const merged = files.map((file) => { + const region = remaining.get(file.path); + if (!region) { + return file; + } + remaining.delete(file.path); + return { ...file, sections: [...file.sections, ...region.sections] }; + }); + return [...merged, ...remaining.values()]; +}; + +export type RepositoryReviewHostProps = { + bootstrap: RepositoryReviewBootstrap; + config: CodiffConfig; + disableCodeViewWorkerPool?: boolean; + gitIdentity: GitIdentity | null; + gitIdentityReady: boolean; + initialHistory?: ReadonlyArray; + initialHistoryLoading?: boolean; + initialWalkthroughFileError?: WalkthroughFileError | null; + initialWalkthroughLoading?: boolean; + initialWalkthroughResult?: NarrativeWalkthroughResult; + launchOptions: CodiffLaunchOptions; + walkthroughSharingEnabled?: boolean; +}; + +export function RepositoryReviewHost({ + bootstrap, + config, + disableCodeViewWorkerPool = false, + gitIdentity, + gitIdentityReady, + initialHistory = [], + initialHistoryLoading = false, + initialWalkthroughFileError, + initialWalkthroughLoading = false, + initialWalkthroughResult, + launchOptions, + walkthroughSharingEnabled = false, +}: RepositoryReviewHostProps) { + const { + historySource: initialHistorySource, + initialScrollTarget, + mainMode: initialMainMode, + reloadDeltaPaths: initialReloadDeltaPaths, + selectedPath: initialSelectedPath, + sidebarMode: startupMode, + state: initialState, + } = bootstrap; + const [loadError, setLoadError] = useState(null); + const [historyEntries, setHistoryEntries] = useState>(initialHistory); + const [historyHasMore, setHistoryHasMore] = useState(initialHistory.length >= HISTORY_PAGE_SIZE); + const [historyLimit, setHistoryLimit] = useState(HISTORY_PAGE_SIZE); + const [historyLoading, setHistoryLoading] = useState(initialHistoryLoading); + const [initialHistoryComplete, setInitialHistoryComplete] = useState(!initialHistoryLoading); + const [historySource, setHistorySource] = useState(() => + initialHistorySource === undefined + ? (getHistorySource(initialState.source) ?? null) + : initialHistorySource, + ); + const [localChangesDetected, setLocalChangesDetected] = useState(false); + const [repositoryRefreshStatus, setRepositoryRefreshStatus] = + useState(null); + const [walkthroughStale, setWalkthroughStale] = useState(false); + const [openReviewSourceKind, setOpenReviewSourceKind] = useState( + null, + ); + const preferences = useMemo(() => getPreferencesFromConfig(config), [config]); + const [reloadDeltaPaths, setReloadDeltaPaths] = useState>( + () => initialReloadDeltaPaths, + ); + const [surfaceInitialScrollTarget, setSurfaceInitialScrollTarget] = useState(initialScrollTarget); + const [isWindowFullscreen, setIsWindowFullscreen] = useState(false); + const [pendingSource, setPendingSource] = useState(null); + const [loadingSectionIds, setLoadingSectionIds] = useState>(() => new Set()); + const [hydratedReviewContentRunKey, setHydratedReviewContentRunKey] = useState( + null, + ); + const [, setSidebarCollapsed] = useState(false); + const [state, setState] = useState(() => ({ + ...initialState, + files: sortFiles(initialState.files), + })); + const [reviewCommentRegions, setReviewCommentRegions] = useState<{ + files: ReadonlyArray; + sourceKey: string; + } | null>(null); + const [updateStatus, setUpdateStatus] = useState(null); + const historyRequestRef = useRef(0); + const historySourceRef = useRef(null); + const [diffContentRequests] = useState(createDiffContentRequestOwner); + const loadingSectionKeysRef = useRef>(new Set()); + const reviewCommentsRequestRef = useRef(0); + const repositoryRefreshRequestRef = useRef(0); + const reviewCommentsInFlightRef = useRef<{ + generation: number; + request: number; + requestId: string; + sourceKey: string; + } | null>(null); + const surfaceCommandBridgeRef = useRef(null); + const sourceSessionsRef = useRef>(new Map()); + const stateRef = useRef(state); + const firstUsableMilestoneReportedRef = useRef(false); + const deferredMilestoneReportedRef = useRef(false); + const walkthroughFileFallbackAppliedRef = useRef(false); + const initialViewed = usesViewedFileState(initialState.source) + ? readViewed(initialState.root) + : {}; + const initialCollapsed = getCollapsedViewedPaths(initialState.files, initialViewed); + const collapsedRef = useRef>(initialCollapsed); + const expandedGeneratedRef = useRef>(new Set()); + const preferencesRef = useRef(preferences); + const previousShowWhitespaceRef = useRef(config.settings.showWhitespace); + const selectedPathRef = useRef(null); + const sourceRequestRef = useRef(0); + const stateGenerationRef = useRef(0); + const markdownRefreshQueueRef = useRef>(Promise.resolve()); + const viewedRef = useRef>(initialViewed); + const persistViewed = useCallback((nextViewed: Record) => { + const currentState = stateRef.current; + if (currentState && usesViewedFileState(currentState.source)) { + writeViewed(currentState.root, nextViewed); + } + }, []); + const { + bumpItemVersion, + collapsed, + expandedGenerated, + itemVersionByKey, + selectedPath, + setCollapsed, + setExpandedGenerated, + setItemVersionByKey, + setSelectedPath, + setViewed, + toggleViewed: toggleReviewViewed, + viewed, + } = useReviewFileState({ + initialCollapsed, + initialSelectedPath, + initialViewed, + onViewedChange: persistViewed, + }); + const toggleViewed = useCallback( + (file: ChangedFile, isViewed: boolean, reviewIdentity?: ReviewIdentity) => { + if (!stateRef.current) { + return; + } + if (reviewIdentity) { + toggleReviewViewed(file, isViewed, reviewIdentity); + } else { + toggleReviewViewed(file, isViewed); + } + }, + [toggleReviewViewed], + ); + + const { + askCodex, + localReviewNotes, + providerDrafts, + providerInlineComments, + resetCommentFocus, + reviewComments, + reviewCommentsRef, + setLocalReviewNotes, + setProviderDrafts, + setReviewComments, + } = useAppReviewComments({ + draftKind: state?.source.type === 'pull-request' ? 'provider-draft' : 'local-note', + initialReviewComments: getReviewCommentsFromState(initialState), + onCommentFileChange: bumpItemVersion, + stateRef, + }); + + const hydrateReviewComments = useCallback( + (requestedState: RepositoryState | null = stateRef.current) => { + if ( + requestedState?.source.type !== 'pull-request' || + requestedState.reviewCommentsLoadState === 'loaded' + ) { + return; + } + + const sourceKey = `${requestedState.root}:${getSourceRevisionKey(requestedState.source)}`; + const generation = stateGenerationRef.current; + const inFlight = reviewCommentsInFlightRef.current; + if (inFlight?.sourceKey === sourceKey && inFlight.generation === generation) { + return; + } + + if (inFlight) { + window.codiff.cancelDiffContentRequest(inFlight.requestId); + diffContentRequests.delete(inFlight.requestId); + } + const request = reviewCommentsRequestRef.current + 1; + const requestId = `review-comments:${request}`; + reviewCommentsRequestRef.current = request; + diffContentRequests.add(requestId); + reviewCommentsInFlightRef.current = { generation, request, requestId, sourceKey }; + const isCurrentState = () => { + const current = stateRef.current; + return ( + reviewCommentsRequestRef.current === request && + stateGenerationRef.current === generation && + current != null && + `${current.root}:${getSourceRevisionKey(current.source)}` === sourceKey + ); + }; + + if (requestedState.reviewCommentsLoadState === 'failed') { + const retryingState = { + ...requestedState, + reviewCommentsError: undefined, + reviewCommentsLoadState: 'not-loaded' as const, + }; + stateRef.current = retryingState; + setState(retryingState); + } + + void window.codiff + .getReviewComments(requestedState.source, requestId) + .then(({ generalComments, reviewComments: loadedComments }) => { + if (!isCurrentState()) { + return; + } + const current = stateRef.current; + if (!current) { + return; + } + const hydratedState = { + ...current, + generalComments, + reviewComments: loadedComments, + reviewCommentsError: undefined, + reviewCommentsLoadState: 'loaded' as const, + }; + stateRef.current = hydratedState; + setState(hydratedState); + setReviewComments((comments) => mergeStateReviewComments(hydratedState, comments)); + }) + .catch((error: unknown) => { + if (!isCurrentState()) { + return; + } + const current = stateRef.current; + if (!current) { + return; + } + const failedState = { + ...current, + reviewCommentsError: error instanceof Error ? error.message : String(error), + reviewCommentsLoadState: 'failed' as const, + }; + stateRef.current = failedState; + setState(failedState); + }) + .finally(() => { + diffContentRequests.delete(requestId); + if (reviewCommentsInFlightRef.current?.request === request) { + reviewCommentsInFlightRef.current = null; + } + }); + }, + [diffContentRequests, setReviewComments], + ); + const { + activeReviewCommandTargetRef, + cancelWalkthroughRequest, + changeSidebarMode, + closeCommitView, + commitWalkthrough, + enabledShareWalkthrough, + loadNarrativeWalkthrough, + mainModeRef, + narrativeNavigation, + narrativeWalkthrough, + narrativeWalkthroughRef, + openCommitView, + plainCommitModel, + refreshWalkthroughForState, + setMainMode, + setNarrativeWalkthrough, + setShareWalkthroughEnabled, + setSidebarMode, + setWalkthroughError, + setWalkthroughFileError, + setWalkthroughLoading, + setWalkthroughUnread, + showPlainCommitView, + sidebarMode, + sidebarModeRef, + subscribeToCommitOutput, + updateActiveWalkthroughReviewTarget, + updateWalkthroughCommitMessage, + walkthroughError, + walkthroughErrorRef, + walkthroughFileError, + walkthroughLoading, + walkthroughProgress, + walkthroughUnread, + } = useAppWalkthrough({ + initialMainMode, + initialSidebarMode: startupMode, + initialWalkthroughFileError, + initialWalkthroughLoading, + initialWalkthroughResult, + preferencesRef, + state, + stateGenerationRef, + stateRef, + }); + + useEffect(() => { + if ( + walkthroughFileFallbackAppliedRef.current || + !initialWalkthroughFileError || + initialWalkthroughResult?.status !== 'unavailable' || + !state || + getSourceRevisionKey(state.source) !== getSourceRevisionKey(bootstrap.source) + ) { + return; + } + walkthroughFileFallbackAppliedRef.current = true; + changeSidebarMode('history'); + }, [ + bootstrap.source, + changeSidebarMode, + initialWalkthroughFileError, + initialWalkthroughResult, + state, + ]); + + useEffect(() => { + if ( + !state || + state.source.type !== 'pull-request' || + state.reviewCommentsLoadState !== 'not-loaded' + ) { + return; + } + hydrateReviewComments(state); + }, [hydrateReviewComments, state]); + + useEffect(() => { + const reviewCommentsComplete = + !state || + state.source.type !== 'pull-request' || + state.reviewCommentsLoadState !== 'not-loaded'; + if ( + !firstUsableMilestoneReportedRef.current || + !initialHistoryComplete || + !gitIdentityReady || + !reviewCommentsComplete || + walkthroughLoading || + deferredMilestoneReportedRef.current + ) { + return; + } + deferredMilestoneReportedRef.current = true; + window.codiff.reportInitialLoadMilestone?.('deferred-review-data-complete'); + }, [gitIdentityReady, initialHistoryComplete, state, walkthroughLoading]); + const [commentsMode, setCommentsMode] = useState(false); + const activeSurfaceMode: ReviewMode = commentsMode ? 'comments' : sidebarMode; + const changeSurfaceMode = useCallback( + (mode: ReviewMode) => { + if (mode === 'comments') { + setMainMode('review'); + setCommentsMode(true); + return; + } + setCommentsMode(false); + if (mode === 'walkthrough' && walkthroughStale) { + setMainMode('review'); + setSidebarMode('walkthrough'); + setWalkthroughUnread(false); + return; + } + changeSidebarMode(mode); + }, + [changeSidebarMode, setMainMode, setSidebarMode, setWalkthroughUnread, walkthroughStale], + ); + + const cancelDiffContentRequests = useCallback( + () => diffContentRequests.cancelAll(), + [diffContentRequests], + ); + useEffect(() => cancelDiffContentRequests, [cancelDiffContentRequests]); + + const reviewContentRunKey = state ? getReviewContentRunKey(state) : null; + const reviewContentSource = state?.source ?? null; + const reviewContentRun = useMemo( + () => + reviewContentRunKey && reviewContentSource + ? createReviewContentRun({ + generation: reviewContentRunKey, + source: reviewContentSource, + transport: diffContentRequests.readRevisionContent, + }) + : null, + [diffContentRequests, reviewContentRunKey, reviewContentSource], + ); + useEffect( + () => () => { + const error = new Error('The review content run was replaced.'); + error.name = 'AbortError'; + reviewContentRun?.abort(error); + }, + [reviewContentRun], + ); + + const startupReviewContentRequests = useMemo( + () => + state?.source.type === 'pull-request' + ? state.files.flatMap((file) => + file.sections + .filter( + (section) => + !section.binary && + section.range != null && + section.summary?.canLoad !== false && + (shouldLoadDiffSectionContents(section) || isPatchOnlyDiffSection(section)), + ) + .map((section) => ({ file, section })), + ) + : [], + [state], + ); + const reviewContentReady = + startupReviewContentRequests.length === 0 || + hydratedReviewContentRunKey === reviewContentRunKey; + + useEffect(() => { + if ( + !state || + !reviewContentRun || + !reviewContentRunKey || + startupReviewContentRequests.length === 0 + ) { + return; + } + + let canceled = false; + const requestedRunKey = reviewContentRunKey; + void hydrateReviewContentRequests(reviewContentRun, startupReviewContentRequests).then( + (results) => { + if (canceled || getReviewContentRunKey(stateRef.current ?? state) !== requestedRunKey) { + return; + } + + const resultsBySectionId = new Map( + results.map((result) => [ + `${result.request.file.path}\0${result.request.section.id}`, + result, + ]), + ); + const current = stateRef.current ?? state; + const hydratedState: RepositoryState = { + ...current, + files: current.files.map((file) => ({ + ...file, + sections: file.sections.map((section) => { + const result = resultsBySectionId.get(`${file.path}\0${section.id}`); + if (!result) { + return section; + } + return result.contents + ? hydrateSectionFromContents(file, section, result.contents) + : getFailedSectionLoadState(section); + }), + })), + }; + stateRef.current = hydratedState; + setState(hydratedState); + setHydratedReviewContentRunKey(requestedRunKey); + }, + ); + + return () => { + canceled = true; + }; + }, [reviewContentRun, reviewContentRunKey, startupReviewContentRequests, state]); + + useEffect(() => { + if (!state || !reviewContentReady || firstUsableMilestoneReportedRef.current) { + return; + } + firstUsableMilestoneReportedRef.current = true; + window.codiff.reportInitialLoadMilestone?.('first-usable-review-rendered'); + }, [reviewContentReady, state]); + + const loadDiffSection = useCallback( + (file: ChangedFile, section: DiffSection, repositoryState = stateRef.current) => { + const currentState = repositoryState; + if ( + !currentState || + !supportsLazyDiffContent(currentState.source) || + (!shouldLoadDiffSectionContents(section) && !isPatchOnlyDiffSection(section)) || + !reviewContentRun + ) { + return; + } + + const sourceKey = getSourceRevisionKey(currentState.source); + const stateGeneration = stateGenerationRef.current; + const reviewKey = getFileReviewIdentity(file).key; + const key = `${currentState.root}:${sourceKey}:${section.id}`; + if (loadingSectionKeysRef.current.has(key)) { + return; + } + + loadingSectionKeysRef.current.add(key); + setLoadingSectionIds((current) => new Set(current).add(section.id)); + + return reviewContentRun + .resolveSectionContents(file, section) + .then((contents) => { + const loadedSection = hydrateSectionFromContents(file, section, contents); + if ( + stateGenerationRef.current !== stateGeneration || + stateRef.current?.root !== currentState.root || + getSourceRevisionKey(stateRef.current.source) !== sourceKey + ) { + return; + } + setState((current) => { + if ( + stateGenerationRef.current !== stateGeneration || + !current || + current.root !== currentState.root || + getSourceRevisionKey(current.source) !== sourceKey + ) { + return current; + } + + return { + ...current, + files: current.files.map((candidate) => + candidate.path === file.path + ? { + ...candidate, + sections: candidate.sections.map((candidateSection) => + candidateSection.id === section.id ? loadedSection : candidateSection, + ), + } + : candidate, + ), + }; + }); + bumpItemVersion(reviewKey); + }) + .catch(() => { + if ( + stateGenerationRef.current !== stateGeneration || + stateRef.current?.root !== currentState.root || + getSourceRevisionKey(stateRef.current.source) !== sourceKey + ) { + return; + } + setState((current) => { + if ( + stateGenerationRef.current !== stateGeneration || + !current || + current.root !== currentState.root || + getSourceRevisionKey(current.source) !== sourceKey + ) { + return current; + } + + return { + ...current, + files: current.files.map((candidate) => + candidate.path === file.path + ? { + ...candidate, + sections: candidate.sections.map((candidateSection) => + candidateSection.id === section.id + ? getFailedSectionLoadState(candidateSection) + : candidateSection, + ), + } + : candidate, + ), + }; + }); + bumpItemVersion(reviewKey); + }) + .finally(() => { + loadingSectionKeysRef.current.delete(key); + setLoadingSectionIds((current) => { + const next = new Set(current); + next.delete(section.id); + return next; + }); + }); + }, + [bumpItemVersion, reviewContentRun], + ); + + const resolveSectionContents = useCallback( + (file: ChangedFile, section: DiffSection) => { + if (!reviewContentRun) { + return Promise.reject(new Error(`Cannot load diff contents for '${file.path}'.`)); + } + return reviewContentRun.resolveSectionContents(file, section); + }, + [reviewContentRun], + ); + + const loadReviewCommentRegion = useCallback( + async (comment: PullRequestExistingReviewComment) => { + const current = stateRef.current; + if (!current || !reviewContentRun) { + return; + } + const file = createReviewCommentRegionFile(current, comment); + if (!file) { + return; + } + const section = file.sections[0]!; + const loadedFile = { + ...file, + sections: [ + hydrateSectionFromContents( + file, + section, + await reviewContentRun.resolveSectionContents(file, section), + ), + ], + }; + const sourceKey = `${current.root}:${getSourceRevisionKey(current.source)}`; + const latest = stateRef.current; + if (!latest || `${latest.root}:${getSourceRevisionKey(latest.source)}` !== sourceKey) { + return; + } + setReviewCommentRegions((regions) => ({ + files: + regions?.sourceKey === sourceKey + ? mergeReviewCommentRegionFiles(regions.files, [loadedFile]) + : [loadedFile], + sourceKey, + })); + }, + [reviewContentRun], + ); + + const resolveImage = useCallback( + (file: ChangedFile, section: DiffSection) => + reviewContentRun?.resolveImage(file, section) ?? + Promise.resolve({ + reason: `Cannot load image contents for '${file.path}'.`, + status: 'unavailable' as const, + }), + [reviewContentRun], + ); + + const refreshMarkdownFile = useCallback( + (file: ChangedFile, _section: DiffSection) => { + const refresh = async () => { + const currentState = stateRef.current; + if ( + !currentState || + (currentState.source.type !== 'working-tree' && + currentState.source.type !== 'branch-working-tree') + ) { + return true; + } + const sourceRequest = sourceRequestRef.current; + const stateGeneration = stateGenerationRef.current; + const sourceKey = getSourceRevisionKey(currentState.source); + + try { + const nextState = await window.codiff.getRepositoryState( + getRefreshSource(currentState.source), + ); + const orderedState = { + ...nextState, + files: sortFiles(nextState.files), + }; + if ( + sourceRequestRef.current !== sourceRequest || + stateGenerationRef.current !== stateGeneration || + stateRef.current?.root !== currentState.root || + getSourceRevisionKey(stateRef.current.source) !== sourceKey + ) { + return false; + } + + const changedPaths = getChangedPaths(currentState.files, orderedState.files); + const walkthroughNeedsRefresh = haveChangedFiles(currentState.files, orderedState.files); + stateGenerationRef.current += 1; + stateRef.current = orderedState; + setState(orderedState); + setLocalChangesDetected(false); + setReviewComments((comments) => mergeStateReviewComments(orderedState, comments)); + if (walkthroughNeedsRefresh) { + refreshWalkthroughForState(orderedState); + } + setCollapsed((current) => { + const next = new Set(current); + for (const path of changedPaths) { + next.delete(path); + } + return next; + }); + setSelectedPath((current) => + current && orderedState.files.some((candidate) => candidate.path === current) + ? current + : (orderedState.files[0]?.path ?? null), + ); + if (changedPaths.size === 0) { + bumpItemVersion(file.path); + } else { + for (const path of changedPaths) { + bumpItemVersion(path); + } + } + return true; + } catch { + setLocalChangesDetected(true); + return false; + } + }; + + const result = markdownRefreshQueueRef.current.then(refresh, refresh); + markdownRefreshQueueRef.current = result.then( + () => {}, + () => {}, + ); + return result; + }, + [bumpItemVersion, refreshWalkthroughForState, setCollapsed, setReviewComments, setSelectedPath], + ); + + const saveCurrentSourceSession = useCallback(() => { + const currentState = stateRef.current; + if (!currentState) { + return; + } + + sourceSessionsRef.current.set(getSourceRevisionKey(currentState.source), { + collapsed: new Set(collapsedRef.current), + expandedGenerated: new Set(expandedGeneratedRef.current), + narrativeWalkthrough: narrativeWalkthroughRef.current, + reviewComments: reviewCommentsRef.current, + selectedPath: selectedPathRef.current, + viewed: viewedRef.current, + walkthroughError: walkthroughErrorRef.current, + walkthroughFiles: currentState.files.map(({ fingerprint, path, status }) => ({ + fingerprint, + path, + status, + })), + }); + }, [narrativeWalkthroughRef, reviewCommentsRef, walkthroughErrorRef]); + + useEffect( + () => + window.codiff.onRepositoryChanged(() => { + setLocalChangesDetected(true); + }), + [], + ); + + useEffect(() => { + void window.codiff.isWindowFullScreen().then(setIsWindowFullscreen, () => {}); + return window.codiff.onWindowFullScreenChanged(setIsWindowFullscreen); + }, []); + + useEffect(() => { + setShareWalkthroughEnabled(walkthroughSharingEnabled); + }, [setShareWalkthroughEnabled, walkthroughSharingEnabled]); + + useEffect(() => { + if ( + !state || + state.source.type === 'pull-request' || + !supportsLazyDiffContent(state.source) || + !selectedPath + ) { + return; + } + + const selectedFile = state.files.find((file) => file.path === selectedPath); + if (!selectedFile) { + return; + } + + const loadableSections = selectedFile.sections.filter(shouldLoadDiffSectionContents); + + if (!loadableSections.length) { + return; + } + + for (const section of loadableSections) { + loadDiffSection(selectedFile, section, state); + } + }, [loadDiffSection, selectedPath, state]); + + useEffect(() => { + const previousShowWhitespace = previousShowWhitespaceRef.current; + const nextPreferences = preferences; + previousShowWhitespaceRef.current = nextPreferences.showWhitespace; + + if (previousShowWhitespace === nextPreferences.showWhitespace) { + return; + } + + const currentState = stateRef.current; + if (!currentState) { + return; + } + + const request = sourceRequestRef.current + 1; + sourceRequestRef.current = request; + stateGenerationRef.current += 1; + loadingSectionKeysRef.current.clear(); + setLoadingSectionIds(new Set()); + + window.codiff + .getRepositoryState(getRefreshSource(currentState.source)) + .then((nextState) => { + if (sourceRequestRef.current !== request) { + return; + } + + const orderedState = { + ...nextState, + files: sortFiles(nextState.files), + }; + const nextSelectedPath = + selectedPathRef.current && + orderedState.files.some((file) => file.path === selectedPathRef.current) + ? selectedPathRef.current + : (orderedState.files[0]?.path ?? null); + const nextViewed = usesViewedFileState(orderedState.source) + ? readViewed(orderedState.root) + : {}; + const walkthroughNeedsRefresh = haveChangedFiles(currentState.files, orderedState.files); + + stateRef.current = orderedState; + setState(orderedState); + if (walkthroughNeedsRefresh) { + refreshWalkthroughForState(orderedState); + } + setSelectedPath(nextSelectedPath); + setReloadDeltaPaths(new Set()); + setItemVersionByKey({}); + setReviewComments((comments) => mergeStateReviewComments(orderedState, comments)); + setViewed(nextViewed); + setCollapsed(getCollapsedViewedPaths(orderedState.files, nextViewed)); + setExpandedGenerated(new Set()); + setLoadError(null); + }) + .catch((error: unknown) => { + if (sourceRequestRef.current !== request) { + return; + } + setLoadError(getRepositoryLoadError(error)); + }); + }, [ + preferences, + setCollapsed, + setExpandedGenerated, + setItemVersionByKey, + refreshWalkthroughForState, + setReviewComments, + setSelectedPath, + setViewed, + ]); + + useDocumentAppearance({ + cleanupCodeFontProperties: true, + clearEmptyCodeFontFamily: true, + codeFontFamily: preferences.codeFontFamily, + codeFontSize: preferences.codeFontSize, + theme: preferences.theme, + }); + + useEffect(() => { + let canceled = false; + window.codiff + .getUpdateStatus() + .then((status) => { + if (!canceled) { + setUpdateStatus(status); + } + }) + .catch(() => {}); + + const unsubscribe = window.codiff.onUpdateStatusChanged(setUpdateStatus); + return () => { + canceled = true; + unsubscribe(); + }; + }, []); + + useEffect(() => { + stateRef.current = state; + }, [state]); + + useEffect(() => { + historySourceRef.current = historySource; + }, [historySource]); + + useEffect(() => { + if (!initialHistoryLoading) { + return; + } + const request = historyRequestRef.current + 1; + historyRequestRef.current = request; + const requestedSource = historySource; + const requestedSourceKey = requestedSource ? getSourceRevisionKey(requestedSource) : ''; + const stateGeneration = stateGenerationRef.current; + queueMicrotask(() => { + if (historyRequestRef.current !== request) { + return; + } + setHistoryLoading(true); + void window.codiff + .getRepositoryHistory(HISTORY_PAGE_SIZE, requestedSource ?? undefined) + .then((nextHistory) => { + const currentSource = historySourceRef.current; + const currentSourceKey = currentSource ? getSourceRevisionKey(currentSource) : ''; + if ( + historyRequestRef.current !== request || + stateGenerationRef.current !== stateGeneration || + currentSourceKey !== requestedSourceKey + ) { + return; + } + setHistoryEntries(nextHistory.entries); + setHistoryHasMore(nextHistory.entries.length >= HISTORY_PAGE_SIZE); + }) + .catch(() => { + if (historyRequestRef.current === request) { + setHistoryHasMore(false); + } + }) + .finally(() => { + if (historyRequestRef.current === request) { + setHistoryLoading(false); + setInitialHistoryComplete(true); + } + }); + }); + }, [historySource, initialHistoryLoading]); + + useEffect(() => { + collapsedRef.current = collapsed; + }, [collapsed]); + + useEffect(() => { + expandedGeneratedRef.current = expandedGenerated; + }, [expandedGenerated]); + + useEffect(() => { + preferencesRef.current = preferences; + }, [preferences]); + + useEffect(() => { + const removeListener = window.codiff.onCopyPendingCommentsRequest(() => { + return surfaceCommandBridgeRef.current?.copyPendingComments() ?? ''; + }); + return removeListener; + }, [reviewCommentsRef]); + + useEffect(() => { + selectedPathRef.current = selectedPath; + }, [selectedPath]); + + useEffect(() => { + viewedRef.current = viewed; + }, [viewed]); + + const toggleSidebar = useCallback(() => { + setSidebarCollapsed((current) => !current); + }, []); + + const toggleWordWrap = useCallback(() => { + void window.codiff.setWordWrap(!preferencesRef.current.wordWrap).catch(() => {}); + }, []); + + const expandSidebar = useCallback(() => { + setSidebarCollapsed(false); + }, []); + + const focusFileFilter = useCallback(() => { + expandSidebar(); + requestAnimationFrame(() => { + const input = document.querySelector('.sidebar-search'); + input?.focus(); + input?.select(); + }); + }, [expandSidebar]); + + const openFile = useCallback((file: ChangedFile) => { + // Deleted files are still shown in diffs, but there is no current file to open. + if (file.status === 'deleted') { + return; + } + + void window.codiff.openFile(file.path).catch(() => {}); + }, []); + + const getReviewCommandTarget = useCallback(() => { + const currentState = stateRef.current; + if (!currentState) { + return null; + } + + return resolveReviewCommandTarget({ + activeTarget: activeReviewCommandTargetRef.current, + files: currentState.files, + selectedPath: selectedPathRef.current, + source: currentState.source, + useActiveTarget: + mainModeRef.current === 'review' && + sidebarModeRef.current === 'walkthrough' && + narrativeWalkthroughRef.current != null, + }); + }, [activeReviewCommandTargetRef, mainModeRef, narrativeWalkthroughRef, sidebarModeRef]); + + const openSelectedFile = useCallback(() => { + const target = getReviewCommandTarget(); + + if (target) { + openFile(target.file); + } + }, [getReviewCommandTarget, openFile]); + + const openSurfaceDiffSearch = useCallback(() => { + surfaceCommandBridgeRef.current?.openDiffSearch(); + }, []); + const copyPendingComments = useCallback( + () => surfaceCommandBridgeRef.current?.copyPendingComments() ?? '', + [], + ); + useEffect(() => window.codiff.onFindInDiffs(openSurfaceDiffSearch), [openSurfaceDiffSearch]); + const updateSurfaceCommandBridge = useCallback((bridge: ReviewSurfaceCommandBridge | null) => { + surfaceCommandBridgeRef.current = bridge; + }, []); + + const loadMoreHistory = useCallback(() => { + if (historyLoading || !historyHasMore) { + return; + } + + const nextLimit = historyLimit + HISTORY_PAGE_SIZE; + const request = historyRequestRef.current + 1; + historyRequestRef.current = request; + const requestedSourceKey = historySource ? getSourceRevisionKey(historySource) : ''; + const stateGeneration = stateGenerationRef.current; + setHistoryLoading(true); + window.codiff + .getRepositoryHistory(nextLimit, historySource ?? undefined) + .then((history) => { + const currentSource = historySourceRef.current; + const currentSourceKey = currentSource ? getSourceRevisionKey(currentSource) : ''; + if ( + historyRequestRef.current !== request || + stateGenerationRef.current !== stateGeneration || + currentSourceKey !== requestedSourceKey + ) { + return; + } + + setHistoryEntries(history.entries); + setHistoryLimit(nextLimit); + setHistoryHasMore(history.entries.length >= nextLimit); + }) + .catch(() => { + if (historyRequestRef.current === request) { + setHistoryHasMore(false); + } + }) + .finally(() => { + if (historyRequestRef.current === request) { + setHistoryLoading(false); + } + }); + }, [historyHasMore, historyLimit, historyLoading, historySource]); + + // Refresh the repository state in place after the working tree changed. + // Unlike a window reload, this keeps all review UI state (selection, scroll, + // search, walkthrough navigation, commit drafts, pending comments) and only + // re-renders files whose reviewed code actually changed. + useEffect(() => { + if (repositoryRefreshStatus?.phase !== 'complete' || walkthroughStale) { + return; + } + const timeout = window.setTimeout(() => setRepositoryRefreshStatus(null), 2500); + return () => window.clearTimeout(timeout); + }, [repositoryRefreshStatus, walkthroughStale]); + + const refreshRepository = useCallback(() => { + const previousState = stateRef.current; + if (!previousState || pendingSource) { + return; + } + + setRepositoryRefreshStatus({ phase: 'refreshing' }); + const request = repositoryRefreshRequestRef.current + 1; + repositoryRefreshRequestRef.current = request; + const sourceRequest = sourceRequestRef.current; + reviewCommentsRequestRef.current += 1; + reviewCommentsInFlightRef.current = null; + const historyRequest = historyRequestRef.current + 1; + historyRequestRef.current = historyRequest; + const refreshSource = getRefreshSource(previousState.source); + const refreshHistorySource = historySourceRef.current + ? getRefreshSource(historySourceRef.current) + : undefined; + const pendingReviewComments = reviewComments.filter(isReviewDraft); + + Promise.all([ + window.codiff.getRepositoryState(refreshSource), + window.codiff.getRepositoryHistory(historyLimit, refreshHistorySource), + ]) + .then(([nextState, history]) => { + if ( + repositoryRefreshRequestRef.current !== request || + sourceRequestRef.current !== sourceRequest || + historyRequestRef.current !== historyRequest + ) { + return; + } + + const requestedState = { + ...nextState, + files: sortFiles(nextState.files), + }; + const reconciliation = reconcileRepositoryRefresh({ + collapsed: collapsedRef.current, + historySource: historySourceRef.current, + mainMode: mainModeRef.current, + nextState: requestedState, + previousState, + selectedPath: selectedPathRef.current, + }); + const reviewedCodeChanged = reconciliation.walkthroughNeedsRefresh; + const orderedState = reviewedCodeChanged + ? requestedState + : { ...requestedState, files: previousState.files }; + + if (reviewedCodeChanged) { + cancelDiffContentRequests(); + stateGenerationRef.current += 1; + if ( + sidebarModeRef.current === 'walkthrough' || + narrativeWalkthrough != null || + walkthroughLoading + ) { + setWalkthroughStale(true); + } + } + stateRef.current = orderedState; + setState(orderedState); + setReloadDeltaPaths(reconciliation.changedPaths); + for (const path of reconciliation.changedPaths) { + bumpItemVersion(path); + } + setCollapsed(reconciliation.collapsed); + setHistoryEntries(history.entries); + setHistoryHasMore(history.entries.length >= historyLimit); + setHistoryLoading(false); + setInitialHistoryComplete(true); + setHistorySource(reconciliation.historySource); + setReviewComments( + mergeReviewComments(getReviewCommentsFromState(orderedState), pendingReviewComments), + ); + setSelectedPath(reconciliation.selectedPath); + if (reconciliation.mainMode !== mainModeRef.current) { + setMainMode(reconciliation.mainMode); + } + setLocalChangesDetected(false); + setRepositoryRefreshStatus({ phase: 'complete', updated: reviewedCodeChanged }); + }) + .catch((error: unknown) => { + if ( + repositoryRefreshRequestRef.current === request && + sourceRequestRef.current === sourceRequest && + historyRequestRef.current === historyRequest + ) { + setHistoryLoading(false); + setInitialHistoryComplete(true); + setRepositoryRefreshStatus({ + phase: 'failed', + reason: error instanceof Error ? error.message : String(error), + }); + } + // Keep the current state; the banner stays up as a retry affordance. + }); + }, [ + bumpItemVersion, + cancelDiffContentRequests, + historyLimit, + mainModeRef, + narrativeWalkthrough, + pendingSource, + reviewComments, + setCollapsed, + setMainMode, + setReviewComments, + setSelectedPath, + sidebarModeRef, + walkthroughLoading, + ]); + + // ⌘R / the View menu's "Refresh Changes" item route here from the main + // process instead of reloading the window. + useEffect(() => window.codiff.onRefreshRequest(refreshRepository), [refreshRepository]); + + useEffect(() => { + const writeCurrentReloadSelection = () => { + const persistenceState = surfaceCommandBridgeRef.current?.getPersistenceState(); + writeReloadSelection( + stateRef.current, + persistenceState?.selectedPath ?? selectedPathRef.current, + historySourceRef.current, + mainModeRef.current, + ); + }; + + window.addEventListener('beforeunload', writeCurrentReloadSelection); + return () => window.removeEventListener('beforeunload', writeCurrentReloadSelection); + }, [mainModeRef]); + + const selectSource = useCallback( + (source: ReviewSource, options: { throwOnError?: boolean } = {}) => { + const currentState = stateRef.current; + const sourceKey = getSourceKey(source); + const currentDisplayKey = getSourceKey(pendingSource ?? currentState?.source ?? source); + if (currentDisplayKey === sourceKey) { + return Promise.resolve(); + } + + saveCurrentSourceSession(); + cancelDiffContentRequests(); + cancelWalkthroughRequest(); + repositoryRefreshRequestRef.current += 1; + const request = sourceRequestRef.current + 1; + sourceRequestRef.current = request; + reviewCommentsRequestRef.current += 1; + reviewCommentsInFlightRef.current = null; + historyRequestRef.current += 1; + setHistoryLoading(false); + setInitialHistoryComplete(true); + setPendingSource(source); + setRepositoryRefreshStatus(null); + setWalkthroughStale(false); + setSurfaceInitialScrollTarget(null); + setLoadError(null); + resetCommentFocus(); + setReloadDeltaPaths(new Set()); + setMainMode('review'); + setWalkthroughUnread(false); + + return window.codiff + .getRepositoryState(source) + .then((nextState) => { + if (sourceRequestRef.current !== request) { + return; + } + + const orderedState = { + ...nextState, + files: sortFiles(nextState.files), + }; + const session = sourceSessionsRef.current.get(getSourceRevisionKey(orderedState.source)); + const nextViewed = + session?.viewed ?? + (usesViewedFileState(orderedState.source) ? readViewed(orderedState.root) : {}); + const nextSelectedPath = + session?.selectedPath && + orderedState.files.some((file) => file.path === session.selectedPath) + ? session.selectedPath + : (orderedState.files[0]?.path ?? null); + const nextCollapsed = + session?.collapsed ?? getCollapsedViewedPaths(orderedState.files, nextViewed); + const nextExpandedGenerated = session?.expandedGenerated ?? new Set(); + const sessionWalkthroughIsCurrent = + session?.narrativeWalkthrough != null && + !haveChangedFiles(session.walkthroughFiles, orderedState.files); + const nextNarrativeWalkthrough = sessionWalkthroughIsCurrent + ? (session?.narrativeWalkthrough ?? null) + : null; + + stateGenerationRef.current += 1; + stateRef.current = orderedState; + setState(orderedState); + setHistorySource(getHistorySource(orderedState.source) ?? historySource); + setCollapsed(new Set(nextCollapsed)); + setExpandedGenerated(new Set(nextExpandedGenerated)); + setItemVersionByKey({}); + setReviewComments(session?.reviewComments ?? getReviewCommentsFromState(orderedState)); + setReloadDeltaPaths(new Set()); + setViewed(nextViewed); + setSelectedPath(nextSelectedPath); + setNarrativeWalkthrough(nextNarrativeWalkthrough); + setWalkthroughError( + sessionWalkthroughIsCurrent ? (session.walkthroughError ?? null) : null, + ); + setWalkthroughLoading(false); + setWalkthroughUnread(false); + setLocalChangesDetected(false); + setPendingSource(null); + if (!sessionWalkthroughIsCurrent) { + refreshWalkthroughForState(orderedState, session?.narrativeWalkthrough ?? null); + } + }) + .catch((error: unknown) => { + if (sourceRequestRef.current === request) { + setLoadError(getRepositoryLoadError(error)); + setWalkthroughLoading(false); + setPendingSource(null); + } + if (options.throwOnError) { + throw error; + } + }); + }, + [ + cancelDiffContentRequests, + cancelWalkthroughRequest, + historySource, + pendingSource, + refreshWalkthroughForState, + resetCommentFocus, + saveCurrentSourceSession, + setCollapsed, + setExpandedGenerated, + setItemVersionByKey, + setMainMode, + setNarrativeWalkthrough, + setReviewComments, + setSelectedPath, + setViewed, + setWalkthroughError, + setWalkthroughLoading, + setWalkthroughUnread, + ], + ); + + const openReviewSource = useCallback( + async (kind: OpenReviewSourceKind, value: string) => { + if (kind === 'pull-request') { + const url = await window.codiff.resolvePullRequestUrl(value); + await selectSource({ type: 'pull-request', url }, { throwOnError: true }); + return; + } + + await selectSource( + kind === 'branch' + ? { ref: value, type: 'branch-working-tree' } + : { ref: value, type: 'commit' }, + { throwOnError: true }, + ); + }, + [selectSource], + ); + + const showOpenReviewSourceDialog = useCallback((kind: OpenReviewSourceKind) => { + setOpenReviewSourceKind(kind); + }, []); + + const openRepositoryFolder = useCallback(() => { + void window.codiff.openRepositoryFolder().catch(() => {}); + }, []); + + useEffect( + () => window.codiff.onOpenReviewSource(showOpenReviewSourceDialog), + [showOpenReviewSourceDialog], + ); + + const commandBarCommands = useAppCommands({ + changeSidebarMode: changeSurfaceMode, + copyPendingCommentsLabel: state + ? getPendingCommentsLabel(getReviewAuthoringMode(state.source)) + : 'Copy Review Notes', + focusFileFilter, + getReviewCommandTarget, + onCopyPendingComments: copyPendingComments, + onOpenDiffSearch: openSurfaceDiffSearch, + onOpenReviewSource: showOpenReviewSourceDialog, + onOpenSelectedFile: openSelectedFile, + onRefreshRepository: refreshRepository, + onToggleSidebar: toggleSidebar, + onToggleViewed: toggleViewed, + onToggleWordWrap: toggleWordWrap, + preferencesRef, + viewedRef, + }); + const desktopCommands = useMemo( + () => commandBarCommands.filter((command) => !portableSurfaceCommandIds.has(command.id)), + [commandBarCommands], + ); + + if (loadError) { + return ( +
+
+ +
+
+ ); + } + + if (!state) { + return null; + } + + if (!reviewContentReady) { + return ; + } + + const source = state.source; + const title = + source.type === 'pull-request' + ? source.title?.trim() || getSourceLabel(source) + : source.type === 'commit' + ? state.commitMetadata?.subject?.trim() || getSourceLabel(source) + : getSourceLabel(source); + const walkthroughStatus: ReviewWalkthroughStatus = walkthroughLoading + ? 'generating' + : narrativeWalkthrough + ? 'ready' + : walkthroughError + ? 'failed' + : 'idle'; + const walkthroughAgent = launchOptions.agentBackend ?? config.settings.agentBackend; + const snapshotState = + reviewCommentRegions?.sourceKey === `${state.root}:${getSourceRevisionKey(state.source)}` + ? { + ...state, + files: mergeReviewCommentRegionFiles(state.files, reviewCommentRegions.files), + } + : state; + const snapshot = { + ...buildSharedReviewSnapshot({ + preferences, + state: snapshotState, + title, + walkthrough: + narrativeWalkthrough ?? createPlaceholderWalkthrough(state, title, walkthroughAgent), + }), + ...(source.type === 'pull-request' + ? { + reviewComments: providerInlineComments.map(toPullRequestExistingReviewComment), + } + : {}), + }; + const branchSource = + historySource?.type === 'branch-diff' + ? historySource + : historySource?.type === 'branch-working-tree' && + historySource.baseSha && + historySource.headSha + ? { + baseSha: historySource.baseSha, + headSha: historySource.headSha, + ref: historySource.ref, + type: 'branch-diff' as const, + } + : null; + const isLocalCommitSource = source.type === 'working-tree'; + const providerSource = + source.type === 'pull-request' && getProviderMutationDestination(source) ? source : null; + const reviewAuthoringMode = getReviewAuthoringMode(source); + const annotationCapabilities: ReviewSurfaceCapabilities = providerSource + ? { + comments: { + authoring: { + canCreateInline: true, + onAsk: askCodex, + }, + destination: 'provider', + inline: { + onSubmit: async (comment) => + toProviderSubmittedReviewComment( + await window.codiff.submitPullRequestComment({ + comment, + source: providerSource, + }), + comment, + ), + }, + reviewSession: { + drafts: { + onChange: setProviderDrafts, + value: providerDrafts, + }, + submit: ({ comments, outcome, summary }) => + window.codiff.submitPullRequestReview({ + ...(summary ? { body: summary } : {}), + comments, + event: toPullRequestReviewEvent(outcome), + source: providerSource, + }), + }, + }, + } + : source.type === 'pull-request' + ? {} + : { + localReviewNotes: { + canCreateInline: true, + drafts: { + onChange: setLocalReviewNotes, + value: localReviewNotes, + }, + onAsk: askCodex, + }, + }; + + return ( + + + { + const currentState = stateRef.current; + if (!currentState) { + return; + } + setWalkthroughStale(false); + void loadNarrativeWalkthrough(currentState.source, { + force: true, + previousWalkthrough: narrativeWalkthroughRef.current ?? undefined, + }); + }} + onRetry={refreshRepository} + status={repositoryRefreshStatus} + walkthroughStale={walkthroughStale} + /> + hydrateReviewComments(stateRef.current)} + reason={ + state.reviewCommentsLoadState === 'failed' + ? state.reviewCommentsError || 'Could not load review comments.' + : null + } + /> + setWalkthroughFileError(null)} + reason={walkthroughFileError?.reason ?? null} + /> + {pendingSource ? : null} + {openReviewSourceKind ? ( + setOpenReviewSourceKind(null)} + onOpen={(value) => openReviewSource(openReviewSourceKind, value)} + /> + ) : null} + + ), + collapsed, + commands: desktopCommands, + ...(isLocalCommitSource && state.files.length > 0 + ? { + commit: { + branch: state.branch, + draft: narrativeNavigation, + model: plainCommitModel, + onCommit: commitWalkthrough, + onCommitOutput: subscribeToCommitOutput, + onToggle: showPlainCommitView ? closeCommitView : openCommitView, + onUpdateMessage: updateWalkthroughCommitMessage, + open: showPlainCommitView, + }, + } + : {}), + disableCodeViewWorkerPool, + isSwitchingSource: pendingSource != null, + isWindowFullscreen, + onActiveWalkthroughReviewTargetChange: updateActiveWalkthroughReviewTarget, + onCollapsedChange: setCollapsed, + onFindDefinitions: window.codiff.findDefinitions, + onOpenDefinition: (candidate) => { + void window.codiff.openFile(candidate.path, candidate.lineNumber).catch(() => {}); + }, + onOpenFile: openFile, + onOpenSelectedFile: openSelectedFile, + onViewedChange: setViewed, + reloadDeltaPaths, + sidebarFooter: updateStatus ? ( + { + window.codiff.applyUpdate().then(setUpdateStatus, () => {}); + }} + onDismiss={() => { + window.codiff.dismissUpdate().then(setUpdateStatus, () => {}); + }} + status={updateStatus} + /> + ) : null, + sourceMenu: ( + + ), + viewed, + }, + history: { + branchSource, + currentSource: pendingSource ?? source, + entries: historyEntries, + hasMore: historyHasMore, + loading: historyLoading, + onLoadMore: loadMoreHistory, + onSelectSource: selectSource, + pullRequestSource: historySource?.type === 'pull-request' ? historySource : null, + }, + preferences: { + diffLayout: { + onChange: (value) => { + void window.codiff.setDiffStyle(value).catch(() => {}); + }, + value: preferences.diffStyle, + }, + outdatedVisibility: { + onChange: (value) => { + void window.codiff.setShowOutdated(value).catch(() => {}); + }, + value: preferences.showOutdated, + }, + pendingCommentPrefix: { + onChange: () => {}, + value: getPendingCommentsPrefix(reviewAuthoringMode, preferences.reviewCommentsPrefix), + }, + selectedPath: { + onChange: setSelectedPath, + value: selectedPath, + }, + wordWrap: { + onChange: (value) => { + void window.codiff.setWordWrap(value).catch(() => {}); + }, + value: preferences.wordWrap, + }, + }, + walkthrough: { + ...(isLocalCommitSource + ? { + commit: commitWalkthrough, + commitOutput: subscribeToCommitOutput, + updateCommitMessage: updateWalkthroughCommitMessage, + } + : {}), + error: walkthroughError, + generationProgress: walkthroughProgress.generation, + onGenerate: () => loadNarrativeWalkthrough(source), + onShare: enabledShareWalkthrough, + progress: ( + + ), + status: walkthroughStatus, + unread: walkthroughUnread, + }, + }} + externalUrl={source.type === 'pull-request' ? source.url : undefined} + gitIdentity={gitIdentity} + key={getSourceRevisionKey(source)} + keymap={config.keymap} + onCommandBridgeChange={updateSurfaceCommandBridge} + providerLabel={ + source.type === 'pull-request' && source.provider === 'gitlab' ? 'GitLab' : 'GitHub' + } + sidebarPosition={config.settings.sidebarPosition} + snapshot={snapshot} + title={title} + /> + ); +} diff --git a/core/app/components/FileTree.tsx b/core/app/components/FileTree.tsx index 31e17b9c..2e0a8f9e 100644 --- a/core/app/components/FileTree.tsx +++ b/core/app/components/FileTree.tsx @@ -35,6 +35,7 @@ export function ReviewFileTree({ showWhitespace: boolean; viewed?: Readonly>; }) { + const initialScrollPendingRef = useRef(scrollSelectedPathIntoView); const treeHostRef = useRef(null); const paths = useMemo(() => files.map((file) => file.path), [files]); const filePathSet = useMemo(() => new Set(paths), [paths]); @@ -152,6 +153,10 @@ export function ReviewFileTree({ const selectedPaths = model.getSelectedPaths(); if (selectedPaths.length === 1 && selectedPaths[0] === selectedPath) { + if (initialScrollPendingRef.current) { + initialScrollPendingRef.current = false; + scrollPathIntoView(selectedPath); + } return; } @@ -159,10 +164,11 @@ export function ReviewFileTree({ model.getItem(path)?.deselect(); } model.getItem(selectedPath)?.select(); - if (scrollSelectedPathIntoView) { - requestAnimationFrame(() => scrollPathIntoView(selectedPath)); + if (initialScrollPendingRef.current) { + initialScrollPendingRef.current = false; + scrollPathIntoView(selectedPath); } - }, [model, scrollPathIntoView, scrollSelectedPathIntoView, selectedPath]); + }, [model, scrollPathIntoView, selectedPath]); const handleTreeClick = useCallback( (event: MouseEvent) => { diff --git a/core/app/components/Panels.tsx b/core/app/components/Panels.tsx index a187da4e..9942d8d7 100644 --- a/core/app/components/Panels.tsx +++ b/core/app/components/Panels.tsx @@ -22,6 +22,7 @@ import { import { matchesShortcut } from '../../config/keymap.ts'; import type { CodiffKeymap } from '../../config/types.ts'; import type { RepositoryLoadError, ReviewComment } from '../../lib/app-types.ts'; +import type { DiffSearchFilters } from '../../lib/diff-search.ts'; import { buildReviewCommentsMarkdown } from '../../lib/review-comments.ts'; import type { ChangedFile, @@ -85,6 +86,63 @@ export function RepositoryChangeBanner({ ); } +export type RepositoryRefreshStatus = + | { phase: 'complete'; updated: boolean } + | { phase: 'failed'; reason: string } + | { phase: 'refreshing' }; + +export function RepositoryRefreshBanner({ + onRestartWalkthrough, + onRetry, + status, + walkthroughStale, +}: { + onRestartWalkthrough: () => void; + onRetry: () => void; + status: RepositoryRefreshStatus | null; + walkthroughStale: boolean; +}) { + const visible = walkthroughStale || status != null; + const failed = status?.phase === 'failed'; + + return ( +
+ + {walkthroughStale ? ( + <> + Walkthrough out of date. + The reviewed code changed. + + ) : status?.phase === 'refreshing' ? ( + Refreshing review… + ) : status?.phase === 'complete' ? ( + {status.updated ? 'Review updated.' : 'Review is up to date.'} + ) : failed ? ( + <> + Refresh failed. + {status.reason} + + ) : null} + + {walkthroughStale ? ( + + ) : failed ? ( + + ) : null} +
+ ); +} + export type { CodiffUpdateStatus as UpdateStatus } from '../../types.ts'; export function UpdatePill({ @@ -161,6 +219,30 @@ export function UpdatePill({ ); } +export function ReviewCommentsLoadBanner({ + onRetry, + reason, +}: { + onRetry: () => void; + reason: string | null; +}) { + return ( +
+ + Review comments unavailable. + {reason ?? ''} + + +
+ ); +} + export function WalkthroughOutdatedBanner({ onDismiss, reason, @@ -284,22 +366,26 @@ export function AgentUnavailablePanel({ export function DiffSearchPanel({ activeIndex, + filters, focusRequest, keymap, matchCount, onChange, onClose, + onFiltersChange, onNext, onPrevious, query, visible, }: { activeIndex: number; + filters: DiffSearchFilters; focusRequest: number; keymap: CodiffKeymap; matchCount: number; onChange: (query: string) => void; onClose: () => void; + onFiltersChange: (filters: DiffSearchFilters) => void; onNext: () => void; onPrevious: () => void; query: string; @@ -356,6 +442,26 @@ export function DiffSearchPanel({ {query.trim() ? (matchCount > 0 ? `${activeIndex + 1}/${matchCount}` : '0/0') : ''} +
+ {( + [ + ['additions', 'New lines'], + ['deletions', 'Old lines'], + ['unchanged', 'Unchanged lines'], + ] as const + ).map(([key, label]) => ( + + ))} +
) : null} - {!readOnly && onOpenFile ? ( + {onOpenFile ? ( ) : null} - {!comment.isReadOnly && onAskCodex ? ( + {isReviewDraft(comment) && onAskCodex ? ( ) : null} - {!comment.isReadOnly ? ( + {isReviewDraft(comment) ? (
@@ -1901,7 +1966,7 @@ function ReviewCommentThreadGroup({ focusEditorRef: (node: MarkdownEditorHandle | null) => void; identity: GitIdentity | null; keymap: CodiffKeymap; - onAskCodex?: (commentId: string) => void; + onAskCodex?: (comment: ReviewComment) => void; onCommentBlur: (comment: ReviewComment, body: string, flushDraft: () => void) => void; onCommentDraftChange?: (comment: Pick | null) => void; onCommentFocus: (comment: ReviewComment) => void; @@ -1937,8 +2002,8 @@ function ReviewCommentThreadGroup({ const canReplyToThread = supportsReviewCommentActions && threadId != null && - comments.some((comment) => comment.isReadOnly) && - !comments.some((comment) => !comment.isReadOnly) && + comments.some(isSubmittedReviewComment) && + !comments.some(isProviderCommentDraft) && !comments.some((comment) => comment.canReplyThread === false) && !effectiveThreadResolved && !resolving; @@ -1952,7 +2017,7 @@ function ReviewCommentThreadGroup({ onReplyToThread(threadId, lastComment); }, [lastComment, onReplyToThread, threadId]); - const handleResolve = useCallback(() => { + const handleResolve = () => { if (!threadId || resolving) { return; } @@ -1969,7 +2034,7 @@ function ReviewCommentThreadGroup({ threadId, }); }); - }, [effectiveThreadResolved, onResolveThread, resolving, threadId, threadResolved]); + }; const threadContent = ( <> @@ -2043,6 +2108,110 @@ function ReviewCommentThreadGroup({ ); } +export function ReviewCommentThreadList({ + agentId, + agentLabel, + comments, + focusCommentId, + focusCommentRequest, + identity, + keymap, + onAskCodex, + onCommentDraftChange, + onCreateReply, + onDeleteComment, + onResolveThread, + onSaveCommentEdit, + onSubmitComment, + onUpdateComment, + supportsReviewCommentActions, +}: { + agentId: 'codex' | 'claude' | 'opencode' | 'pi'; + agentLabel: string; + comments: ReadonlyArray; + focusCommentId: string | null; + focusCommentRequest: number; + identity: GitIdentity | null; + keymap: CodiffKeymap; + onAskCodex?: (comment: ReviewComment) => void; + onCommentDraftChange?: (comment: Pick | null) => void; + onCreateReply: (threadId: string, comment: ReviewComment) => void; + onDeleteComment: (commentId: string) => void; + onResolveThread?: (threadId: string, resolved: boolean) => Promise | void; + onSaveCommentEdit: (commentId: string, body: string) => Promise | void; + onSubmitComment: (commentId: string) => void; + onUpdateComment: (commentId: string, body: string) => void; + supportsReviewCommentActions: boolean; +}) { + const containerRef = useRef(null); + const focusEditorRef = useRef(null); + const setFocusEditorRef = useCallback((editor: MarkdownEditorHandle | null) => { + focusEditorRef.current = editor; + }, []); + + useEffect(() => { + if (!focusCommentId) { + return; + } + const element = [ + ...(containerRef.current?.querySelectorAll('[data-review-comment-id]') ?? []), + ].find((candidate) => candidate.dataset.reviewCommentId === focusCommentId); + element?.scrollIntoView?.({ block: 'center' }); + focusEditorRef.current?.focus({ preventScroll: true }); + }, [focusCommentId, focusCommentRequest]); + + return ( +
+ {groupReviewCommentsByThread(comments).map((group) => { + const root = group.comments[0]!; + return ( +
+
+ {root.filePath} + {getReviewCommentLineLabel(root)} + {root.isOutdated ? Outdated : null} + Code region unavailable +
+ { + onCommentDraftChange?.(null); + if (!isReviewDraft(comment)) { + return; + } + if (body.trim()) { + onUpdateComment(comment.id, body); + } else { + onDeleteComment(comment.id); + } + }} + onCommentDraftChange={onCommentDraftChange} + onCommentFocus={(comment) => + onCommentDraftChange?.({ body: comment.body, id: comment.id }) + } + onDeleteComment={onDeleteComment} + onReplyToThread={onCreateReply} + onResolveThread={onResolveThread} + onSaveCommentEdit={onSaveCommentEdit} + onSubmitComment={onSubmitComment} + onUpdateComment={onUpdateComment} + supportsReviewCommentActions={supportsReviewCommentActions} + /> +
+ ); + })} +
+ ); +} + function ReviewAnnotation({ agentId, agentLabel, @@ -2073,7 +2242,7 @@ function ReviewAnnotation({ focusCommentRequest: number; identity: GitIdentity | null; keymap: CodiffKeymap; - onAskCodex?: (commentId: string) => void; + onAskCodex?: (comment: ReviewComment) => void; onCommentBlur: (comment: ReviewComment, body: string, flushDraft: () => void) => void; onCommentDraftChange?: (comment: Pick | null) => void; onCommentFocus: (comment: ReviewComment) => void; @@ -2098,8 +2267,14 @@ function ReviewAnnotation({ focusCommentId != null && annotationComments.some((comment) => comment.id === focusCommentId); useEffect(() => { - if (hasFocusedComment) { - focusEditorRef.current?.focus(); + if (!hasFocusedComment) { + return; + } + threadRef.current?.scrollIntoView?.({ block: 'center' }); + if (focusEditorRef.current) { + focusEditorRef.current.focus(); + } else { + threadRef.current?.focus({ preventScroll: true }); } }, [focusCommentId, focusCommentRequest, hasFocusedComment]); @@ -2128,7 +2303,7 @@ function ReviewAnnotation({ const commentGroups = groupReviewCommentsByThread(annotationComments); return ( -
+
{commentGroups.map((group) => ( | undefined, + files: ReadonlyArray, + showWhitespace: boolean, +) => { + const projectedFiles = (blocks ?? createFileReviewBlocks(files)) + .map((block) => block.file) + .filter((file): file is ChangedFile => file != null) + .map((file) => ({ + fingerprint: file.fingerprint, + path: file.path, + sections: file.sections.map((section) => section.id), + })); + return JSON.stringify({ files: projectedFiles, showWhitespace }); +}; + const getBlockItemId = (block: FileReviewDiffBlock, section: DiffSection) => block.itemIdPrefix ? `${block.itemIdPrefix}:${getItemId(section)}` : getItemId(section); @@ -2319,11 +2510,15 @@ const dedupeReviewComments = ( const groupReviewCommentsBySection = (comments: ReadonlyArray) => { const map = new Map>(); for (const comment of comments) { - const list = map.get(comment.sectionId); + const sectionId = getReviewCommentRendererSectionId(comment); + if (!sectionId) { + continue; + } + const list = map.get(sectionId); if (list) { list.push(comment); } else { - map.set(comment.sectionId, [comment]); + map.set(sectionId, [comment]); } } return map; @@ -2490,6 +2685,23 @@ const getHunkSelectionRange = ( const isSameSelection = (a: SelectedLineRange, b: SelectedLineRange) => a.start === b.start && a.end === b.end && a.side === b.side; +const isReviewContextCandidate = (section: DiffSection) => + section.summary?.canLoad !== false && + section.loadState !== 'error' && + !section.binary && + section.patch.trim().length > 0 && + section.oldFile == null && + section.newFile == null; + +const disableNonLoadablePartialDiff = ( + fileDiff: FileDiffMetadata, + section: DiffSection, +): FileDiffMetadata => + fileDiff.isPartial && + (section.summary?.canLoad === false || section.loadState === 'error' || section.binary) + ? { ...fileDiff, isPartial: false } + : fileDiff; + export function ReviewCodeView({ activeSearchMatch, agentId, @@ -2524,13 +2736,13 @@ export function ReviewCodeView({ onFindDefinitions, onLoadImageContent, onLoadSection, - onLoadSectionContents, onOpenDefinition, onOpenFile, onRefreshMarkdown, onResolveThread = noopResolveThread, onSaveCommentEdit, onSelectPathFromScroll, + onSourceDescriptionCollapsedChange, onSubmitComment, onToggleCollapsed, onToggleViewed, @@ -2538,6 +2750,8 @@ export function ReviewCodeView({ onUpdateSourceDescription, onUpdateSourceTitle, onUploadSourceDescriptionAsset, + resolveImage, + resolveSectionContents, reviewIdentityByPath, scrollTarget, searchQuery, @@ -2546,7 +2760,9 @@ export function ReviewCodeView({ showWhitespace, source, sourceDescriptionActions, + sourceDescriptionCollapsed: controlledSourceDescriptionCollapsed, sourceDescriptionFooter, + sourceDescriptionFooterAside, supportsReviewCommentActions, theme = 'system', viewed, @@ -2579,20 +2795,20 @@ export function ReviewCodeView({ keymap: CodiffKeymap; loadingSectionIds: ReadonlySet; onActiveBlockChange?: (blockId: string) => void; - onAskCodex?: (commentId: string) => void; + onAskCodex?: (comment: ReviewComment) => void; onCommentDraftChange?: (comment: Pick | null) => void; - onCreateComment: (comment: Omit) => void; + onCreateComment: (comment: ReviewCommentCreation) => void; onDeleteComment: (commentId: string) => void; onFindDefinitions?: (request: DefinitionSearchRequest) => Promise; onLoadImageContent?: (request: DiffImageContentRequest) => Promise; - onLoadSection: (file: ChangedFile, section: DiffSection) => void; - onLoadSectionContents?: (file: ChangedFile, section: DiffSection) => Promise; + onLoadSection?: (file: ChangedFile, section: DiffSection) => void; onOpenDefinition?: (candidate: DefinitionCandidate) => void; onOpenFile?: (file: ChangedFile) => void; onRefreshMarkdown?: (file: ChangedFile, section: DiffSection) => Promise; onResolveThread?: (threadId: string, resolved: boolean) => Promise | void; onSaveCommentEdit: (commentId: string, body: string) => Promise | void; onSelectPathFromScroll: (viewer: CodeViewInstance) => void; + onSourceDescriptionCollapsedChange?: (collapsed: boolean) => void; onSubmitComment: (commentId: string) => void; onToggleCollapsed: (file: ChangedFile, isCollapsed: boolean, reviewKey: string) => void; onToggleViewed: (file: ChangedFile, isViewed: boolean, reviewIdentity: ReviewIdentity) => void; @@ -2600,15 +2816,22 @@ export function ReviewCodeView({ onUpdateSourceDescription?: (body: string) => Promise | void; onUpdateSourceTitle?: (title: string) => Promise | void; onUploadSourceDescriptionAsset?: (file: File) => Promise | string; + resolveImage?: (file: ChangedFile, section: DiffSection) => Promise; + resolveSectionContents?: ( + file: ChangedFile, + section: DiffSection, + ) => Promise; reviewIdentityByPath?: ReadonlyMap; scrollTarget: ReviewScrollTarget | null; searchQuery: string; selectedPath: string | null; showSourceDescription?: boolean; showWhitespace: boolean; - source: ReviewSource; + source: ResolvedReviewSource; sourceDescriptionActions?: ReactNode; + sourceDescriptionCollapsed?: boolean; sourceDescriptionFooter?: ReactNode; + sourceDescriptionFooterAside?: ReactNode; supportsReviewCommentActions: boolean; theme?: CodiffPreferences['theme']; viewed: Record; @@ -2675,62 +2898,69 @@ export function ReviewCodeView({ setDefinitionLookup(null); } const selectedLinesRef = useRef(null); - const commitMessageMetadata = source.type === 'commit' ? commitMetadata : null; - const shouldShowCommitMessage = commitMessageMetadata != null; - const shouldShowSourceDescription = showSourceDescription && source.type === 'pull-request'; - const sourceDescription = shouldShowCommitMessage - ? commitMessageMetadata.body.trim() - : shouldShowSourceDescription - ? (source.description?.trim() ?? '') - : ''; - const sourceDescriptionHasBody = sourceDescription.length > 0; - const sourceDescriptionHasContent = sourceDescriptionHasBody || shouldShowCommitMessage; + const sourceDescriptionModel = buildSourceDescriptionModel({ + commitMetadata, + showPullRequestDescription: showSourceDescription, + source, + }); + const shouldShowCommitMessage = sourceDescriptionModel?.kind === 'commit'; + const sourceDescription = sourceDescriptionModel?.body ?? ''; + const sourceDescriptionHasContent = sourceDescriptionModel != null; const canEditSourceDescription = - shouldShowSourceDescription && - source.canEditDescription === true && + sourceDescriptionModel?.kind === 'pull-request' && + sourceDescriptionModel.allowsBodyEdit && onUpdateSourceDescription != null; const canEditSourceTitle = - shouldShowSourceDescription && - (source.canEditTitle === true || source.canEditDescription === true) && + sourceDescriptionModel?.kind === 'pull-request' && + sourceDescriptionModel.allowsTitleEdit && onUpdateSourceTitle != null; - const sourceAuthor = shouldShowCommitMessage - ? getCommitDescriptionAuthor(commitMessageMetadata.author) - : shouldShowSourceDescription && source.author - ? getPullRequestDescriptionAuthor(source.author) - : undefined; - const sourceTitle = shouldShowCommitMessage - ? commitMessageMetadata.subject.trim() || commitMessageMetadata.shortRef - : shouldShowSourceDescription - ? (source.title?.trim() ?? '') - : ''; - const sourceDescriptionItemId = - shouldShowCommitMessage && source.type === 'commit' - ? `commit-message:${source.ref}` - : shouldShowSourceDescription && (sourceDescription || sourceTitle) - ? `source-description:${source.provider ?? ''}:${source.url}` - : null; - const sourceDescriptionLabel = shouldShowCommitMessage - ? 'Commit' - : source.type === 'pull-request' - ? getPullRequestDescriptionLabel(source) - : ''; - const sourceDescriptionAriaLabel = shouldShowCommitMessage - ? 'Preview commit message' - : 'Preview source description'; - const [collapsedSourceDescriptionItemId, setCollapsedSourceDescriptionItemId] = useState< - string | null - >(null); + const sourceAuthor = sourceDescriptionModel?.author; + const sourceTitle = sourceDescriptionModel?.title ?? ''; + const sourceDescriptionItemId = sourceDescriptionModel?.identity ?? null; + const sourceDescriptionLabel = sourceDescriptionModel?.label ?? ''; + const sourceDescriptionAriaLabel = sourceDescriptionModel?.ariaLabel ?? ''; + const [sourceDescriptionCollapseState, setSourceDescriptionCollapseState] = useState(() => ({ + collapsed: sourceDescriptionModel?.defaultCollapsed ?? false, + identity: sourceDescriptionModel?.identity ?? '', + })); + const uncontrolledSourceDescriptionCollapsed = sourceDescriptionModel + ? sourceDescriptionCollapseState.identity === sourceDescriptionModel.identity + ? sourceDescriptionCollapseState.collapsed + : sourceDescriptionModel.defaultCollapsed + : true; const sourceDescriptionCollapsed = - (!sourceDescriptionHasContent && !canEditSourceDescription) || - collapsedSourceDescriptionItemId === sourceDescriptionItemId; + controlledSourceDescriptionCollapsed ?? uncontrolledSourceDescriptionCollapsed; const toggleSourceDescriptionCollapsed = useCallback(() => { - setCollapsedSourceDescriptionItemId((current) => - current === sourceDescriptionItemId ? null : sourceDescriptionItemId, - ); - }, [sourceDescriptionItemId]); + if (!sourceDescriptionModel) { + return; + } + const next = !sourceDescriptionCollapsed; + if (onSourceDescriptionCollapsedChange) { + onSourceDescriptionCollapsedChange(next); + } else { + setSourceDescriptionCollapseState({ + collapsed: next, + identity: sourceDescriptionModel.identity, + }); + } + }, [onSourceDescriptionCollapsedChange, sourceDescriptionCollapsed, sourceDescriptionModel]); const stickyHeaderFrameRef = useRef(null); const reviewBlocks = useMemo(() => blocks ?? createFileReviewBlocks(files), [blocks, files]); + const codeProjectionKey = useMemo( + () => getReviewCodeProjectionKey(blocks, files, showWhitespace), + [blocks, files, showWhitespace], + ); + const codeViewContainerRef = useRef(null); + const appliedExpansionDigestByInstanceRef = useRef(new WeakMap()); + const [pendingContextHydration, setPendingContextHydration] = useState<{ + keys: ReadonlySet; + projectionKey: string; + }>(() => ({ keys: new Set(), projectionKey: codeProjectionKey })); + const pendingContextHydrationKeys = + pendingContextHydration.projectionKey === codeProjectionKey + ? pendingContextHydration.keys + : emptyPendingContextKeys; const commentLookup = useMemo(() => { const map = new Map(); for (const comment of comments) { @@ -2846,7 +3076,8 @@ export function ReviewCodeView({ const walkthroughNote = getBlockWalkthroughNote(block, walkthroughNotes); const blockCommentsBySection = groupReviewCommentsBySection(block.comments ?? []); - for (const [index, { fileDiff, section }] of sections.entries()) { + for (const [index, { fileDiff: parsedFileDiff, section }] of sections.entries()) { + const fileDiff = disableNonLoadablePartialDiff(parsedFileDiff, section); const baseItemId = getItemId(section); const id = getBlockItemId(block, section); nextItemBlockId.set(id, block.id); @@ -2854,8 +3085,7 @@ export function ReviewCodeView({ searchTargets.push({ fileDiff, itemId: id, path: file.path }); nextSearchTargetsByBaseItemId.set(baseItemId, searchTargets); const markdownPreview = getMarkdownPreviewContents(file, section, fileDiff); - const canRenderImage = - !isReadOnly && onLoadImageContent != null && canRenderImagePreview(file.path, section); + const canRenderImage = resolveImage != null && canRenderImagePreview(file.path, section); const canRenderMarkdown = markdownPreview != null; const canEditMarkdown = canRenderMarkdown && @@ -3073,7 +3303,7 @@ export function ReviewCodeView({ itemVersionByKey, markdownPreviewLayoutPassBySection, markdownPreviewSections, - onLoadImageContent, + resolveImage, reviewBlocks, selectedPath, showWhitespace, @@ -3083,6 +3313,106 @@ export function ReviewCodeView({ walkthroughNotes, ]); + const getSectionExpansionKey = useCallback( + (itemId: string) => { + const meta = itemMetadata.get(itemId); + return meta + ? reviewContextExpansionProjectionKey( + codeProjectionKey, + meta.file.fingerprint, + meta.section.id, + ) + : null; + }, + [codeProjectionKey, itemMetadata], + ); + + const restoreExpansionForItem = useCallback( + (item: CodeViewItem, instance: ContextExpansionInstance) => { + if (item.type !== 'diff') { + return; + } + const sectionKey = getSectionExpansionKey(item.id); + if (!sectionKey) { + return; + } + const state = reviewContextExpansionByProjection.get(sectionKey); + const marker = `${sectionKey}:${reviewContextExpansionDigest(state)}`; + if (appliedExpansionDigestByInstanceRef.current.get(instance) === marker) { + return; + } + appliedExpansionDigestByInstanceRef.current.set(instance, marker); + for (const [hunkIndex, region] of state ?? []) { + if (region.fromStart > 0) { + instance.expandHunk(hunkIndex, 'up', region.fromStart); + } + if (region.fromEnd > 0) { + instance.expandHunk(hunkIndex, 'down', region.fromEnd); + } + } + }, + [getSectionExpansionKey], + ); + + const recordExpansionClick = useCallback( + (event: MouseEvent) => { + const path = event.composedPath(); + const elements = path.filter( + (value): value is HTMLElement => + typeof value === 'object' && + value !== null && + 'hasAttribute' in value && + typeof value.hasAttribute === 'function', + ); + const separator = elements.find((element) => element.hasAttribute('data-expand-index')); + const hunkIndex = Number.parseInt(separator?.getAttribute('data-expand-index') ?? '', 10); + if (!Number.isFinite(hunkIndex)) { + return; + } + const direction = elements.some((element) => element.hasAttribute('data-expand-up')) + ? 'up' + : elements.some((element) => element.hasAttribute('data-expand-down')) + ? 'down' + : 'both'; + const expandAll = + event.shiftKey || + elements.some((element) => element.hasAttribute('data-expand-all-button')); + const viewer = codeViewRef.current?.getInstance(); + const renderedItem = viewer + ?.getRenderedItems() + .find((candidate) => path.includes(candidate.element)); + if (!renderedItem || renderedItem.type !== 'diff') { + return; + } + const sectionKey = getSectionExpansionKey(renderedItem.id); + if (!sectionKey) { + return; + } + const nextState = getReviewContextExpansionState( + reviewContextExpansionByProjection.get(sectionKey), + hunkIndex, + direction, + diffContextExpansionLineCount, + expandAll, + ); + reviewContextExpansionByProjection.set(sectionKey, nextState); + appliedExpansionDigestByInstanceRef.current.set( + renderedItem.instance, + `${sectionKey}:${reviewContextExpansionDigest(nextState)}`, + ); + }, + [getSectionExpansionKey], + ); + + useEffect(() => { + const container = codeViewContainerRef.current; + if (!container) { + return; + } + container.addEventListener('click', recordExpansionClick); + return () => container.removeEventListener('click', recordExpansionClick); + }, [recordExpansionClick]); + const codeViewItems = useMemo>>(() => { if (items.length > 0 || !sourceDescriptionItemId) { return items; @@ -3261,7 +3591,12 @@ export function ReviewCodeView({ }, []); const canCreateFileComments = - !isReadOnly && source.type === 'pull-request' && source.provider === 'gitlab'; + !isReadOnly && + source.type === 'pull-request' && + (source.provider === 'gitlab' || + source.provider === 'github' || + source.host === 'github.com' || + (!source.provider && !source.host)); const createFileComment = useCallback( (meta: CodeViewItemMetadata, itemId: string) => { @@ -3284,6 +3619,7 @@ export function ReviewCodeView({ onCreateComment({ anchor: 'file', filePath: meta.file.path, + ...(meta.section.range ? { position: { range: meta.section.range } } : {}), sectionId: meta.section.id, }); clearCommentLineHighlight(); @@ -3328,6 +3664,7 @@ export function ReviewCodeView({ onCreateComment({ filePath: meta.file.path, lineNumber: end, + ...(meta.section.range ? { position: { range: meta.section.range } } : {}), sectionId: meta.section.id, side: endSide, ...(end !== start ? { startLineNumber: start } : {}), @@ -3347,7 +3684,12 @@ export function ReviewCodeView({ // context on a patch-only diff; the partial FileDiffMetadata is hydrated in // place (see `parseSectionDiffWithOptions` for the identity contract). const loadDiffFiles = useMemo(() => { - if (!onLoadSectionContents || isReadOnly) { + const hasSupportedPartialSection = + resolveSectionContents != null && + reviewBlocks.some((block) => + block.file?.sections.some((section) => isReviewContextCandidate(section)), + ); + if (!hasSupportedPartialSection) { return undefined; } @@ -3359,9 +3701,37 @@ export function ReviewCodeView({ ); } - return loadSectionContents(target.file, target.section, onLoadSectionContents); + if (!resolveSectionContents) { + return Promise.reject( + new Error(`Full review context is unavailable for '${target.file.path}'.`), + ); + } + + return loadSectionContents(target.file, target.section, async (file, section) => { + const key = reviewContextExpansionProjectionKey( + codeProjectionKey, + file.fingerprint, + section.id, + ); + setPendingContextHydration((current) => ({ + keys: new Set(current.projectionKey === codeProjectionKey ? current.keys : []).add(key), + projectionKey: codeProjectionKey, + })); + try { + return await resolveSectionContents(file, section); + } finally { + setPendingContextHydration((current) => { + if (current.projectionKey !== codeProjectionKey) { + return current; + } + const next = new Set(current.keys); + next.delete(key); + return { keys: next, projectionKey: codeProjectionKey }; + }); + } + }); }; - }, [isReadOnly, onLoadSectionContents]); + }, [codeProjectionKey, resolveSectionContents, reviewBlocks]); const codeViewOptions: CodeViewOptions = useMemo( () => @@ -3457,7 +3827,7 @@ export function ReviewCodeView({ } if (shouldLoadDiffSectionContents(meta.section)) { - onLoadSection(meta.file, meta.section); + onLoadSection?.(meta.file, meta.section); return; } @@ -3474,6 +3844,7 @@ export function ReviewCodeView({ onCreateComment({ filePath: meta.file.path, lineNumber: line.lineNumber, + ...(meta.section.range ? { position: { range: meta.section.range } } : {}), sectionId: meta.section.id, side, }); @@ -3493,7 +3864,7 @@ export function ReviewCodeView({ createCommentForRange(range, context); }, - onPostRender: (node, _instance, _phase, context) => { + onPostRender: (node, instance, phase, context) => { const metadata = itemMetadata.get(context.item.id); const isWalkthroughHeaderItem = context.item.id.endsWith(':walkthrough-header'); node.classList.toggle('codiff-walkthrough-header-item', isWalkthroughHeaderItem); @@ -3518,6 +3889,22 @@ export function ReviewCodeView({ 'codiff-loading-summary-item', Boolean(metadata && loadingSectionIds.has(metadata.section.id)), ); + node.classList.toggle( + 'codiff-loading-context-item', + Boolean( + metadata && + pendingContextHydrationKeys.has( + reviewContextExpansionProjectionKey( + codeProjectionKey, + metadata.file.fingerprint, + metadata.section.id, + ), + ), + ), + ); + if (phase !== 'unmount' && 'expandHunk' in instance) { + restoreExpansionForItem(context.item, instance); + } if (definitionModifierActiveRef.current) { window.requestAnimationFrame(() => { if (definitionModifierActiveRef.current && node.isConnected) { @@ -3539,6 +3926,7 @@ export function ReviewCodeView({ [ bottomInset, cancelPendingEmptyCommentDeletes, + codeProjectionKey, createCommentForRange, diffStyle, isReadOnly, @@ -3548,6 +3936,8 @@ export function ReviewCodeView({ onCreateComment, onFindDefinitions, onLoadSection, + pendingContextHydrationKeys, + restoreExpansionForItem, selectedHeaderItemIds, source, sourceKey, @@ -3582,7 +3972,7 @@ export function ReviewCodeView({ } flushDraft(); clearCommentLineHighlight(); - if (!comment.isReadOnly && body.trim().length === 0) { + if (isReviewDraft(comment) && body.trim().length === 0) { const existingTimer = emptyCommentDeleteTimersRef.current.get(comment.id); if (existingTimer != null) { window.clearTimeout(existingTimer); @@ -3611,11 +4001,16 @@ export function ReviewCodeView({ (threadId: string, comment: ReviewComment) => { clearCommentLineHighlight(); cancelPendingEmptyCommentDeletes(); + const sectionId = getReviewCommentRendererSectionId(comment); + if (!sectionId) { + return; + } onCreateComment({ ...(isFileReviewComment(comment) ? { anchor: 'file' as const } : {}), filePath: comment.filePath, ...(comment.lineNumber != null ? { lineNumber: comment.lineNumber } : {}), - sectionId: comment.sectionId, + ...(comment.position ? { position: comment.position } : {}), + sectionId, ...(comment.side ? { side: comment.side } : {}), ...(comment.startLineNumber != null ? { startLineNumber: comment.startLineNumber } : {}), ...(comment.startSide ? { startSide: comment.startSide } : {}), @@ -4188,20 +4583,39 @@ export function ReviewCodeView({ {!sourceDescriptionCollapsed && (sourceDescriptionHasContent || canEditSourceDescription) ? (
- - {sourceDescriptionFooter ? ( -
{sourceDescriptionFooter}
- ) : null} + {sourceDescriptionFooter || sourceDescriptionFooterAside ? ( +
+
+ +
+ +
+ ) : ( + + )}
) : null}
@@ -4219,6 +4633,7 @@ export function ReviewCodeView({ sourceDescriptionAriaLabel, sourceDescriptionCollapsed, sourceDescriptionFooter, + sourceDescriptionFooterAside, sourceDescriptionHasContent, sourceDescriptionLabel, sourceTitle, @@ -4269,13 +4684,12 @@ export function ReviewCodeView({ ) => { if (annotation.metadata.type === 'image-preview') { const meta = itemMetadata.get(item.id); - return meta && onLoadImageContent ? ( + return meta && resolveImage ? ( ) : null; } @@ -4347,7 +4761,7 @@ export function ReviewCodeView({ markCommentLayoutChanged, onAskCodex, onCommentDraftChange, - onLoadImageContent, + resolveImage, onResolveThread, onSaveCommentEdit, onSubmitComment, @@ -4355,7 +4769,6 @@ export function ReviewCodeView({ renderComments, replyToThread, setMarkdownEditorRef, - source, supportsReviewCommentActions, ], ); @@ -4400,6 +4813,7 @@ export function ReviewCodeView({ const codeView = ( | null; + branchSource: Extract | null; commitFiles: ReadonlyArray; commitViewOpen: boolean; - currentSource: ReviewSource; + currentSource: ResolvedReviewSource | ReviewSource; files: ReadonlyArray; historyEntries: ReadonlyArray; historyHasMore: boolean; historyLoading: boolean; keymap: CodiffKeymap; mode: SidebarMode; + modes: ReadonlyArray>; narrativeNavigation: NarrativeNavigation; narrativeWalkthrough: NarrativeWalkthrough | null; onActivatePath: (path: string) => void; onLoadMoreHistory: () => void; + onModeChange: (mode: SidebarMode) => void; onSearchQueryChange: (query: string) => void; onSelectSource: (source: ReviewSource) => void; onShareWalkthrough?: () => void; @@ -81,6 +92,7 @@ export function Sidebar({ walkthroughError: WalkthroughError | null; walkthroughLoading: boolean; walkthroughProgress: { + generation: import('../../types.ts').WalkthroughGenerationProgress | null; phase: import('../../types.ts').WalkthroughProgressPhase | null; responseLabelIndex: number; stageRevision: number; @@ -115,18 +127,21 @@ export function Sidebar({ return ( <> -
- onSearchQueryChange(event.currentTarget.value)} - placeholder={mode === 'history' ? 'Filter history' : 'Filter files'} - ref={searchInputRef} - spellCheck={false} - type="search" - value={searchQuery} - /> -
+ + {mode === 'tree' ? ( +
+ onSearchQueryChange(event.currentTarget.value)} + placeholder="Filter files" + ref={searchInputRef} + spellCheck={false} + type="search" + value={searchQuery} + /> +
+ ) : null} {mode === 'history' ? ( ) : mode === 'walkthrough' && narrativeWalkthrough ? ( @@ -232,7 +247,7 @@ const shortDate = (timestamp: number) => { return `${Math.floor(months / 12)}y ago`; }; -function HistorySidebar({ +export function HistorySidebar({ branchSource, currentSource, entries, @@ -241,60 +256,47 @@ function HistorySidebar({ onLoadMore, onSelectSource, pullRequestSource, - searchQuery, }: { - branchSource: Extract | null; - currentSource: ReviewSource; + branchSource: Extract | null; + currentSource: ResolvedReviewSource | ReviewSource; entries: ReadonlyArray; hasMore: boolean; loading: boolean; onLoadMore: () => void; onSelectSource: (source: ReviewSource) => void; pullRequestSource: PullRequestSource | null; - searchQuery: string; }) { const currentSourceKey = getSourceKey(currentSource); - const normalizedQuery = searchQuery.trim().toLowerCase(); const listRef = useRef(null); const rows = useMemo(() => { const commitRows = entries.map((entry) => ({ author: entry.author, committedAt: entry.committedAt, gravatarUrl: entry.gravatarUrl, - key: `commit:${entry.ref}`, + key: `commit:${entry.sha}`, kind: 'entry' as const, - ref: entry.ref, + ref: entry.sha, scope: entry.scope, - source: { ref: entry.ref, type: 'commit' } satisfies ReviewSource, + source: { ref: entry.sha, type: 'commit' } satisfies ReviewSource, subject: entry.subject, })); - const matchesQuery = (row: (typeof commitRows)[number]) => - !normalizedQuery || - row.subject.toLowerCase().includes(normalizedQuery) || - row.ref.toLowerCase().includes(normalizedQuery) || - row.author.toLowerCase().includes(normalizedQuery); - if (pullRequestSource) { const hasScopedRows = commitRows.some((row) => row.scope != null); - const pullRequestRows = commitRows - .filter((row) => (hasScopedRows ? row.scope === 'pull-request' : row.scope == null)) - .filter(matchesQuery); - const baseRows = hasScopedRows - ? commitRows.filter((row) => row.scope === 'base').filter(matchesQuery) - : []; + const pullRequestRows = commitRows.filter((row) => + hasScopedRows ? row.scope === 'pull-request' : row.scope == null, + ); + const baseRows = hasScopedRows ? commitRows.filter((row) => row.scope === 'base') : []; return [ - !normalizedQuery - ? { - author: null, - committedAt: null, - gravatarUrl: undefined, - key: getSourceKey(pullRequestSource), - kind: 'entry' as const, - ref: pullRequestSource.number ? `PR #${pullRequestSource.number}` : 'PR', - source: pullRequestSource satisfies ReviewSource, - subject: pullRequestSource.title || 'Pull Request', - } - : null, + { + author: null, + committedAt: null, + gravatarUrl: undefined, + key: getSourceKey(pullRequestSource), + kind: 'entry' as const, + ref: pullRequestSource.number ? `PR #${pullRequestSource.number}` : 'PR', + source: pullRequestSource satisfies ReviewSource, + subject: pullRequestSource.title || 'Pull Request', + }, { key: 'history-section:pull-request', kind: 'section' as const, @@ -307,61 +309,53 @@ function HistorySidebar({ } if (branchSource) { - const localRows = commitRows.filter(matchesQuery); + const localRows = commitRows; return [ - !normalizedQuery - ? { - key: 'history-section:review-scope', - kind: 'section' as const, - label: 'Review scope', - } - : null, - !normalizedQuery - ? { - author: null, - committedAt: null, - gravatarUrl: undefined, - key: 'working-tree', - kind: 'entry' as const, - ref: '', - source: { type: 'working-tree' } satisfies ReviewSource, - subject: 'Uncommitted changes', - } - : null, - !normalizedQuery - ? { - author: null, - committedAt: null, - gravatarUrl: undefined, - key: getSourceKey({ - baseRef: branchSource.baseRef, - headRef: branchSource.headRef, - ref: branchSource.ref, - type: 'branch-working-tree', - }), - kind: 'entry' as const, - ref: 'branch+', - source: { - baseRef: branchSource.baseRef, - headRef: branchSource.headRef, - ref: branchSource.ref, - type: 'branch-working-tree', - } satisfies ReviewSource, - subject: `All changes vs ${branchSource.ref}`, - } - : null, - !normalizedQuery - ? { - author: null, - committedAt: null, - gravatarUrl: undefined, - key: getSourceKey(branchSource), - kind: 'entry' as const, - ref: 'branch', - source: branchSource satisfies ReviewSource, - subject: `Committed only vs ${branchSource.ref}`, - } - : null, + { + key: 'history-section:review-scope', + kind: 'section' as const, + label: 'Review scope', + }, + { + author: null, + committedAt: null, + gravatarUrl: undefined, + key: 'working-tree', + kind: 'entry' as const, + ref: '', + source: { type: 'working-tree' } satisfies ReviewSource, + subject: 'Uncommitted changes', + }, + { + author: null, + committedAt: null, + gravatarUrl: undefined, + key: getSourceKey({ + baseSha: branchSource.baseSha, + headSha: branchSource.headSha, + ref: branchSource.ref, + type: 'branch-working-tree', + }), + kind: 'entry' as const, + ref: 'branch+', + source: { + baseSha: branchSource.baseSha, + headSha: branchSource.headSha, + ref: branchSource.ref, + type: 'branch-working-tree', + } satisfies ReviewSource, + subject: `All changes vs ${branchSource.ref}`, + }, + { + author: null, + committedAt: null, + gravatarUrl: undefined, + key: getSourceKey(branchSource), + kind: 'entry' as const, + ref: 'branch', + source: branchSource satisfies ReviewSource, + subject: `Committed only vs ${branchSource.ref}`, + }, localRows.length > 0 ? { key: 'history-section:branch', @@ -373,33 +367,30 @@ function HistorySidebar({ ].filter((row): row is NonNullable => row != null); } - const localRows = commitRows.filter(matchesQuery); return [ - !normalizedQuery - ? { - author: null, - committedAt: null, - gravatarUrl: undefined, - key: 'working-tree', - kind: 'entry' as const, - ref: '', - source: { type: 'working-tree' } satisfies ReviewSource, - subject: 'Uncommitted changes', - } - : null, - ...localRows, - ].filter((row): row is NonNullable => row != null); - }, [branchSource, entries, normalizedQuery, pullRequestSource]); + { + author: null, + committedAt: null, + gravatarUrl: undefined, + key: 'working-tree', + kind: 'entry' as const, + ref: '', + source: { type: 'working-tree' } satisfies ReviewSource, + subject: 'Uncommitted changes', + }, + ...commitRows, + ]; + }, [branchSource, entries, pullRequestSource]); const maybeLoadMore = useCallback(() => { const element = listRef.current; - if (!element || loading || !hasMore || normalizedQuery) { + if (!element || loading || !hasMore) { return; } if (element.scrollHeight - element.scrollTop - element.clientHeight < 120) { onLoadMore(); } - }, [hasMore, loading, normalizedQuery, onLoadMore]); + }, [hasMore, loading, onLoadMore]); return (
diff --git a/core/app/components/merge-request/GeneralComments.tsx b/core/app/components/merge-request/GeneralComments.tsx index 12c863c4..f4b29375 100644 --- a/core/app/components/merge-request/GeneralComments.tsx +++ b/core/app/components/merge-request/GeneralComments.tsx @@ -1,5 +1,7 @@ import { MarkdownEditor, type MarkdownEditorHandle } from '@nkzw/mdx-editor'; import useRelativeTime from '@nkzw/use-relative-time'; +import { ArrowSquareOutIcon as ArrowSquareOut } from '@phosphor-icons/react/ArrowSquareOut'; +import { CaretDownIcon as CaretDown } from '@phosphor-icons/react/CaretDown'; import { ChatCircleIcon as ChatCircle } from '@phosphor-icons/react/ChatCircle'; import { X } from 'lucide-react'; import { @@ -13,32 +15,20 @@ import { } from 'react'; import { matchesShortcut } from '../../../config/keymap.ts'; import type { CodiffKeymap } from '../../../config/types.ts'; +import type { RenderedSubmittedReviewComment } from '../../../lib/app-types.ts'; +import { getReviewCommentLineLabel } from '../../../lib/review-comments.ts'; import type { GitIdentity, - PullRequestExistingReviewComment, PullRequestGeneralComment, PullRequestGeneralCommentThread, - PullRequestReviewComment, ReviewAuthor, + ReviewCommenting, } from '../../../types.ts'; import { Avatar } from '../Avatar.tsx'; import { Button } from '../Button.tsx'; import { ReadOnlyMarkdownView } from '../ReadOnlyMarkdownView.tsx'; import { ResolvedThreadDisclosure } from '../ResolvedThreadDisclosure.tsx'; -export type ReviewCommenting = { - canComment: boolean; - onDeleteComment: (commentId: string) => Promise; - onDeleteGeneralComment: (commentId: string) => Promise; - onReplyGeneralComment: (threadId: string, body: string) => Promise; - onResolveDiscussion: (discussionId: string, resolved: boolean) => Promise; - onSignIn: () => Promise | void; - onSubmitComment: (comment: PullRequestReviewComment) => Promise; - onSubmitGeneralComment: (body: string) => Promise; - onUpdateComment: (commentId: string, body: string) => Promise; - onUpdateGeneralComment: (commentId: string, body: string) => Promise; -}; - const getAuthorDisplayName = (author: ReviewAuthor) => author.name || author.login; const getGeneralCommentElementId = (commentId: string) => `general-comment:${commentId}`; @@ -108,10 +98,12 @@ export function ReadOnlyGeneralCommentCard({ className = '', comment, focused = false, + permalinkLabel = 'View on provider', }: { className?: string; comment: PullRequestGeneralComment; focused?: boolean; + permalinkLabel?: string; }) { const displayName = getAuthorDisplayName(comment.author); const classes = ['review-comment', 'general-comment-card', focused ? 'focused' : '', className] @@ -123,8 +115,25 @@ export function ReadOnlyGeneralCommentCard({
- {displayName} + {comment.author.url ? ( + + {displayName} + + ) : ( + {displayName} + )} {comment.submittedAt ? : null} + {comment.url ? ( + + {permalinkLabel} + + + ) : null}
void; onSaveEdit: () => void; onStartEdit: (comment: PullRequestGeneralComment) => void; + permalinkLabel: string; }) { const displayName = getAuthorDisplayName(comment.author); const canSaveEdit = editing && !editSubmitting && Boolean(editDraft.trim()); @@ -212,11 +227,29 @@ function GeneralCommentCard({
- {displayName} + {comment.author.url ? ( + + {displayName} + + ) : ( + {displayName} + )} {comment.submittedAt ? : null} + {comment.url ? ( + + {permalinkLabel} + + + ) : null} {editing ? ( ) : null} - {comment.canDelete ? ( + {canDelete ? (
); } @@ -500,6 +543,101 @@ export function SidebarGeneralCommentList({ ); } +export function SidebarCommentSection({ + children, + count, + title, +}: { + children: ReactNode; + count: number; + title: string; +}) { + const [expanded, setExpanded] = useState(true); + return ( +
+ + {expanded ?
{children}
: null} +
+ ); +} + +export function SidebarInlineReviewCommentList({ + comments, + focusedCommentId, + onActivateComment, + permalinkLabel, +}: { + comments: ReadonlyArray; + focusedCommentId: string | null; + onActivateComment: (commentId: string) => void; + permalinkLabel: string; +}) { + if (comments.length === 0) { + return
No inline review comments.
; + } + + return ( +
+ {comments.map((comment) => { + const displayName = getAuthorDisplayName(comment.author); + return ( + + ); + })} +
+ ); +} + function GeneralCommentComposer({ disabled, draft, @@ -580,6 +718,7 @@ function GeneralCommentComposer({ export function MergeRequestCommentsView({ canComment, commenting, + commentPermalinkLabel, draft, editDraft, editError, @@ -589,6 +728,7 @@ export function MergeRequestCommentsView({ focusedCommentId, focusedCommentRequest, gitIdentity, + inlineCommentCount, keymap, onCancelEdit, onChangeDraft, @@ -604,6 +744,7 @@ export function MergeRequestCommentsView({ }: { canComment: boolean; commenting?: ReviewCommenting; + commentPermalinkLabel: string; draft: string; editDraft: string; editError: string | null; @@ -613,6 +754,7 @@ export function MergeRequestCommentsView({ focusedCommentId: string | null; focusedCommentRequest: number; gitIdentity: GitIdentity | null; + inlineCommentCount: number; keymap: CodiffKeymap; onCancelEdit: () => void; onChangeDraft: (draft: string) => void; @@ -650,7 +792,10 @@ export function MergeRequestCommentsView({
{threads.map((thread) => ( { - void commenting?.onDeleteGeneralComment(commentId).catch((error: unknown) => { + void commenting?.onDeleteGeneralComment?.(commentId).catch((error: unknown) => { window.alert(error instanceof Error ? error.message : String(error)); }); }} onReply={(threadId, body) => - commenting?.onReplyGeneralComment(threadId, body) ?? + commenting?.onReplyGeneralComment?.(threadId, body) ?? Promise.reject(new Error('Replying is unavailable.')) } onResolve={(threadId, resolved) => onResolveDiscussion?.(threadId, resolved) ?? - commenting?.onResolveDiscussion(threadId, resolved) ?? + commenting?.onResolveDiscussion?.(threadId, resolved) ?? Promise.reject(new Error('Resolving is unavailable.')) } onSaveEdit={onSaveEdit} onStartEdit={onStartEdit} + permalinkLabel={commentPermalinkLabel} thread={thread} /> ))} @@ -684,8 +830,14 @@ export function MergeRequestCommentsView({ ) : (
- No comments yet - Add a comment to start the discussion. + + {inlineCommentCount > 0 ? 'No overview comments yet' : 'No review comments yet'} + + + {inlineCommentCount > 0 + ? 'Inline review comments are available in Tree.' + : 'Add inline feedback from Tree to start the review.'} +
)} @@ -700,7 +852,7 @@ export function MergeRequestCommentsView({ onSubmit={onSubmit} submitting={submitting} /> - ) : commenting ? ( + ) : commenting?.onSignIn ? (
); @@ -109,7 +121,10 @@ function SupportingFilesStop({ const current = navigation.mode === 'support'; const isDone = navigation.supportVisited && !current; const fileRows = formatWalkthroughFileLineRows([ - ...walkthroughView.support.flatMap((item) => item.hunks), + ...resolveWalkthroughFileLineItems( + walkthroughView.support.flatMap((item) => item.hunks), + files, + ), ...uncoveredFiles, ]); return ( @@ -200,6 +215,7 @@ export function NarrativeSidebar({ {chapter.stops.map((stop) => ( + <> - - {navigation.mode === 'commit' ? ( - - ) : walkthroughView.sequence.length > 0 ? ( - renderDiffBlocks(reviewBlocks, reviewBlockScrollTarget, handleActiveBlockChange) - ) : ( - renderDiffBlocks(reviewBlocks, reviewBlockScrollTarget, handleActiveBlockChange) - )} - - {navigation.mode === 'commit' ? null : completionAction ? ( - - ) : navigation.mode === 'stop' && next ? ( - - ) : navigation.mode === 'stop' && supportAvailable ? ( - + ) : navigation.mode === 'stop' && next ? ( + + ) : navigation.mode === 'stop' && supportAvailable ? ( + - ) : navigation.mode === 'stop' && committable ? ( - + ) : navigation.mode === 'stop' && committable ? ( + - ) : navigation.mode === 'support' && committable ? ( - + ) : navigation.mode === 'support' && committable ? ( + - ) : navigation.mode === 'support' ? ( - + ) : navigation.mode === 'support' ? ( + - ) : null} -
+ + ) : null} +
+ ); } diff --git a/core/app/components/walkthrough/WalkthroughDiffSurface.tsx b/core/app/components/walkthrough/WalkthroughDiffSurface.tsx index 29a7b32f..b0513709 100644 --- a/core/app/components/walkthrough/WalkthroughDiffSurface.tsx +++ b/core/app/components/walkthrough/WalkthroughDiffSurface.tsx @@ -35,6 +35,7 @@ export function WalkthroughDiffSurface({ scrollTarget, sourceDescriptionActions, sourceDescriptionFooter, + sourceDescriptionFooterAside, }: { allowViewedToggle?: boolean; blocks: ReadonlyArray; @@ -44,6 +45,7 @@ export function WalkthroughDiffSurface({ scrollTarget: WalkthroughBlockScrollTarget | null; sourceDescriptionActions?: ReviewCodeViewProps['sourceDescriptionActions']; sourceDescriptionFooter?: ReviewCodeViewProps['sourceDescriptionFooter']; + sourceDescriptionFooterAside?: ReviewCodeViewProps['sourceDescriptionFooterAside']; }) { return (
@@ -62,6 +64,9 @@ export function WalkthroughDiffSurface({ showSourceDescription sourceDescriptionActions={sourceDescriptionActions ?? reviewProps.sourceDescriptionActions} sourceDescriptionFooter={sourceDescriptionFooter ?? reviewProps.sourceDescriptionFooter} + sourceDescriptionFooterAside={ + sourceDescriptionFooterAside ?? reviewProps.sourceDescriptionFooterAside + } walkthroughNotes={emptyWalkthroughNotes} />
diff --git a/core/app/components/walkthrough/WalkthroughProgress.tsx b/core/app/components/walkthrough/WalkthroughProgress.tsx index a116272b..64e24aca 100644 --- a/core/app/components/walkthrough/WalkthroughProgress.tsx +++ b/core/app/components/walkthrough/WalkthroughProgress.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react'; -import type { WalkthroughProgressPhase } from '../../../types.ts'; +import type { WalkthroughGenerationProgress, WalkthroughProgressPhase } from '../../../types.ts'; export const walkthroughResponseLabels = [ 'Building walkthrough…', @@ -15,12 +15,35 @@ export const nextWalkthroughResponseLabelIndex = (current: number) => const TIMER_THRESHOLD_SECONDS = 3; +const unitStatusLabel = ( + status: NonNullable[number]['status'], +) => { + switch (status) { + case 'ready': + return 'done'; + case 'generating': + return 'generating'; + case 'preparing': + return 'preparing'; + case 'failed': + return 'failed'; + default: + return 'pending'; + } +}; + export function WalkthroughProgress({ + detail, + label: labelOverride, phase, + progress, responseLabelIndex, stageRevision, }: { + detail?: string | null; + label?: string; phase: WalkthroughProgressPhase | null; + progress?: WalkthroughGenerationProgress | null; responseLabelIndex: number; stageRevision: number; }) { @@ -40,20 +63,55 @@ export function WalkthroughProgress({ const elapsedSeconds = timerState.stageRevision === stageRevision ? timerState.elapsedSeconds : 0; const showTimer = elapsedSeconds >= TIMER_THRESHOLD_SECONDS; const label = - phase === 'agent-generation' + labelOverride ?? + (phase === 'agent-generation' ? 'Analyzing changes…' : phase === 'response-received' ? walkthroughResponseLabels[Math.abs(responseLabelIndex) % walkthroughResponseLabels.length] - : 'Generating walkthrough…'; + : 'Generating walkthrough…'); + const summary = progress?.summary ?? detail ?? null; + const units = progress?.units ?? []; + const counts = + progress?.total != null + ? `${progress.completed ?? units.filter((unit) => unit.status === 'ready').length}/${progress.total}` + : null; return ( - {label} - - {showTimer ? `${elapsedSeconds}s` : '0s'} + + + + {label} + {counts ? ` · ${counts}` : ''} + {summary ? ` · ${summary}` : ''} + + + + {units.length > 0 ? ( +
    + {units.map((unit) => ( +
  • + + {unitStatusLabel(unit.status)} + + {unit.label} + {unit.status === 'failed' && unit.detail ? ( + {unit.detail} + ) : null} +
  • + ))} +
+ ) : null}
); diff --git a/core/app/hooks/useAppCommands.ts b/core/app/hooks/useAppCommands.ts index d6173bb8..b2c0d9fa 100644 --- a/core/app/hooks/useAppCommands.ts +++ b/core/app/hooks/useAppCommands.ts @@ -1,20 +1,16 @@ import { useEffect, useRef, useState, type RefObject } from 'react'; -import type { ReviewComment, ReviewIdentity, SidebarMode } from '../../lib/app-types.ts'; +import type { ReviewIdentity, SidebarMode } from '../../lib/app-types.ts'; import { createCommandRegistry, type Command } from '../../lib/command-registry.ts'; import type { ReviewCommandTarget } from '../../lib/review-command-target.ts'; -import { buildReviewCommentsMarkdown } from '../../lib/review-comments.ts'; import { isReviewIdentityViewed } from '../../lib/review-identity.ts'; -import type { - ChangedFile, - CodiffPreferences, - OpenReviewSourceKind, - RepositoryState, -} from '../../types.ts'; +import type { ChangedFile, CodiffPreferences, OpenReviewSourceKind } from '../../types.ts'; type UseAppCommandsOptions = { changeSidebarMode: (mode: SidebarMode) => void; + copyPendingCommentsLabel: string; focusFileFilter: () => void; getReviewCommandTarget: () => ReviewCommandTarget | null; + onCopyPendingComments: () => string; onOpenDiffSearch: () => void; onOpenReviewSource: (kind: OpenReviewSourceKind) => void; onOpenSelectedFile: () => void; @@ -23,15 +19,15 @@ type UseAppCommandsOptions = { onToggleViewed: (file: ChangedFile, isViewed: boolean, reviewIdentity: ReviewIdentity) => void; onToggleWordWrap: () => void; preferencesRef: RefObject; - reviewCommentsRef: RefObject>; - stateRef: RefObject; viewedRef: RefObject>; }; export function useAppCommands({ changeSidebarMode, + copyPendingCommentsLabel, focusFileFilter, getReviewCommandTarget, + onCopyPendingComments, onOpenDiffSearch, onOpenReviewSource, onOpenSelectedFile, @@ -40,8 +36,6 @@ export function useAppCommands({ onToggleViewed, onToggleWordWrap, preferencesRef, - reviewCommentsRef, - stateRef, viewedRef, }: UseAppCommandsOptions) { const registryRef = useRef(createCommandRegistry()); @@ -101,37 +95,17 @@ export function useAppCommands({ }), registry.register({ execute: () => { - const currentState = stateRef.current; - if (!currentState) { - return; - } - - const markdown = buildReviewCommentsMarkdown( - currentState.files, - reviewCommentsRef.current, - preferencesRef.current.showWhitespace, - preferencesRef.current.reviewCommentsPrefix, - ); + const markdown = onCopyPendingComments(); if (markdown) { void navigator.clipboard.writeText(markdown); } }, id: 'copy-comments', - title: 'Copy Review Comments', + title: copyPendingCommentsLabel, }), registry.register({ execute: () => { - const currentState = stateRef.current; - if (!currentState) { - return; - } - - const markdown = buildReviewCommentsMarkdown( - currentState.files, - reviewCommentsRef.current, - preferencesRef.current.showWhitespace, - preferencesRef.current.reviewCommentsPrefix, - ); + const markdown = onCopyPendingComments(); if (markdown) { void navigator.clipboard.writeText(markdown).then(() => { window.close(); @@ -141,7 +115,7 @@ export function useAppCommands({ } }, id: 'copy-comments-and-close', - title: 'Copy Review Comments and Close', + title: `${copyPendingCommentsLabel} and Close`, }), registry.register({ description: () => getReviewCommandTarget()?.file.path ?? null, @@ -238,8 +212,10 @@ export function useAppCommands({ }; }, [ changeSidebarMode, + copyPendingCommentsLabel, focusFileFilter, getReviewCommandTarget, + onCopyPendingComments, onOpenDiffSearch, onOpenReviewSource, onOpenSelectedFile, @@ -248,8 +224,6 @@ export function useAppCommands({ onToggleViewed, onToggleWordWrap, preferencesRef, - reviewCommentsRef, - stateRef, viewedRef, ]); diff --git a/core/app/hooks/useAppKeyboardShortcuts.ts b/core/app/hooks/useAppKeyboardShortcuts.ts index ab174994..f1987438 100644 --- a/core/app/hooks/useAppKeyboardShortcuts.ts +++ b/core/app/hooks/useAppKeyboardShortcuts.ts @@ -4,11 +4,12 @@ import type { CodiffKeymap } from '../../config/types.ts'; import { isNativeInputTarget } from '../../lib/keyboard.ts'; type UseAppKeyboardShortcutsOptions = { + enabled?: boolean; keymap: CodiffKeymap; navigateHunks: (direction: 1 | -1) => void; onFocusFileFilter: () => void; onOpenDiffSearch: () => void; - onOpenSelectedFile: () => void; + onOpenSelectedFile?: () => void; onToggleSidebar: () => void; onToggleWordWrap: () => void; shouldDeferHunkNavigation: () => boolean; @@ -16,6 +17,7 @@ type UseAppKeyboardShortcutsOptions = { }; export function useAppKeyboardShortcuts({ + enabled = true, keymap, navigateHunks, onFocusFileFilter, @@ -31,6 +33,9 @@ export function useAppKeyboardShortcuts({ const closeCommandBar = useCallback(() => setCommandBarVisible(false), []); useEffect(() => { + if (!enabled) { + return; + } const handleKeyDown = (event: KeyboardEvent) => { if (matchesShortcut(event, keymap, 'commandBar')) { event.preventDefault(); @@ -52,7 +57,11 @@ export function useAppKeyboardShortcuts({ onOpenDiffSearch(); return; } - if (!isNativeInputTarget(event.target) && matchesShortcut(event, keymap, 'openFile')) { + if ( + onOpenSelectedFile && + !isNativeInputTarget(event.target) && + matchesShortcut(event, keymap, 'openFile') + ) { event.preventDefault(); onOpenSelectedFile(); return; @@ -85,6 +94,7 @@ export function useAppKeyboardShortcuts({ window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [ + enabled, keymap, navigateHunks, onFocusFileFilter, @@ -97,6 +107,9 @@ export function useAppKeyboardShortcuts({ ]); useEffect(() => { + if (!enabled) { + return; + } const handleKeyDown = (event: KeyboardEvent) => { if (event.repeat || isNativeInputTarget(event.target)) { return; @@ -123,7 +136,7 @@ export function useAppKeyboardShortcuts({ window.removeEventListener('keyup', handleKeyUp); window.removeEventListener('blur', handleBlur); }; - }, [keymap]); + }, [enabled, keymap]); return { closeCommandBar, diff --git a/core/app/hooks/useAppReviewComments.ts b/core/app/hooks/useAppReviewComments.ts index 7b102409..c9433b10 100644 --- a/core/app/hooks/useAppReviewComments.ts +++ b/core/app/hooks/useAppReviewComments.ts @@ -1,53 +1,47 @@ -import { useCallback, useState, type RefObject } from 'react'; -import type { ReviewComment } from '../../lib/app-types.ts'; +import { useCallback, useState, type Dispatch, type RefObject, type SetStateAction } from 'react'; +import type { + LocalReviewNote, + ProviderCommentDraft, + ReviewComment, + ReviewDraft, +} from '../../lib/app-types.ts'; import { - getPendingPullRequestReviewComments, getReviewCommentRangeProps, - toPullRequestReviewComment, + isLocalReviewNote, + isProviderCommentDraft, + isProviderInlineComment, + isReviewDraft, } from '../../lib/review-comments.ts'; -import type { - PullRequestReviewEvent, - PullRequestReviewStatus, - RepositoryState, - ReviewAssistantRequest, -} from '../../types.ts'; +import type { RepositoryState, ReviewAssistantRequest } from '../../types.ts'; import { useReviewCommentDrafts } from './useReviewCommentDrafts.ts'; type UseAppReviewCommentsOptions = { - isReviewActionDisabled: ( - reviewStatus: PullRequestReviewStatus | undefined, - event: PullRequestReviewEvent, - ) => boolean; + draftKind: ReviewDraft['kind']; + initialReviewComments?: ReadonlyArray; onCommentFileChange: (filePath: string) => void; stateRef: RefObject; }; export function useAppReviewComments({ - isReviewActionDisabled, + draftKind, + initialReviewComments = [], onCommentFileChange, stateRef, }: UseAppReviewCommentsOptions) { - const [reviewComments, setReviewComments] = useState>([]); - const [pullRequestReviewSubmitting, setPullRequestReviewSubmitting] = - useState(null); + const [reviewComments, setReviewComments] = + useState>(initialReviewComments); const commentDrafts = useReviewCommentDrafts({ comments: reviewComments, + draftKind, onCommentFileChange, setComments: setReviewComments, }); - const { - activeReviewCommentDraftRef, - activeReviewCommentDraftState, - clearCommentFocus, - reviewCommentsRef, - updateActiveReviewCommentDraft, - } = commentDrafts; const updateCodexReply = useCallback( - (commentId: string, filePath: string, codexReply: NonNullable) => { + (commentId: string, filePath: string, codexReply: NonNullable) => { setReviewComments((current) => current.map((comment) => - comment.id === commentId + comment.id === commentId && isReviewDraft(comment) ? { ...comment, codexReply, @@ -60,33 +54,11 @@ export function useAppReviewComments({ [onCommentFileChange], ); - const updateRemoteSubmit = useCallback( - (commentId: string, remoteSubmit: ReviewComment['remoteSubmit']) => { - setReviewComments((current) => - current.map((comment) => - comment.id === commentId - ? { - ...comment, - remoteSubmit, - } - : comment, - ), - ); - const comment = reviewCommentsRef.current.find((candidate) => candidate.id === commentId); - if (comment) { - onCommentFileChange(comment.filePath); - } - }, - [onCommentFileChange, reviewCommentsRef], - ); - const askCodex = useCallback( - (commentId: string) => { + (comment: ReviewDraft) => { const currentState = stateRef.current; - const comment = reviewCommentsRef.current.find((candidate) => candidate.id === commentId); if ( !currentState || - !comment || comment.body.trim().length === 0 || comment.codexReply?.status === 'loading' ) { @@ -130,134 +102,41 @@ export function useAppReviewComments({ }); }); }, - [reviewCommentsRef, stateRef, updateCodexReply], + [stateRef, updateCodexReply], ); - const submitPullRequestComment = useCallback( - (commentId: string) => { - const currentState = stateRef.current; - const comment = reviewCommentsRef.current.find((candidate) => candidate.id === commentId); - if ( - currentState?.source.type !== 'pull-request' || - !comment || - comment.body.trim().length === 0 || - comment.remoteSubmit?.status === 'submitting' - ) { - return; - } - - updateRemoteSubmit(comment.id, { status: 'submitting' }); - updateActiveReviewCommentDraft(null); - void window.codiff - .submitPullRequestComment({ - comment: toPullRequestReviewComment(comment), - source: currentState.source, - }) - .then((submittedComment) => { - clearCommentFocus(comment.id); - setReviewComments((current) => - current.map((candidate) => - candidate.id === comment.id - ? { - author: submittedComment.author, - body: submittedComment.body, - filePath: submittedComment.filePath, - id: submittedComment.id, - isReadOnly: true, - ...(submittedComment.anchor === 'file' ? { anchor: 'file' as const } : {}), - ...(submittedComment.lineNumber != null - ? { lineNumber: submittedComment.lineNumber } - : {}), - sectionId: comment.sectionId, - ...(submittedComment.side ? { side: submittedComment.side } : {}), - ...getReviewCommentRangeProps(submittedComment), - submittedAt: submittedComment.submittedAt, - url: submittedComment.url, - } - : candidate, - ), - ); - onCommentFileChange(comment.filePath); - }) - .catch((error: unknown) => { - updateRemoteSubmit(comment.id, { - error: error instanceof Error ? error.message : String(error), - status: 'error', - }); - }); - }, - [ - clearCommentFocus, - onCommentFileChange, - reviewCommentsRef, - stateRef, - updateActiveReviewCommentDraft, - updateRemoteSubmit, - ], - ); - - const submitPullRequestReview = useCallback( - (event: PullRequestReviewEvent, body?: string) => { - const currentState = stateRef.current; - if ( - currentState?.source.type !== 'pull-request' || - pullRequestReviewSubmitting || - isReviewActionDisabled(currentState.source.reviewStatus, event) - ) { - return; - } - - const pendingComments = getPendingPullRequestReviewComments( - reviewCommentsRef.current, - activeReviewCommentDraftRef.current, - ); - if (event === 'COMMENT' && pendingComments.length === 0 && !body?.trim()) { - return; - } - const pendingCommentIds = new Set(pendingComments.map((comment) => comment.id)); - setPullRequestReviewSubmitting(event); - return window.codiff - .submitPullRequestReview({ - ...(body ? { body } : {}), - comments: pendingComments.map((comment) => toPullRequestReviewComment(comment)), - event, - source: currentState.source, - }) - .then(() => { - updateActiveReviewCommentDraft(null); - setReviewComments((current) => - current.filter((comment) => !pendingCommentIds.has(comment.id)), - ); - }) - .catch((error: unknown) => { - window.alert(error instanceof Error ? error.message : String(error)); - throw error; - }) - .finally(() => { - setPullRequestReviewSubmitting(null); - }); + const localReviewNotes = reviewComments.filter(isLocalReviewNote); + const providerDrafts = reviewComments.filter(isProviderCommentDraft); + const providerInlineComments = reviewComments.filter(isProviderInlineComment); + const setLocalReviewNotes = useCallback>>>( + (update) => { + setReviewComments((current) => { + const currentNotes = current.filter(isLocalReviewNote); + const nextNotes = typeof update === 'function' ? update(currentNotes) : update; + return [...current.filter((comment) => !isLocalReviewNote(comment)), ...nextNotes]; + }); }, - [ - activeReviewCommentDraftRef, - isReviewActionDisabled, - pullRequestReviewSubmitting, - reviewCommentsRef, - stateRef, - updateActiveReviewCommentDraft, - ], + [], ); - - const hasPendingReviewComments = - getPendingPullRequestReviewComments(reviewComments, activeReviewCommentDraftState).length > 0; + const setProviderDrafts = useCallback< + Dispatch>> + >((update) => { + setReviewComments((current) => { + const currentDrafts = current.filter(isProviderCommentDraft); + const nextDrafts = typeof update === 'function' ? update(currentDrafts) : update; + return [...current.filter((comment) => !isProviderCommentDraft(comment)), ...nextDrafts]; + }); + }, []); return { ...commentDrafts, askCodex, - hasPendingReviewComments, - pullRequestReviewSubmitting, + localReviewNotes, + providerDrafts, + providerInlineComments, reviewComments, + setLocalReviewNotes, + setProviderDrafts, setReviewComments, - submitPullRequestComment, - submitPullRequestReview, }; } diff --git a/core/app/hooks/useAppWalkthrough.ts b/core/app/hooks/useAppWalkthrough.ts index 41cb5ffe..b7e0fc86 100644 --- a/core/app/hooks/useAppWalkthrough.ts +++ b/core/app/hooks/useAppWalkthrough.ts @@ -1,15 +1,17 @@ import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from 'react'; import type { SidebarMode, WalkthroughError } from '../../lib/app-types.ts'; import { buildCommitModel, buildGenericCommitModel } from '../../lib/narrative-walkthrough.ts'; +import type { ReloadMainMode } from '../../lib/reload-selection.ts'; import { createReviewCommandTarget, type ReviewCommandTarget, } from '../../lib/review-command-target.ts'; -import { getSourceKey } from '../../lib/source.ts'; +import { getSourceRevisionKey } from '../../lib/source.ts'; import type { ChangedFile, CodiffPreferences, NarrativeWalkthrough, + NarrativeWalkthroughResult, NarrativeWalkthroughRequestOptions, RepositoryState, SharedWalkthroughSnapshot, @@ -22,9 +24,12 @@ import { useNarrativeNavigation } from '../components/walkthrough/useNarrativeNa import { nextWalkthroughResponseLabelIndex } from '../components/walkthrough/WalkthroughProgress.tsx'; import type { WalkthroughFileError } from '../components/WalkthroughFileError.tsx'; -type MainMode = 'commit' | 'review'; - type UseAppWalkthroughOptions = { + initialMainMode?: ReloadMainMode; + initialSidebarMode?: SidebarMode; + initialWalkthroughFileError?: WalkthroughFileError | null; + initialWalkthroughLoading?: boolean; + initialWalkthroughResult?: NarrativeWalkthroughResult; preferencesRef: RefObject; state: RepositoryState | null; stateGenerationRef: RefObject; @@ -34,41 +39,66 @@ type UseAppWalkthroughOptions = { const emptyFiles: ReadonlyArray = []; export function useAppWalkthrough({ + initialMainMode = 'review', + initialSidebarMode = 'tree', + initialWalkthroughFileError = null, + initialWalkthroughLoading = false, + initialWalkthroughResult, preferencesRef, state, stateGenerationRef, stateRef, }: UseAppWalkthroughOptions) { - const [mainMode, setMainMode] = useState('review'); + const [mainMode, setMainMode] = useState(initialMainMode); + const initialWalkthrough = + initialWalkthroughResult?.status === 'ready' ? initialWalkthroughResult.walkthrough : null; const [narrativeWalkthrough, setNarrativeWalkthrough] = useState( - null, + initialWalkthrough, ); const [shareWalkthroughEnabled, setShareWalkthroughEnabled] = useState(false); - const [sidebarMode, setSidebarMode] = useState('tree'); - const [walkthroughError, setWalkthroughError] = useState(null); + const [sidebarMode, setSidebarMode] = useState(initialSidebarMode); + const [walkthroughError, setWalkthroughError] = useState(() => + initialWalkthroughResult?.status === 'unavailable' ? initialWalkthroughResult : null, + ); const [walkthroughFileError, setWalkthroughFileError] = useState( - null, + initialWalkthroughFileError, ); - const [walkthroughLoading, setWalkthroughLoading] = useState(false); + const [walkthroughLoading, setWalkthroughLoadingState] = useState(initialWalkthroughLoading); const [walkthroughProgress, setWalkthroughProgress] = useState<{ - phase: WalkthroughProgressEvent['phase'] | null; + generation: NonNullable | null; + phase: NonNullable | null; responseLabelIndex: number; stageRevision: number; - }>({ phase: null, responseLabelIndex: -1, stageRevision: 0 }); + }>({ generation: null, phase: null, responseLabelIndex: -1, stageRevision: 0 }); const [walkthroughSharing, setWalkthroughSharing] = useState(false); const [walkthroughUnread, setWalkthroughUnread] = useState(false); const activeReviewCommandTargetRef = useRef(null); - const mainModeRef = useRef('review'); - const narrativeWalkthroughRef = useRef(null); - const sidebarModeRef = useRef('tree'); - const walkthroughErrorRef = useRef(null); + const mainModeRef = useRef(initialMainMode); + const narrativeWalkthroughRef = useRef(narrativeWalkthrough); + const sidebarModeRef = useRef(initialSidebarMode); + const walkthroughErrorRef = useRef(walkthroughError); + const walkthroughLoadingRef = useRef(initialWalkthroughLoading); + const walkthroughProgressEnabledRef = useRef(true); const walkthroughRequestRef = useRef(0); - const navigationResetKey = state ? `${state.root}:${getSourceKey(state.source)}` : ''; + const initialSourceKeyRef = useRef(state ? getSourceRevisionKey(state.source) : null); + const initialStateGenerationRef = useRef(0); + const navigationResetKey = state ? `${state.root}:${getSourceRevisionKey(state.source)}` : ''; const narrativeNavigation = useNarrativeNavigation( narrativeWalkthrough, state?.files ?? emptyFiles, navigationResetKey, ); + const setWalkthroughLoading = useCallback((loading: boolean) => { + walkthroughLoadingRef.current = loading; + setWalkthroughLoadingState(loading); + if (!loading) { + setWalkthroughProgress((current) => + current.phase == null + ? current + : { ...current, phase: null, stageRevision: current.stageRevision + 1 }, + ); + } + }, []); useEffect(() => { mainModeRef.current = mainMode; @@ -86,6 +116,45 @@ export function useAppWalkthrough({ walkthroughErrorRef.current = walkthroughError; }, [walkthroughError]); + useEffect(() => { + const currentSource = stateRef.current?.source ?? state?.source; + if ( + !initialWalkthroughResult || + !initialSourceKeyRef.current || + !currentSource || + stateGenerationRef.current !== initialStateGenerationRef.current || + getSourceRevisionKey(currentSource) !== initialSourceKeyRef.current + ) { + return; + } + const sourceKey = initialSourceKeyRef.current; + queueMicrotask(() => { + const latestSource = stateRef.current?.source; + if ( + stateGenerationRef.current !== initialStateGenerationRef.current || + !latestSource || + getSourceRevisionKey(latestSource) !== sourceKey + ) { + return; + } + setWalkthroughLoading(false); + if (initialWalkthroughResult.status === 'ready') { + setNarrativeWalkthrough(initialWalkthroughResult.walkthrough); + setWalkthroughError(null); + } else { + setWalkthroughError(initialWalkthroughResult); + } + setWalkthroughFileError(initialWalkthroughFileError); + }); + }, [ + initialWalkthroughFileError, + initialWalkthroughResult, + setWalkthroughLoading, + state?.source, + stateGenerationRef, + stateRef, + ]); + useEffect(() => { activeReviewCommandTargetRef.current = null; }, [navigationResetKey]); @@ -93,28 +162,45 @@ export function useAppWalkthrough({ useEffect( () => window.codiff.onWalkthroughProgress((progress) => { - setWalkthroughProgress((current) => - current.phase === progress.phase - ? current - : { - phase: progress.phase, - responseLabelIndex: current.responseLabelIndex, - stageRevision: current.stageRevision + 1, - }, - ); + if (!walkthroughProgressEnabledRef.current) { + return; + } + setWalkthroughProgress((current) => { + const phase = progress.phase ?? current.phase; + return { + generation: progress.generation ?? current.generation, + phase, + responseLabelIndex: current.responseLabelIndex, + stageRevision: + current.phase === phase ? current.stageRevision : current.stageRevision + 1, + }; + }); }), [], ); const startWalkthroughLoading = useCallback(() => { + walkthroughProgressEnabledRef.current = true; setWalkthroughProgress((current) => ({ + generation: null, phase: null, responseLabelIndex: nextWalkthroughResponseLabelIndex(current.responseLabelIndex), stageRevision: current.stageRevision + 1, })); - setWalkthroughLoading(true); + walkthroughLoadingRef.current = true; + setWalkthroughLoadingState(true); }, []); + const cancelWalkthroughRequest = useCallback(() => { + walkthroughRequestRef.current += 1; + walkthroughProgressEnabledRef.current = false; + const cancelMainProcess = walkthroughLoadingRef.current; + setWalkthroughLoading(false); + if (cancelMainProcess) { + void window.codiff.cancelNarrativeWalkthrough().catch(() => {}); + } + }, [setWalkthroughLoading]); + const commitWalkthrough = useCallback( (request: WalkthroughCommitRequest) => window.codiff.createWalkthroughCommit({ @@ -142,15 +228,15 @@ export function useAppWalkthrough({ (source: RepositoryState['source'], options?: NarrativeWalkthroughRequestOptions) => { const request = walkthroughRequestRef.current + 1; walkthroughRequestRef.current = request; - const sourceKey = getSourceKey(source); + const sourceKey = getSourceRevisionKey(source); const stateGeneration = stateGenerationRef.current; const isCurrentState = () => walkthroughRequestRef.current === request && stateGenerationRef.current === stateGeneration && - getSourceKey(stateRef.current?.source ?? source) === sourceKey; + getSourceRevisionKey(stateRef.current?.source ?? source) === sourceKey; startWalkthroughLoading(); setWalkthroughError(null); - window.codiff + return window.codiff .getNarrativeWalkthrough(source, options) .then((result) => { if (!isCurrentState()) { @@ -160,7 +246,7 @@ export function useAppWalkthrough({ if (result.status === 'ready') { setNarrativeWalkthrough(result.walkthrough); if (sidebarModeRef.current === 'walkthrough') { - setSidebarMode('walkthrough'); + setWalkthroughUnread(false); } else { setWalkthroughUnread(true); } @@ -179,12 +265,15 @@ export function useAppWalkthrough({ }); }) .finally(() => { + // A repository refresh can advance the state generation without + // cancelling this request. Reject its old-code result above, but let + // the request that still owns the loading slot clear it on completion. if (walkthroughRequestRef.current === request) { setWalkthroughLoading(false); } }); }, - [startWalkthroughLoading, stateGenerationRef, stateRef], + [setWalkthroughLoading, startWalkthroughLoading, stateGenerationRef, stateRef], ); const refreshWalkthroughForState = useCallback( @@ -192,7 +281,11 @@ export function useAppWalkthrough({ nextState: RepositoryState, previousWalkthrough: NarrativeWalkthrough | null = narrativeWalkthroughRef.current, ) => { - if (sidebarModeRef.current !== 'walkthrough' && previousWalkthrough == null) { + if ( + sidebarModeRef.current !== 'walkthrough' && + previousWalkthrough == null && + !walkthroughLoadingRef.current + ) { return; } @@ -209,7 +302,7 @@ export function useAppWalkthrough({ previousWalkthrough: previousWalkthrough ?? undefined, }); }, - [loadNarrativeWalkthrough], + [loadNarrativeWalkthrough, setWalkthroughLoading], ); const changeSidebarMode = useCallback( @@ -239,7 +332,14 @@ export function useAppWalkthrough({ loadNarrativeWalkthrough(state.source); }, - [loadNarrativeWalkthrough, narrativeWalkthrough, state, walkthroughError, walkthroughLoading], + [ + loadNarrativeWalkthrough, + narrativeWalkthrough, + setWalkthroughLoading, + state, + walkthroughError, + walkthroughLoading, + ], ); const openCommitView = useCallback(() => { @@ -335,10 +435,12 @@ export function useAppWalkthrough({ return { activeReviewCommandTargetRef, + cancelWalkthroughRequest, changeSidebarMode, closeCommitView, commitWalkthrough, enabledShareWalkthrough: shareWalkthroughEnabled ? shareWalkthrough : undefined, + loadNarrativeWalkthrough, mainModeRef, narrativeNavigation, narrativeWalkthrough, diff --git a/core/app/hooks/useDiffSearch.ts b/core/app/hooks/useDiffSearch.ts index 4bf241e9..56ea7100 100644 --- a/core/app/hooks/useDiffSearch.ts +++ b/core/app/hooks/useDiffSearch.ts @@ -1,5 +1,9 @@ import { useCallback, useMemo, useState } from 'react'; -import { getDiffSearchResult } from '../../lib/diff-search.ts'; +import { + defaultDiffSearchFilters, + getDiffSearchResult, + type DiffSearchFilters, +} from '../../lib/diff-search.ts'; import { fileHasVisibleDiff } from '../../lib/diff.ts'; import { fuzzyMatches } from '../../lib/files.ts'; import type { ChangedFile } from '../../types.ts'; @@ -13,6 +17,7 @@ type UseDiffSearchOptions = { export function useDiffSearch({ files, fileSearchQuery, showWhitespace }: UseDiffSearchOptions) { const [activeMatchIndex, setActiveMatchIndex] = useState(0); const [focusRequest, setFocusRequest] = useState(0); + const [filters, setFilters] = useState(defaultDiffSearchFilters); const [query, setQuery] = useState(''); const [visible, setVisible] = useState(false); @@ -28,10 +33,10 @@ export function useDiffSearch({ files, fileSearchQuery, showWhitespace }: UseDif () => query.trim() ? fileFilteredFiles - .map((file) => getDiffSearchResult(file, showWhitespace, query)) + .map((file) => getDiffSearchResult(file, showWhitespace, query, filters)) .filter((result) => result != null) : [], - [fileFilteredFiles, query, showWhitespace], + [fileFilteredFiles, filters, query, showWhitespace], ); const matches = useMemo(() => results.flatMap((result) => result.matches), [results]); const matchPathSet = useMemo(() => new Set(results.map((result) => result.file.path)), [results]); @@ -88,6 +93,7 @@ export function useDiffSearch({ files, fileSearchQuery, showWhitespace }: UseDif activeMatchIndex: effectiveActiveMatchIndex, closeSearch, fileFilteredFiles, + filters, focusRequest, hasQuery: query.trim().length > 0, matches, @@ -96,6 +102,7 @@ export function useDiffSearch({ files, fileSearchQuery, showWhitespace }: UseDif openSearch, query, resetSearch, + updateFilters: setFilters, updateQuery, visible, visibleFiles, diff --git a/core/app/hooks/useReviewCommentDrafts.ts b/core/app/hooks/useReviewCommentDrafts.ts index e64599d5..1b196ec3 100644 --- a/core/app/hooks/useReviewCommentDrafts.ts +++ b/core/app/hooks/useReviewCommentDrafts.ts @@ -6,14 +6,20 @@ import { type Dispatch, type SetStateAction, } from 'react'; -import type { ReviewComment } from '../../lib/app-types.ts'; -import { findReusableReviewCommentDraft, getCommentKey } from '../../lib/review-comments.ts'; +import type { ReviewComment, ReviewCommentCreation, ReviewDraft } from '../../lib/app-types.ts'; +import { + findReusableReviewCommentDraft, + getCommentKey, + isProviderReviewCommentPosition, + isReviewDraft, +} from '../../lib/review-comments.ts'; -type ReviewCommentDraft = Pick; +type ReviewCommentDraft = Pick; type UseReviewCommentDraftsOptions = { canCreateComment?: boolean; comments: ReadonlyArray; + draftKind: ReviewDraft['kind']; onCommentFileChange: (filePath: string) => void; setComments: Dispatch>>; }; @@ -25,7 +31,7 @@ const updateCommentBody = ( ) => { let changed = false; const next = comments.map((comment) => { - if (comment.id !== commentId || comment.isReadOnly || comment.body === body) { + if (comment.id !== commentId || !isReviewDraft(comment) || comment.body === body) { return comment; } changed = true; @@ -34,9 +40,40 @@ const updateCommentBody = ( return changed ? next : comments; }; +const createReviewDraft = ( + kind: ReviewDraft['kind'], + comment: ReviewCommentCreation, + id: string, +): ReviewDraft => { + const { position, threadId, ...anchor } = comment; + switch (kind) { + case 'local-note': + return { ...anchor, body: '', id, kind, ...(position ? { position } : {}) }; + case 'provider-draft': + return { + ...anchor, + body: '', + id, + kind, + ...(position && isProviderReviewCommentPosition(position) ? { position } : {}), + ...(threadId ? { threadId } : {}), + }; + case 'share-draft': + return { + ...anchor, + body: '', + id, + kind, + ...(position ? { position } : {}), + ...(threadId ? { threadId } : {}), + }; + } +}; + export function useReviewCommentDrafts({ canCreateComment = true, comments, + draftKind, onCommentFileChange, setComments, }: UseReviewCommentDraftsOptions) { @@ -80,14 +117,17 @@ export function useReviewCommentDrafts({ }, []); const createComment = useCallback( - (comment: Omit) => { + (comment: ReviewCommentCreation) => { if (!canCreateComment) { return; } const commentKey = getCommentKey(comment); const emptyExistingComment = reviewCommentsRef.current.find( - (candidate) => candidate.body.length === 0 && getCommentKey(candidate) === commentKey, + (candidate) => + isReviewDraft(candidate) && + candidate.body.length === 0 && + getCommentKey(candidate) === commentKey, ); if (emptyExistingComment) { focusComment(emptyExistingComment.id); @@ -95,7 +135,7 @@ export function useReviewCommentDrafts({ } const emptyDraft = findReusableReviewCommentDraft( - reviewCommentsRef.current, + reviewCommentsRef.current.filter(isReviewDraft), activeReviewCommentDraftRef.current, ); if (emptyDraft) { @@ -103,13 +143,7 @@ export function useReviewCommentDrafts({ focusComment(id); setComments((current) => current.map((candidate) => - candidate.id === emptyDraft.id - ? { - ...comment, - body: '', - id, - } - : candidate, + candidate.id === emptyDraft.id ? createReviewDraft(draftKind, comment, id) : candidate, ), ); onCommentFileChange(emptyDraft.filePath); @@ -119,10 +153,10 @@ export function useReviewCommentDrafts({ const id = crypto.randomUUID(); focusComment(id); - setComments((current) => [...current, { ...comment, body: '', id }]); + setComments((current) => [...current, createReviewDraft(draftKind, comment, id)]); onCommentFileChange(comment.filePath); }, - [canCreateComment, focusComment, onCommentFileChange, setComments], + [canCreateComment, draftKind, focusComment, onCommentFileChange, setComments], ); const updateComment = useCallback( diff --git a/core/app/hooks/useReviewState.ts b/core/app/hooks/useReviewState.ts index 6049a94c..4ce68aa6 100644 --- a/core/app/hooks/useReviewState.ts +++ b/core/app/hooks/useReviewState.ts @@ -1,4 +1,4 @@ -import { useCallback, useState } from 'react'; +import { useCallback, useMemo, useState, type Dispatch, type SetStateAction } from 'react'; import type { ReviewIdentity } from '../../lib/app-types.ts'; import { isGeneratedWalkthroughFile } from '../../lib/narrative-walkthrough-diff.js'; import { @@ -9,19 +9,71 @@ import { import type { ChangedFile } from '../../types.ts'; type UseReviewFileStateOptions = { + collapsed?: ReadonlySet; + initialCollapsed?: ReadonlySet; initialSelectedPath?: string | null; + initialViewed?: Record; + onCollapsedChange?: (collapsed: Set) => void; onViewedChange?: (viewed: Record) => void; + viewed?: Readonly>; }; export function useReviewFileState({ + collapsed: controlledCollapsed, + initialCollapsed = new Set(), initialSelectedPath = null, + initialViewed = {}, + onCollapsedChange, onViewedChange, + viewed: controlledViewed, }: UseReviewFileStateOptions = {}) { - const [collapsed, setCollapsed] = useState>(() => new Set()); + const [uncontrolledCollapsed, setUncontrolledCollapsed] = useState>( + () => new Set(initialCollapsed), + ); const [expandedGenerated, setExpandedGenerated] = useState>(() => new Set()); const [itemVersionByKey, setItemVersionByKey] = useState>({}); const [selectedPath, setSelectedPath] = useState(initialSelectedPath); - const [viewed, setViewed] = useState>({}); + const [uncontrolledViewed, setUncontrolledViewed] = + useState>(initialViewed); + const collapsed = useMemo( + () => (controlledCollapsed ? new Set(controlledCollapsed) : uncontrolledCollapsed), + [controlledCollapsed, uncontrolledCollapsed], + ); + const viewed = controlledViewed ?? uncontrolledViewed; + + const setCollapsed = useCallback>>>( + (update) => { + if (controlledCollapsed == null) { + setUncontrolledCollapsed((current) => { + const next = typeof update === 'function' ? update(new Set(current)) : update; + onCollapsedChange?.(next); + return next; + }); + return; + } + const current = new Set(controlledCollapsed); + const next = typeof update === 'function' ? update(current) : update; + onCollapsedChange?.(next); + }, + [controlledCollapsed, onCollapsedChange], + ); + + const setViewed = useCallback>>>( + (update) => { + if (controlledViewed == null) { + setUncontrolledViewed((current) => { + const next = typeof update === 'function' ? update({ ...current }) : update; + onViewedChange?.(next); + return next; + }); + return; + } + const current = { ...controlledViewed }; + const next = typeof update === 'function' ? update(current) : update; + onViewedChange?.(next); + }, + [controlledViewed, onViewedChange], + ); const bumpItemVersion = useCallback((key: string) => { setItemVersionByKey((current) => ({ @@ -52,7 +104,7 @@ export function useReviewFileState({ }); bumpItemVersion(reviewKey); }, - [bumpItemVersion], + [bumpItemVersion, setCollapsed], ); const toggleViewed = useCallback( @@ -62,9 +114,7 @@ export function useReviewFileState({ reviewIdentity: ReviewIdentity = getFileReviewIdentity(file), ) => { setViewed((current) => { - const next = updateReviewIdentityViewed(current, reviewIdentity, isViewed); - onViewedChange?.(next); - return next; + return updateReviewIdentityViewed(current, reviewIdentity, isViewed); }); setCollapsed((current) => updateReviewIdentityCollapsed(current, reviewIdentity, isViewed)); if (!isViewed) { @@ -76,7 +126,7 @@ export function useReviewFileState({ } bumpItemVersion(reviewIdentity.key); }, - [bumpItemVersion, onViewedChange], + [bumpItemVersion, setCollapsed, setViewed], ); return { diff --git a/core/global.d.ts b/core/global.d.ts index 21a0f5cb..d8f8b0f2 100644 --- a/core/global.d.ts +++ b/core/global.d.ts @@ -13,6 +13,8 @@ import type { DiffImageContentResult, DiffSection, DiffSectionContentRequest, + DiffSectionsContentRequest, + DiffSectionsContentResult, GitIdentity, NarrativeWalkthroughRequestOptions, NarrativeWalkthroughResult, @@ -21,6 +23,7 @@ import type { PlanReview, RepositoryHistory, RepositoryState, + ResolvedReviewSource, ReviewAssistantRequest, ReviewAssistantResult, ReviewSource, @@ -30,8 +33,12 @@ import type { SharedWalkthroughSnapshot, ShareWalkthroughResult, SubmitPullRequestCommentRequest, + PullRequestGeneralCommentThread, PullRequestExistingReviewComment, + RevisionContentBatchRequest, + RevisionContentBatchResult, SubmitPullRequestReviewRequest, + SubmitPullRequestReviewResult, TerminalHelperStatus, WalkthroughCommitMessageRequest, WalkthroughCommitMessageResult, @@ -47,6 +54,8 @@ declare global { codiff: { applyUpdate: () => Promise; askReviewAssistant: (request: ReviewAssistantRequest) => Promise; + cancelDiffContentRequest: (requestId: string) => void; + cancelNarrativeWalkthrough: () => Promise; completePlan: (review: PlanReview, status: PlanHandoffStatus) => Promise; createWalkthroughCommit: ( request: WalkthroughCommitRequest, @@ -56,8 +65,6 @@ declare global { findDefinitions: (request: DefinitionSearchRequest) => Promise; getAgentSkillStatus: () => Promise; getConfig: () => Promise; - getDiffImageContent: (request: DiffImageContentRequest) => Promise; - getDiffSectionContent: (request: DiffSectionContentRequest) => Promise; getFeatureFlags: () => Promise; getGitIdentity: () => Promise; getKeyboardLayout: () => Promise; @@ -67,13 +74,20 @@ declare global { path: string; }) => Promise; getNarrativeWalkthrough: ( - source?: ReviewSource, + source?: ResolvedReviewSource, options?: NarrativeWalkthroughRequestOptions, ) => Promise; getPlanReview: () => Promise; getPreferences: () => Promise; getRepositoryHistory: (limit?: number, source?: ReviewSource) => Promise; getRepositoryState: (source?: ReviewSource) => Promise; + getReviewComments: ( + source: Extract, + requestId?: string, + ) => Promise<{ + generalComments: ReadonlyArray; + reviewComments: ReadonlyArray; + }>; getTerminalHelperStatus: () => Promise; getUpdateStatus: () => Promise; increaseCodeFontSize: () => Promise; @@ -104,6 +118,12 @@ declare global { openFile: (path: string, lineNumber?: number) => Promise; openReleasePage: () => Promise; openRepositoryFolder: () => Promise; + readRevisionContent: ( + request: RevisionContentBatchRequest, + ) => Promise; + reportInitialLoadMilestone: ( + name: 'deferred-review-data-complete' | 'first-usable-review-rendered', + ) => void; resetCodeFontSize: () => Promise; resolvePullRequestUrl: (value: string) => Promise; saveMarkdownDocument: ( @@ -119,7 +139,9 @@ declare global { submitPullRequestComment: ( request: SubmitPullRequestCommentRequest, ) => Promise; - submitPullRequestReview: (request: SubmitPullRequestReviewRequest) => Promise; + submitPullRequestReview: ( + request: SubmitPullRequestReviewRequest, + ) => Promise; updateWalkthroughCommitMessage: ( request: WalkthroughCommitMessageRequest, ) => Promise; diff --git a/core/index.ts b/core/index.ts index 0a5d9053..cd94f5f3 100644 --- a/core/index.ts +++ b/core/index.ts @@ -1,4 +1,60 @@ export { defaultReviewPreferences } from './defaults.ts'; +export { + diffRangesMatch, + resolveProviderCommentTarget, + resolveShareCommentTarget, + type ProviderCommentTargetResolution, + type ShareCommentTargetResolution, +} from './lib/review-comment-target.ts'; +export { diffRange, isCommitRevision, shaForRevision } from './lib/review-history.ts'; +export { + orderReviewCommitStack, + validateReviewCommitStack, + type ReviewCommitStack, + type ReviewCommitStackItem, +} from './lib/review-commit-stack.ts'; +export { + getReviewContextExpansionState, + reviewContextExpansionDigest, + reviewContextExpansionProjectionKey, + type ReviewContextExpansionRegion, + type ReviewContextExpansionState, +} from './lib/review-context-expansion.ts'; +export { + createCommitArtifactRequestKey, + createFileBlobArtifactRequestKey, + createReviewArtifactRun, + reviewArtifactSchemaVersion, + validateCommitArtifact, + validateRangeArtifact, + validateReviewArtifactRangeResult, + validateStackSnapshot, + type ArtifactCoverage, + type ArtifactFile, + type BlobArtifact, + type CommitArtifact, + type CommitArtifactRequest, + type CommitArtifactRequestKey, + type FileBlobArtifactRequest, + type RangeArtifact, + type ReviewArtifactProject, + type ReviewArtifactRangeRequest, + type ReviewArtifactRangeResult, + type ReviewArtifactProvenance, + type ReviewArtifactRun, + type ReviewArtifactRunDiagnostics, + type ReviewArtifactSource, + type StackSnapshot, +} from './lib/review-artifacts.ts'; +export { + runWalkthroughGenerationTasks, + walkthroughGenerationConcurrency, + type ReusableWalkthroughGenerationComponent, + type RunWalkthroughGenerationTasksInput, + type RunWalkthroughGenerationTasksResult, + type WalkthroughGenerationFailure, + type WalkthroughGenerationTask, +} from './lib/walkthrough-generation-tasks.ts'; export { parsePlanShareManifest, parsePlanShareUpload, @@ -9,8 +65,13 @@ export type { ChangedFile, CodiffFeatureFlags, CodiffPreferences, + DiffRange, DiffSection, + GenerationMetadata, + GenerationProfile, + GenerationSettings, GitIdentity, + GitSha, NarrativeWalkthrough, PlanCommentThread, PullRequestCodeQualityFinding, @@ -23,13 +84,40 @@ export type { PullRequestReviewEvent, PullRequestReviewStatus, PullRequestReviewer, + ProviderCommentSubmission, + ProviderReviewCommentPosition, + ReviewCommenting, + ReviewCommentPosition, + ReviewCommitSummary, ReviewPreferences, + ResolvedReviewSource, RepositoryState, + Revision, + RevisionLabel, ReviewSource, + ShareCommentSubmission, SharePlanResult, ShareWalkthroughResult, SharedPlanSnapshot, SharedWalkthroughSnapshot, + SubmittedReviewComment, + SubmitPullRequestReviewResult, WalkthroughShareManifestV1, WalkthroughHunk, + WalkthroughGenerationProgress, + WalkthroughGenerationUnitProgress, + WalkthroughProgressEvent, + WalkthroughProgressPhase, } from './types.ts'; +export type { + LocalReviewNote, + ProviderCommentDraft, + ProviderInlineComment, + RenderedReviewCommentTarget, + RenderedSubmittedReviewComment, + ReviewComment, + ReviewCommentDraftTarget, + ReviewDraft, + ShareCommentDraft, + ShareInlineComment, +} from './lib/app-types.ts'; diff --git a/core/lib/app-types.ts b/core/lib/app-types.ts index 478d2486..e67e9794 100644 --- a/core/lib/app-types.ts +++ b/core/lib/app-types.ts @@ -6,7 +6,9 @@ import type { NarrativeWalkthrough, NarrativeWalkthroughResult, PullRequestCodeQualityFinding, - PullRequestExistingReviewComment, + ProviderReviewCommentPosition, + ReviewCommentPosition, + SubmittedReviewComment as PersistedSubmittedReviewComment, ReviewSource, } from '../types.ts'; @@ -88,38 +90,82 @@ export type DiffLineCount = { deletions: number; }; -export type ReviewComment = { +export type RenderedReviewCommentTarget = { anchor?: 'file' | 'line'; - author?: PullRequestExistingReviewComment['author']; - body: string; - canDelete?: boolean; - canEdit?: boolean; - canReplyThread?: boolean; - canResolveThread?: boolean; - codexReply?: { - body?: string; - error?: string; - status: 'error' | 'loading' | 'ready'; - }; filePath: string; - id: string; - isOutdated?: boolean; - isReadOnly?: boolean; - isThreadResolved?: boolean; lineNumber?: number; - remoteSubmit?: { - error?: string; - status: 'error' | 'submitting'; - }; sectionId: string; side?: 'additions' | 'deletions'; startLineNumber?: number; startSide?: 'additions' | 'deletions'; - submittedAt?: string; +}; + +export type ReviewCommentDraftTarget = RenderedReviewCommentTarget & { + position?: ReviewCommentPosition; +}; + +type ReviewAssistantReply = { + body?: string; + error?: string; + status: 'error' | 'loading' | 'ready'; +}; + +type EditableReviewCommentDraft = ReviewCommentDraftTarget & { + author?: never; + body: string; + canDelete?: never; + canEdit?: never; + canReplyThread?: never; + canResolveThread?: never; + codexReply?: ReviewAssistantReply; + id: string; + isOutdated?: never; + isReadOnly?: false; + isThreadResolved?: never; + submittedAt?: never; + url?: never; +}; + +export type LocalReviewNote = EditableReviewCommentDraft & { + kind: 'local-note'; + remoteSubmit?: never; + threadId?: never; +}; + +export type ShareCommentDraft = EditableReviewCommentDraft & { + kind: 'share-draft'; + remoteSubmit?: { + error?: string; + status: 'error' | 'submitting'; + }; threadId?: string; - url?: string; }; +export type ProviderCommentDraft = EditableReviewCommentDraft & { + kind: 'provider-draft'; + position?: ProviderReviewCommentPosition; + remoteSubmit?: { + error?: string; + status: 'error' | 'outcome-unknown' | 'submitting'; + }; + threadId?: string; +}; + +export type RenderedSubmittedReviewComment = PersistedSubmittedReviewComment & { + codexReply?: never; + kind: 'submitted-comment'; + remoteSubmit?: never; +}; + +export type ProviderInlineComment = Extract< + RenderedSubmittedReviewComment, + { destination: 'provider' } +>; +export type ShareInlineComment = Extract; +export type ReviewDraft = LocalReviewNote | ProviderCommentDraft | ShareCommentDraft; +export type ReviewComment = RenderedSubmittedReviewComment | ReviewDraft; +export type ReviewCommentCreation = ReviewCommentDraftTarget & { threadId?: string }; + export type SidebarMode = 'tree' | 'walkthrough' | 'history'; export type PullRequestSource = Extract; diff --git a/core/lib/code-view-options.ts b/core/lib/code-view-options.ts index d5ea93b6..bfaeeaec 100644 --- a/core/lib/code-view-options.ts +++ b/core/lib/code-view-options.ts @@ -177,6 +177,28 @@ export const codeViewUnsafeCSS = ` text-decoration-style: solid; } + :host(.codiff-loading-context-item) [data-expand-index] { + cursor: progress; + opacity: 0.72; + pointer-events: none; + } + + :host(.codiff-loading-context-item) [data-expand-index]::after { + animation: codiff-context-spin 0.8s linear infinite; + border: 2px solid color-mix(in srgb, currentColor 24%, transparent); + border-radius: 50%; + border-top-color: currentColor; + content: ''; + display: inline-block; + height: 10px; + margin-left: 6px; + vertical-align: -1px; + width: 10px; + } + + @keyframes codiff-context-spin { + to { transform: rotate(360deg); } + } .codiff-search-mark { background: var(--diffs-find-highlight-bg, rgb(255 216 92 / 0.65)); border-radius: 3px; diff --git a/core/lib/diff-search.ts b/core/lib/diff-search.ts index f2849027..cbcf6f14 100644 --- a/core/lib/diff-search.ts +++ b/core/lib/diff-search.ts @@ -2,6 +2,18 @@ import type { ChangedFile } from '../types.ts'; import type { DiffSearchMatch, DiffSearchResult } from './app-types.ts'; import { getFirstVisibleSection, getItemId, getVisibleDiffSections } from './diff.ts'; +export type DiffSearchFilters = { + additions: boolean; + deletions: boolean; + unchanged: boolean; +}; + +export const defaultDiffSearchFilters: DiffSearchFilters = { + additions: true, + deletions: true, + unchanged: false, +}; + const countOccurrences = (text: string, normalizedQuery: string) => { if (!normalizedQuery) { return 0; @@ -25,6 +37,7 @@ export const getDiffSearchResult = ( file: ChangedFile, showWhitespace: boolean, query: string, + filters: DiffSearchFilters = defaultDiffSearchFilters, ): DiffSearchResult | null => { const normalizedQuery = query.trim().toLowerCase(); if (!normalizedQuery) { @@ -70,19 +83,23 @@ export const getDiffSearchResult = ( for (const content of hunk.hunkContent) { if (content.type === 'context') { - for (let index = 0; index < content.lines; index += 1) { - const line = fileDiff.additionLines[content.additionLineIndex + index]; - const occurrences = countOccurrences(line ?? '', normalizedQuery); - if (occurrences > 0) { - pushMatch( - { - filePath: file.path, - itemId, - lineNumber: additionLineNumber + index, - side: 'additions', - }, - occurrences, - ); + // Search only unchanged lines that are part of the visible patch. + // Full file contents must not make hidden context searchable. + if (filters.unchanged) { + for (let index = 0; index < content.lines; index += 1) { + const line = fileDiff.additionLines[content.additionLineIndex + index]; + const occurrences = countOccurrences(line ?? '', normalizedQuery); + if (occurrences > 0) { + pushMatch( + { + filePath: file.path, + itemId, + lineNumber: additionLineNumber + index, + side: 'additions', + }, + occurrences, + ); + } } } @@ -91,7 +108,7 @@ export const getDiffSearchResult = ( continue; } - for (let index = 0; index < content.deletions; index += 1) { + for (let index = 0; filters.deletions && index < content.deletions; index += 1) { const line = fileDiff.deletionLines[content.deletionLineIndex + index]; const occurrences = countOccurrences(line ?? '', normalizedQuery); if (occurrences > 0) { @@ -107,7 +124,7 @@ export const getDiffSearchResult = ( } } - for (let index = 0; index < content.additions; index += 1) { + for (let index = 0; filters.additions && index < content.additions; index += 1) { const line = fileDiff.additionLines[content.additionLineIndex + index]; const occurrences = countOccurrences(line ?? '', normalizedQuery); if (occurrences > 0) { diff --git a/core/lib/diff.ts b/core/lib/diff.ts index 841011a4..e94386d5 100644 --- a/core/lib/diff.ts +++ b/core/lib/diff.ts @@ -28,12 +28,6 @@ export const isPatchOnlyDiffSection = (section: DiffSection) => export const shouldLoadDiffSectionContents = (section: DiffSection) => section.summary?.canLoad !== false && section.loadState === 'deferred'; -// Diff search needs full context lines in app state, so it also preloads -// patch-only sections through the eager (state-replacing) flow. -export const shouldPreloadSectionContentsForSearch = (section: DiffSection) => - shouldLoadDiffSectionContents(section) || - (section.summary?.canLoad !== false && isPatchOnlyDiffSection(section)); - // Full file contents fetched lazily for patch-only sections via the CodeView // `loadDiffFiles` option. Kept outside React state so the library's in-place // hydration of the rendered diff is not reset by re-renders. Keyed without the @@ -192,6 +186,14 @@ export const getDiffLineCountFromVisibleSections = ( export const getDiffLineCount = (file: ChangedFile, showWhitespace: boolean): DiffLineCount => getDiffLineCountFromVisibleSections(getVisibleDiffSections(file, showWhitespace)); +export const getDiffSectionLineCount = (file: ChangedFile, section: DiffSection): DiffLineCount => + getDiffLineCountFromVisibleSections([ + { + fileDiff: parseSectionDiffWithOptions(file, section, true), + section, + }, + ]); + export const getTotalDiffLineCount = (lineCounts: Iterable): DiffLineCount => { let additions = 0; let countable = false; @@ -325,13 +327,21 @@ export const parseSectionDiffWithOptions = ( return cached; } + const oldFile = + section.oldFile ?? + ((file.status === 'added' || file.status === 'untracked') && section.newFile + ? { contents: '', name: file.oldPath ?? file.path } + : undefined); + const newFile = + section.newFile ?? + (file.status === 'deleted' && section.oldFile ? { contents: '', name: file.path } : undefined); let fileDiff: FileDiffMetadata; if (section.binary || (section.loadState != null && section.loadState !== 'ready')) { fileDiff = createBinaryFileDiff(file, section); - } else if (section.oldFile && section.newFile) { + } else if (oldFile && newFile) { try { fileDiff = { - ...parseDiffFromFile(section.oldFile, section.newFile, { + ...parseDiffFromFile(oldFile, newFile, { ignoreWhitespace: !showWhitespace, }), cacheKey, diff --git a/core/lib/narrative-walkthrough-diff.cjs b/core/lib/narrative-walkthrough-diff.cjs index 293d3e4a..0d7ba7cc 100644 --- a/core/lib/narrative-walkthrough-diff.cjs +++ b/core/lib/narrative-walkthrough-diff.cjs @@ -220,7 +220,7 @@ const fileHasRenameMetadata = (file) => /** * @param {{oldPath?: string; path: string; status?: string}} file - * @param {{binary?: boolean; loadState?: string; patch?: string; summary?: {reason?: string}}} section + * @param {{binary?: boolean; lineCount?: {additions: number; deletions: number}; loadState?: string; patch?: string; summary?: {reason?: string}}} section */ const shouldCreateSyntheticHunk = (file, section) => { if (section.binary) { @@ -232,13 +232,16 @@ const shouldCreateSyntheticHunk = (file, section) => { if (fileHasRenameMetadata(file)) { return true; } + if (section.lineCount != null) { + return true; + } return typeof section.patch === 'string' && section.patch.trim().length > 0; }; /** * @param {{generated?: boolean; oldPath?: string; path: string; status: string}} file - * @param {{binary?: boolean; id: string; kind: string; loadState?: string; patch?: string; summary?: {reason?: string}}} section + * @param {{binary?: boolean; id: string; kind: string; lineCount?: {additions: number; deletions: number}; loadState?: string; patch?: string; summary?: {reason?: string}}} section */ const createSyntheticSectionHunk = (file, section, lineCount = { added: 0, deleted: 0 }) => ({ added: lineCount.added, @@ -262,7 +265,7 @@ const createSyntheticSectionHunk = (file, section, lineCount = { added: 0, delet * synthetic hunk so walkthroughs remain hunk-based for every visible change. * * @param {{generated?: boolean; oldPath?: string; path: string; status: string}} file - * @param {{binary?: boolean; id: string; kind: string; loadState?: string; patch?: string; summary?: {reason?: string}}} section + * @param {{binary?: boolean; id: string; kind: string; lineCount?: {additions: number; deletions: number}; loadState?: string; patch?: string; summary?: {reason?: string}}} section */ const getSectionWalkthroughHunks = (file, section) => { const patchHunks = extractPatchHunks(section.patch || ''); @@ -285,7 +288,18 @@ const getSectionWalkthroughHunks = (file, section) => { } return shouldCreateSyntheticHunk(file, section) - ? [createSyntheticSectionHunk(file, section)] + ? [ + createSyntheticSectionHunk( + file, + section, + section.lineCount == null + ? undefined + : { + added: section.lineCount.additions, + deleted: section.lineCount.deletions, + }, + ), + ] : []; }; diff --git a/core/lib/narrative-walkthrough-diff.js b/core/lib/narrative-walkthrough-diff.js index a7fc1f4c..8d22076d 100644 --- a/core/lib/narrative-walkthrough-diff.js +++ b/core/lib/narrative-walkthrough-diff.js @@ -220,7 +220,7 @@ const fileHasRenameMetadata = (file) => /** * @param {{oldPath?: string; path: string; status?: string}} file - * @param {{binary?: boolean; loadState?: string; patch?: string; summary?: {reason?: string}}} section + * @param {{binary?: boolean; lineCount?: {additions: number; deletions: number}; loadState?: string; patch?: string; summary?: {reason?: string}}} section */ const shouldCreateSyntheticHunk = (file, section) => { if (section.binary) { @@ -232,13 +232,16 @@ const shouldCreateSyntheticHunk = (file, section) => { if (fileHasRenameMetadata(file)) { return true; } + if (section.lineCount != null) { + return true; + } return typeof section.patch === 'string' && section.patch.trim().length > 0; }; /** * @param {{generated?: boolean; oldPath?: string; path: string; status: string}} file - * @param {{binary?: boolean; id: string; kind: string; loadState?: string; patch?: string; summary?: {reason?: string}}} section + * @param {{binary?: boolean; id: string; kind: string; lineCount?: {additions: number; deletions: number}; loadState?: string; patch?: string; summary?: {reason?: string}}} section */ const createSyntheticSectionHunk = (file, section, lineCount = { added: 0, deleted: 0 }) => ({ added: lineCount.added, @@ -262,7 +265,7 @@ const createSyntheticSectionHunk = (file, section, lineCount = { added: 0, delet * synthetic hunk so walkthroughs remain hunk-based for every visible change. * * @param {{generated?: boolean; oldPath?: string; path: string; status: string}} file - * @param {{binary?: boolean; id: string; kind: string; loadState?: string; patch?: string; summary?: {reason?: string}}} section + * @param {{binary?: boolean; id: string; kind: string; lineCount?: {additions: number; deletions: number}; loadState?: string; patch?: string; summary?: {reason?: string}}} section */ const getSectionWalkthroughHunks = (file, section) => { const patchHunks = extractPatchHunks(section.patch || ''); @@ -285,7 +288,18 @@ const getSectionWalkthroughHunks = (file, section) => { } return shouldCreateSyntheticHunk(file, section) - ? [createSyntheticSectionHunk(file, section)] + ? [ + createSyntheticSectionHunk( + file, + section, + section.lineCount == null + ? undefined + : { + added: section.lineCount.additions, + deleted: section.lineCount.deletions, + }, + ), + ] : []; }; diff --git a/core/lib/narrative-walkthrough.ts b/core/lib/narrative-walkthrough.ts index 000c29e1..02fdf0e5 100644 --- a/core/lib/narrative-walkthrough.ts +++ b/core/lib/narrative-walkthrough.ts @@ -10,7 +10,7 @@ import type { WalkthroughSupportGroup, WalkthroughStop, } from '../types.ts'; -import { getDiffLineCount, getVisibleDiffSections } from './diff.ts'; +import { getDiffLineCount, getDiffSectionLineCount, getVisibleDiffSections } from './diff.ts'; import { filterPatchToHunkIds, getSectionWalkthroughHunks, @@ -20,6 +20,7 @@ import { type NarrativeLineCount = { added: number; deleted: number; + diffAvailable?: false; }; /** A stop with a global position in the walkthrough. */ @@ -55,6 +56,7 @@ export type WalkthroughFileList = { export type WalkthroughFileLineRow = { added: number; deleted: number; + diffAvailable?: false; label: string; path?: string; title: string; @@ -119,7 +121,12 @@ export const formatWalkthroughFileList = ( }; export const formatWalkthroughFileLineRows = ( - items: ReadonlyArray<{ added: number; deleted: number; path: string }>, + items: ReadonlyArray<{ + added: number; + deleted: number; + diffAvailable?: false; + path: string; + }>, maxVisibleFiles = 5, ): ReadonlyArray => { const order: Array = []; @@ -133,6 +140,9 @@ export const formatWalkthroughFileLineRows = ( totalsByPath.set(item.path, { added: current.added + item.added, deleted: current.deleted + item.deleted, + ...(current.diffAvailable === false || item.diffAvailable === false + ? { diffAvailable: false as const } + : {}), }); } @@ -143,8 +153,11 @@ export const formatWalkthroughFileLineRows = ( if (order.length > maxVisibleFiles) { const totals = [...totalsByPath.values()].reduce( (sum, item) => ({ added: sum.added + item.added, deleted: sum.deleted + item.deleted }), - { added: 0, deleted: 0 }, + { added: 0, deleted: 0 } as NarrativeLineCount, ); + if ([...totalsByPath.values()].some((item) => item.diffAvailable === false)) { + totals.diffAvailable = false; + } return [ { ...totals, @@ -162,6 +175,56 @@ export const formatWalkthroughFileLineRows = ( })); }; +export const resolveWalkthroughFileLineItems = ( + hunks: ReadonlyArray, + files: ReadonlyArray, +): ReadonlyArray<{ added: number; deleted: number; diffAvailable?: false; path: string }> => + hunks.map((hunk) => { + if (!isSyntheticWalkthroughHunk(hunk)) { + return { added: hunk.added, deleted: hunk.deleted, path: hunk.path }; + } + + const resolved = resolveWalkthroughHunkFile(hunk, files); + if (!resolved) { + return { added: 0, deleted: 0, diffAvailable: false, path: hunk.path }; + } + const { file, section } = resolved; + const hasExactContents = + (section.newFile != null && + (section.oldFile != null || file.status === 'added' || file.status === 'untracked')) || + (section.oldFile != null && file.status === 'deleted'); + if (hasExactContents) { + const lineCount = getDiffSectionLineCount(file, section); + if (lineCount.countable) { + return { + added: lineCount.additions, + deleted: lineCount.deletions, + path: hunk.path, + }; + } + } + if (section.patch.trim().length > 0) { + const materialized = getSectionWalkthroughHunks(file, section).find( + (candidate: { id: string }) => candidate.id === hunk.id, + ) as { added: number; deleted: number } | undefined; + if (materialized) { + return { + added: materialized.added, + deleted: materialized.deleted, + path: hunk.path, + }; + } + } + if (section.lineCount != null) { + return { + added: section.lineCount.additions, + deleted: section.lineCount.deletions, + path: hunk.path, + }; + } + return { added: 0, deleted: 0, diffAvailable: false, path: hunk.path }; + }); + const walkthroughCoveredHunkIds = (view: WalkthroughView): ReadonlySet => new Set([...view.sequence, ...view.support].flatMap((item) => item.hunkIds)); @@ -263,12 +326,13 @@ export const getUncoveredWalkthroughFileLineItems = ( files: ReadonlyArray, view: WalkthroughView, showWhitespace: boolean, -): ReadonlyArray<{ added: number; deleted: number; path: string }> => +): ReadonlyArray<{ added: number; deleted: number; diffAvailable?: false; path: string }> => getUncoveredWalkthroughFiles(files, view, showWhitespace).map((file) => { const lineCount = getDiffLineCount(file, showWhitespace); return { added: lineCount.countable ? lineCount.additions : 0, deleted: lineCount.countable ? lineCount.deletions : 0, + ...(!lineCount.countable ? { diffAvailable: false as const } : {}), path: file.path, }; }); diff --git a/core/lib/reload-selection.ts b/core/lib/reload-selection.ts index d602ffa5..0c62fece 100644 --- a/core/lib/reload-selection.ts +++ b/core/lib/reload-selection.ts @@ -1,4 +1,10 @@ -import type { GitFileStatus, RepositoryState, ReviewSource } from '../types.ts'; +import type { + GitFileStatus, + RepositoryState, + ResolvedReviewSource, + ReviewSource, +} from '../types.ts'; +import { decodeResolvedReviewSource, decodeReviewSource } from './review-source-codec.ts'; import { getSourceKey } from './source.ts'; const reloadSelectionStorageKey = 'codiff.reloadSelection.v3'; @@ -11,13 +17,13 @@ type ReloadSelectionFile = { export type ReloadMainMode = 'commit' | 'review'; -type ReloadSelection = { +export type ReloadSelection = { files: ReadonlyArray; historySource?: ReviewSource | null; mainMode?: ReloadMainMode; root: string; selectedPath: string | null; - source: ReviewSource; + source: ResolvedReviewSource; }; const getStorage = () => { @@ -31,51 +37,10 @@ const getStorage = () => { const isObject = (value: unknown): value is Record => typeof value === 'object' && value != null; -const isOptionalString = (value: unknown) => value == null || typeof value === 'string'; +const isReviewSource = (value: unknown): value is ReviewSource => decodeReviewSource(value) != null; -const isReviewSource = (value: unknown): value is ReviewSource => { - if (!isObject(value) || typeof value.type !== 'string') { - return false; - } - - if (value.type === 'working-tree') { - return true; - } - - if (value.type === 'commit') { - return typeof value.ref === 'string'; - } - - if (value.type === 'range') { - return ( - typeof value.base === 'string' && - typeof value.head === 'string' && - typeof value.symmetric === 'boolean' - ); - } - - if (value.type === 'branch') { - return typeof value.ref === 'string'; - } - - if (value.type === 'branch-diff' || value.type === 'branch-working-tree') { - return ( - typeof value.ref === 'string' && - typeof value.baseRef === 'string' && - typeof value.headRef === 'string' - ); - } - - return ( - value.type === 'pull-request' && - typeof value.url === 'string' && - (value.number == null || typeof value.number === 'number') && - isOptionalString(value.headSha) && - isOptionalString(value.owner) && - isOptionalString(value.repo) && - isOptionalString(value.title) - ); -}; +const isResolvedReviewSource = (value: unknown): value is ResolvedReviewSource => + decodeResolvedReviewSource(value) != null; const isGitFileStatus = (value: unknown): value is GitFileStatus => value === 'added' || @@ -99,7 +64,7 @@ const isReloadSelection = (value: unknown): value is ReloadSelection => (value.mainMode == null || value.mainMode === 'commit' || value.mainMode === 'review') && typeof value.root === 'string' && (value.selectedPath == null || typeof value.selectedPath === 'string') && - isReviewSource(value.source); + isResolvedReviewSource(value.source); const getMatchingSelection = (selection: ReloadSelection | null, state: RepositoryState) => selection?.root === state.root && getSourceKey(selection.source) === getSourceKey(state.source) diff --git a/core/lib/repository-refresh.ts b/core/lib/repository-refresh.ts new file mode 100644 index 00000000..eb939915 --- /dev/null +++ b/core/lib/repository-refresh.ts @@ -0,0 +1,106 @@ +import type { RepositoryState, ReviewSource } from '../types.ts'; +import type { ReloadMainMode } from './reload-selection.ts'; +import { getHistorySource } from './source.ts'; + +type RefreshFile = RepositoryState['files'][number]; + +const getSectionCodeIdentity = (section: RefreshFile['sections'][number]) => { + if (section.patch) { + return `patch\0${section.patch}`; + } + + if (section.summary?.fingerprint) { + return `blob\0${section.summary.fingerprint}`; + } + + if (section.oldFile || section.newFile) { + return `contents\0${section.oldFile?.contents ?? ''}\0${section.newFile?.contents ?? ''}`; + } + + return 'unavailable'; +}; + +const getFileCodeIdentity = (file: RefreshFile) => + [ + file.path, + file.oldPath ?? '', + file.status, + ...file.sections.map(getSectionCodeIdentity).toSorted(), + ].join('\0'); + +const getFilesByPath = (files: RepositoryState['files']) => + new Map(files.map((file) => [file.path, getFileCodeIdentity(file)])); + +const getReviewedCodeChangedPaths = ( + previousFiles: RepositoryState['files'], + nextFiles: RepositoryState['files'], +) => { + const previousByPath = getFilesByPath(previousFiles); + const changedPaths = new Set(); + for (const file of nextFiles) { + if (previousByPath.get(file.path) !== getFileCodeIdentity(file)) { + changedPaths.add(file.path); + } + } + return changedPaths; +}; + +export const hasReviewedCodeChanged = ( + previousFiles: RepositoryState['files'], + nextFiles: RepositoryState['files'], +) => { + if (previousFiles.length !== nextFiles.length) { + return true; + } + + const previousIdentities = previousFiles.map(getFileCodeIdentity).toSorted(); + const nextIdentities = nextFiles.map(getFileCodeIdentity).toSorted(); + return previousIdentities.some((identity, index) => identity !== nextIdentities[index]); +}; + +export type RepositoryRefreshReconciliation = { + changedPaths: ReadonlySet; + collapsed: Set; + historySource: ReviewSource | null; + mainMode: ReloadMainMode; + selectedPath: string | null; + walkthroughNeedsRefresh: boolean; +}; + +export const reconcileRepositoryRefresh = ({ + collapsed, + historySource, + mainMode, + nextState, + previousState, + selectedPath, +}: { + collapsed: ReadonlySet; + historySource: ReviewSource | null; + mainMode: ReloadMainMode; + nextState: RepositoryState; + previousState: RepositoryState; + selectedPath: string | null; +}): RepositoryRefreshReconciliation => { + const changedPaths = getReviewedCodeChangedPaths(previousState.files, nextState.files); + const nextCollapsed = new Set(collapsed); + for (const path of changedPaths) { + nextCollapsed.delete(path); + } + + return { + changedPaths, + collapsed: nextCollapsed, + historySource: getHistorySource(nextState.source) ?? historySource, + mainMode: + mainMode === 'commit' && + (nextState.source.type !== 'working-tree' || nextState.files.length === 0) + ? 'review' + : mainMode, + selectedPath: + selectedPath != null && nextState.files.some((file) => file.path === selectedPath) + ? selectedPath + : (nextState.files[0]?.path ?? null), + walkthroughNeedsRefresh: hasReviewedCodeChanged(previousState.files, nextState.files), + }; +}; diff --git a/core/lib/repository-review-bootstrap.ts b/core/lib/repository-review-bootstrap.ts new file mode 100644 index 00000000..b5e108a3 --- /dev/null +++ b/core/lib/repository-review-bootstrap.ts @@ -0,0 +1,91 @@ +import type { + CodiffLaunchOptions, + RepositoryState, + ResolvedReviewSource, + ReviewSource, +} from '../types.ts'; +import type { ReviewScrollTarget, SidebarMode } from './app-types.ts'; +import { + getReloadDeltaPaths, + getReloadHistorySource, + getReloadMainMode, + getReloadSelectionPath, + haveReloadedFilesChanged, + type ReloadMainMode, + type ReloadSelection, +} from './reload-selection.ts'; +import { + getHistorySource, + getRefreshSource, + getSourceKey, + shouldStartInHistoryWhenEmpty, +} from './source.ts'; + +export type RepositoryReviewBootstrap = { + forceInitialWalkthrough: boolean; + historySource: ReviewSource | null; + initialScrollTarget: ReviewScrollTarget | null; + mainMode: ReloadMainMode; + reloadDeltaPaths: ReadonlySet; + selectedPath: string | null; + sidebarMode: SidebarMode; + source: ResolvedReviewSource; + state: RepositoryState; +}; + +export const resolveReloadSourceForLaunch = ( + reloadSelection: ReloadSelection | null, + launchOptions: CodiffLaunchOptions, +): ReviewSource | undefined => { + if (!reloadSelection) { + return undefined; + } + if (!launchOptions.source) { + return getRefreshSource(reloadSelection.source); + } + return getSourceKey(reloadSelection.source) === getSourceKey(launchOptions.source) + ? getRefreshSource(reloadSelection.source) + : undefined; +}; + +export const resolveRepositoryReviewBootstrap = ({ + launchOptions, + reloadSelection, + state, +}: { + launchOptions: CodiffLaunchOptions; + reloadSelection: ReloadSelection | null; + state: RepositoryState; +}): RepositoryReviewBootstrap => { + const restoredSelectedPath = getReloadSelectionPath(reloadSelection, state); + const selectedPath = restoredSelectedPath ?? state.files[0]?.path ?? null; + const requestedMainMode = getReloadMainMode(reloadSelection, state); + const mainMode: ReloadMainMode = + requestedMainMode === 'commit' && state.source.type === 'working-tree' && state.files.length > 0 + ? 'commit' + : 'review'; + const walkthroughRequested = Boolean(launchOptions.walkthrough || launchOptions.walkthroughFile); + const sidebarMode: SidebarMode = walkthroughRequested + ? 'walkthrough' + : shouldStartInHistoryWhenEmpty(state.source) && state.files.length === 0 + ? 'history' + : 'tree'; + + return { + forceInitialWalkthrough: + walkthroughRequested && + !launchOptions.walkthroughFile && + haveReloadedFilesChanged(reloadSelection, state), + historySource: + getReloadHistorySource(reloadSelection, state) ?? getHistorySource(state.source) ?? null, + initialScrollTarget: restoredSelectedPath + ? { behavior: 'instant', path: restoredSelectedPath, request: 1 } + : null, + mainMode, + reloadDeltaPaths: getReloadDeltaPaths(reloadSelection, state), + selectedPath, + sidebarMode, + source: state.source, + state, + }; +}; diff --git a/core/lib/review-artifacts.ts b/core/lib/review-artifacts.ts new file mode 100644 index 00000000..97627ab3 --- /dev/null +++ b/core/lib/review-artifacts.ts @@ -0,0 +1,583 @@ +import type { GitFileStatus, GitSha, ReviewCommitSummary } from '../types.ts'; +import { validateReviewCommitStack } from './review-commit-stack.ts'; + +export const reviewArtifactSchemaVersion = 'review-artifact-v1'; + +export type ArtifactCoverage = 'complete' | 'opaque' | 'truncated'; + +export type ReviewArtifactProject = { + host: string; + project: string; + provider: 'git' | 'github' | 'gitlab'; +}; + +export type ReviewArtifactProvenance = { + kind: 'github-api' | 'gitlab-api' | 'native-git'; + project: ReviewArtifactProject; +}; + +export type StackSnapshot = { + baseSha: GitSha; + commits: ReadonlyArray; + coverage: ArtifactCoverage; + headSha: GitSha; + provenance: ReviewArtifactProvenance; +}; + +export type ArtifactFile = { + coverage: ArtifactCoverage; + lineCount?: { additions: number; deletions: number }; + newMode?: string; + newObjectId?: string; + oldMode?: string; + oldObjectId?: string; + oldPath?: string; + /** Complete normalized patch when textual patch data is available. */ + patch?: string; + path: string; + status: GitFileStatus; +}; + +export type CommitArtifact = { + commitSha: GitSha; + coverage: ArtifactCoverage; + files: ReadonlyArray; + /** The selected parent whose change this artifact represents; null for a root commit. */ + parentSha: GitSha | null; + provenance: ReviewArtifactProvenance; +}; + +export type CommitArtifactRequest = { + commitSha: GitSha; + parentSha: GitSha | null; +}; + +export type CommitArtifactRequestKey = string & { + readonly __commitArtifactRequestKey: unique symbol; +}; + +export type ReviewArtifactRangeRequest = { + /** Immutable requested review head; providers must not substitute this coordinate. */ + headSha: GitSha; + /** Immutable provider selector. The returned artifact base may differ after resolution. */ + requestedBaseSha: GitSha; +}; + +export type ReviewArtifactRangeResult = { + range: RangeArtifact; + stack: StackSnapshot; +}; + +export type RangeArtifact = { + baseSha: GitSha; + coverage: ArtifactCoverage; + files: ReadonlyArray; + headSha: GitSha; + /** Human-readable reason when the whole range is incomplete. */ + incompleteReason?: string; + provenance: ReviewArtifactProvenance; +}; + +export type BlobArtifact = { + bytes: Uint8Array; + objectId: string; + provenance: ReviewArtifactProvenance; +}; + +/** Immutable endpoint coordinate used to resolve a path to its Git blob. */ +export type FileBlobArtifactRequest = { + /** Per-file retained-byte limit for on-demand context or image rendering. */ + maxBytes?: number; + path: string; + ref: GitSha; +}; + +export interface ReviewArtifactSource { + readBlobs( + objectIds: ReadonlyArray, + signal: AbortSignal, + ): Promise>; + readCommitArtifacts( + commits: ReadonlyArray, + signal: AbortSignal, + ): Promise>; + /** + * Optional path resolver for sources whose caller does not yet know the Git + * object ID. Results are still Blob Artifacts keyed by immutable ref+path. + */ + readFileBlobs?( + requests: ReadonlyArray, + signal: AbortSignal, + ): Promise>; + readStackAndRange( + request: ReviewArtifactRangeRequest, + signal: AbortSignal, + ): Promise; +} + +export type ReviewArtifactRunDiagnostics = { + acquired: { + blobs: Readonly>; + commits: Readonly>; + stackAndRanges: Readonly>; + }; + cacheHits: { + blobs: number; + commits: number; + stackAndRanges: number; + }; + sourceCalls: { + blobs: number; + commits: number; + stackAndRanges: number; + }; +}; + +export type ReviewArtifactRun = ReviewArtifactSource & { + abort(reason?: unknown): void; + diagnostics(): ReviewArtifactRunDiagnostics; + readFileBlobs( + requests: ReadonlyArray, + signal: AbortSignal, + ): Promise>; + readonly signal: AbortSignal; +}; + +export const createFileBlobArtifactRequestKey = ({ path, ref }: FileBlobArtifactRequest) => + `${ref}:${path}`; + +export const createCommitArtifactRequestKey = ({ + commitSha, + parentSha, +}: CommitArtifactRequest): CommitArtifactRequestKey => + `${commitSha}:${parentSha ?? 'root'}` as CommitArtifactRequestKey; + +const validateCoverage = ( + artifact: { + coverage: ArtifactCoverage; + files: ReadonlyArray; + incompleteReason?: string; + }, + label: string, +) => { + if ( + artifact.coverage === 'complete' && + artifact.files.some((file) => file.coverage !== 'complete') + ) { + throw new Error(`${label} cannot be complete when one or more files are incomplete.`); + } + if (artifact.incompleteReason != null) { + if (!artifact.incompleteReason.trim()) { + throw new Error(`${label} has an empty incomplete-evidence reason.`); + } + if (artifact.coverage === 'complete') { + throw new Error(`${label} cannot be complete when it has an incomplete-evidence reason.`); + } + } + for (const file of artifact.files) { + if (!file.path) { + throw new Error(`${label} contains a file without a path.`); + } + if ( + file.lineCount != null && + (!Number.isInteger(file.lineCount.additions) || + file.lineCount.additions < 0 || + !Number.isInteger(file.lineCount.deletions) || + file.lineCount.deletions < 0) + ) { + throw new Error(`${label} contains invalid line counts for ${file.path}.`); + } + if (file.coverage === 'complete' && file.patch == null) { + const hasExactMetadata = + file.oldObjectId != null || + file.newObjectId != null || + (file.oldMode != null && file.newMode != null && file.oldMode !== file.newMode); + if (!hasExactMetadata) { + throw new Error( + `${label} marks ${file.path} complete without a patch or exact object/mode metadata.`, + ); + } + } + } +}; + +export const validateCommitArtifact = (artifact: Artifact) => { + validateCoverage(artifact, `Commit Artifact ${artifact.commitSha}`); + if (artifact.parentSha === artifact.commitSha) { + throw new Error(`Commit Artifact ${artifact.commitSha} cannot select itself as its parent.`); + } + return artifact; +}; + +export const validateRangeArtifact = (artifact: Artifact) => { + validateCoverage(artifact, `Range Artifact ${artifact.baseSha}..${artifact.headSha}`); + return artifact; +}; + +export const validateStackSnapshot = (snapshot: Snapshot) => { + const commits = validateReviewCommitStack(snapshot.commits); + if (commits.length > 0 && commits.at(-1)?.sha !== snapshot.headSha) { + throw new Error('A non-empty Stack Snapshot must end at its declared head SHA.'); + } + if (commits.length === 0 && snapshot.baseSha !== snapshot.headSha) { + throw new Error('An empty Stack Snapshot must have identical base and head SHAs.'); + } + return snapshot; +}; + +const projectsMatch = (left: ReviewArtifactProject, right: ReviewArtifactProject) => + left.provider === right.provider && left.host === right.host && left.project === right.project; + +/** + * Validate one provider-resolved artifact range. `requestedBaseSha` is a + * provider selector; `RangeArtifact.baseSha` and `StackSnapshot.baseSha` are + * the authoritative effective comparison base. + */ +export const validateReviewArtifactRangeResult = ( + request: ReviewArtifactRangeRequest, + result: Result, +) => { + const range = validateRangeArtifact(result.range); + const stack = result.stack; + if (range.baseSha !== stack.baseSha || range.headSha !== stack.headSha) { + throw new Error( + 'Artifact Source returned a Range Artifact and Stack Snapshot for different endpoints.', + ); + } + if (range.headSha !== request.headSha) { + throw new Error( + 'Artifact Source substituted a different head SHA for the requested artifact range.', + ); + } + if (!projectsMatch(range.provenance.project, stack.provenance.project)) { + throw new Error( + 'Artifact Source returned range and stack artifacts for different provider projects.', + ); + } + if ( + request.requestedBaseSha === request.headSha && + (range.baseSha !== request.headSha || range.files.length !== 0 || stack.commits.length !== 0) + ) { + throw new Error('A same-commit artifact request must return an empty same-commit result.'); + } + return { range, stack: validateStackSnapshot(stack) } as Result; +}; + +const increment = (counts: Map, key: string) => + counts.set(key, (counts.get(key) ?? 0) + 1); + +const countRecord = (counts: ReadonlyMap) => Object.fromEntries(counts); + +const fileBlobCacheKey = (key: string, request: FileBlobArtifactRequest) => + `${key}:${request.maxBytes ?? 'default'}`; + +/** + * Coordinate one comparison's immutable artifact reads. Completed values and + * explicit source misses are reused only inside this run; cancelable in-flight + * work is never shared with an unrelated run. + */ +export const createReviewArtifactRun = ( + source: ReviewArtifactSource, + options: { controller?: AbortController; signal?: AbortSignal } = {}, +): ReviewArtifactRun => { + const controller = options.controller ?? new AbortController(); + const boundSignals = new WeakSet(); + const commitValues = new Map(); + const commitPending = new Map>(); + const blobValues = new Map(); + const blobPending = new Map>(); + const fileBlobValues = new Map(); + const fileBlobPending = new Map>(); + const stackAndRangeValues = new Map(); + const stackAndRangePending = new Map>(); + const acquired = { + blobs: new Map(), + commits: new Map(), + stackAndRanges: new Map(), + }; + const cacheHits = { blobs: 0, commits: 0, stackAndRanges: 0 }; + const sourceCalls = { blobs: 0, commits: 0, stackAndRanges: 0 }; + + const bindSignal = (signal?: AbortSignal) => { + if (!signal || signal === controller.signal || boundSignals.has(signal)) { + return; + } + boundSignals.add(signal); + if (signal.aborted) { + controller.abort(signal.reason); + } else { + signal.addEventListener('abort', () => controller.abort(signal.reason), { once: true }); + } + }; + bindSignal(options.signal); + + const readCommitArtifacts = async ( + commits: ReadonlyArray, + signal: AbortSignal, + ) => { + bindSignal(signal); + controller.signal.throwIfAborted(); + const requested = [ + ...new Map( + commits.map((commit) => [createCommitArtifactRequestKey(commit), commit]), + ).entries(), + ]; + const misses = requested.filter(([key]) => { + if (commitValues.has(key) || commitPending.has(key)) { + cacheHits.commits += 1; + return false; + } + return true; + }); + if (misses.length > 0) { + sourceCalls.commits += 1; + for (const [key] of misses) { + increment(acquired.commits, key); + } + const requestedBatchKeys = new Set(misses.map(([key]) => key)); + const batch = source + .readCommitArtifacts( + misses.map(([, commit]) => commit), + controller.signal, + ) + .then((artifacts) => { + for (const [key, artifact] of artifacts) { + if (!requestedBatchKeys.has(key)) { + throw new Error(`Artifact Source returned unrequested commit coordinate ${key}.`); + } + if (createCommitArtifactRequestKey(artifact) !== key) { + throw new Error(`Artifact Source returned different coordinates for ${key}.`); + } + } + return artifacts; + }); + for (const [key, commit] of misses) { + const pending = batch + .then((artifacts) => { + const artifact = artifacts.get(key); + if (!artifact) { + return null; + } + if ( + artifact.commitSha !== commit.commitSha || + artifact.parentSha !== commit.parentSha + ) { + throw new Error( + `Artifact Source returned different coordinates for commit ${commit.commitSha}.`, + ); + } + return validateCommitArtifact(artifact); + }) + .then((artifact) => { + commitValues.set(key, artifact); + return artifact; + }) + .finally(() => { + if (commitPending.get(key) === pending) { + commitPending.delete(key); + } + }); + commitPending.set(key, pending); + } + } + await Promise.all(requested.map(([key]) => commitPending.get(key)).filter(Boolean)); + controller.signal.throwIfAborted(); + return new Map( + requested + .map(([key]) => [key, commitValues.get(key)] as const) + .filter( + (entry): entry is readonly [CommitArtifactRequestKey, CommitArtifact] => entry[1] != null, + ), + ); + }; + + const readBlobs = async (objectIds: ReadonlyArray, signal: AbortSignal) => { + bindSignal(signal); + controller.signal.throwIfAborted(); + const requested = [...new Set(objectIds)]; + const misses = requested.filter((objectId) => { + if (blobValues.has(objectId) || blobPending.has(objectId)) { + cacheHits.blobs += 1; + return false; + } + return true; + }); + if (misses.length > 0) { + sourceCalls.blobs += 1; + for (const objectId of misses) { + increment(acquired.blobs, objectId); + } + const batch = source.readBlobs(misses, controller.signal); + for (const objectId of misses) { + const pending = batch + .then((blobs) => { + const blob = blobs.get(objectId); + if (!blob) { + return null; + } + if (blob.objectId !== objectId) { + throw new Error(`Artifact Source returned ${blob.objectId} for blob ${objectId}.`); + } + return blob; + }) + .then((blob) => { + blobValues.set(objectId, blob); + return blob; + }) + .finally(() => { + if (blobPending.get(objectId) === pending) { + blobPending.delete(objectId); + } + }); + blobPending.set(objectId, pending); + } + } + await Promise.all(requested.map((objectId) => blobPending.get(objectId)).filter(Boolean)); + controller.signal.throwIfAborted(); + return new Map( + requested + .map((objectId) => [objectId, blobValues.get(objectId)] as const) + .filter((entry): entry is readonly [string, BlobArtifact] => entry[1] != null), + ); + }; + + const readFileBlobs = async ( + requests: ReadonlyArray, + signal: AbortSignal, + ) => { + bindSignal(signal); + controller.signal.throwIfAborted(); + const requested = [ + ...new Map( + requests.map((request) => { + if ( + request.maxBytes != null && + (!Number.isFinite(request.maxBytes) || request.maxBytes < 0) + ) { + throw new RangeError('File Blob Artifact byte limit must be non-negative and finite.'); + } + const normalized = { + ...request, + ...(request.maxBytes == null ? {} : { maxBytes: Math.floor(request.maxBytes) }), + }; + return [createFileBlobArtifactRequestKey(normalized), normalized] as const; + }), + ).entries(), + ]; + const misses = requested.filter(([key, request]) => { + const keyWithLimit = fileBlobCacheKey(key, request); + if (fileBlobValues.has(keyWithLimit) || fileBlobPending.has(keyWithLimit)) { + cacheHits.blobs += 1; + return false; + } + return true; + }); + if (misses.length > 0 && source.readFileBlobs) { + sourceCalls.blobs += 1; + for (const [key] of misses) { + increment(acquired.blobs, `file:${key}`); + } + const batch = source.readFileBlobs!( + misses.map(([, request]) => request), + controller.signal, + ); + for (const [key, request] of misses) { + const keyWithLimit = fileBlobCacheKey(key, request); + const pending = batch + .then((blobs) => { + const blob = blobs.get(key); + if (!blob || (request.maxBytes != null && blob.bytes.byteLength > request.maxBytes)) { + return null; + } + if (!blob.objectId) { + throw new Error( + `Artifact Source returned a file blob without an object ID for ${key}.`, + ); + } + return blob; + }) + .then((blob) => { + fileBlobValues.set(keyWithLimit, blob); + if (blob) { + blobValues.set(blob.objectId, blob); + } + return blob; + }) + .finally(() => { + if (fileBlobPending.get(keyWithLimit) === pending) { + fileBlobPending.delete(keyWithLimit); + } + }); + fileBlobPending.set(keyWithLimit, pending); + } + } else if (misses.length > 0) { + for (const [key, request] of misses) { + fileBlobValues.set(fileBlobCacheKey(key, request), null); + } + } + await Promise.all( + requested + .map(([key, request]) => fileBlobPending.get(fileBlobCacheKey(key, request))) + .filter(Boolean), + ); + controller.signal.throwIfAborted(); + return new Map( + requested + .map(([key, request]) => [key, fileBlobValues.get(fileBlobCacheKey(key, request))] as const) + .filter((entry): entry is readonly [string, BlobArtifact] => entry[1] != null), + ); + }; + + const readStackAndRange = async (request: ReviewArtifactRangeRequest, signal: AbortSignal) => { + bindSignal(signal); + controller.signal.throwIfAborted(); + const key = `${request.requestedBaseSha}:${request.headSha}`; + const cached = stackAndRangeValues.get(key); + if (cached) { + cacheHits.stackAndRanges += 1; + return cached; + } + const active = stackAndRangePending.get(key); + if (active) { + cacheHits.stackAndRanges += 1; + return active; + } + sourceCalls.stackAndRanges += 1; + increment(acquired.stackAndRanges, key); + const pending = source + .readStackAndRange(request, controller.signal) + .then((value) => { + controller.signal.throwIfAborted(); + return validateReviewArtifactRangeResult(request, value); + }) + .then((value) => { + stackAndRangeValues.set(key, value); + return value; + }) + .finally(() => { + if (stackAndRangePending.get(key) === pending) { + stackAndRangePending.delete(key); + } + }); + stackAndRangePending.set(key, pending); + return pending; + }; + + return { + abort: (reason) => controller.abort(reason), + diagnostics: () => ({ + acquired: { + blobs: countRecord(acquired.blobs), + commits: countRecord(acquired.commits), + stackAndRanges: countRecord(acquired.stackAndRanges), + }, + cacheHits: { ...cacheHits }, + sourceCalls: { ...sourceCalls }, + }), + readBlobs, + readCommitArtifacts, + readFileBlobs, + readStackAndRange, + signal: controller.signal, + }; +}; diff --git a/core/lib/review-command-target.ts b/core/lib/review-command-target.ts index b7405e3f..4993d5aa 100644 --- a/core/lib/review-command-target.ts +++ b/core/lib/review-command-target.ts @@ -1,4 +1,4 @@ -import type { ChangedFile, ReviewSource } from '../types.ts'; +import type { ChangedFile, ResolvedReviewSource } from '../types.ts'; import type { ReviewIdentity } from './app-types.ts'; import { getFileReviewIdentity } from './review-identity.ts'; import { getSourceKey } from './source.ts'; @@ -10,7 +10,7 @@ export type ReviewCommandTarget = { }; export const createReviewCommandTarget = ( - source: ReviewSource, + source: ResolvedReviewSource, file: ChangedFile, reviewIdentity: ReviewIdentity = getFileReviewIdentity(file), ): ReviewCommandTarget => ({ @@ -29,7 +29,7 @@ export const resolveReviewCommandTarget = ({ activeTarget: ReviewCommandTarget | null; files: ReadonlyArray; selectedPath: string | null; - source: ReviewSource; + source: ResolvedReviewSource; useActiveTarget: boolean; }): ReviewCommandTarget | null => { const sourceKey = getSourceKey(source); diff --git a/core/lib/review-comment-target.ts b/core/lib/review-comment-target.ts new file mode 100644 index 00000000..ac8b2ffc --- /dev/null +++ b/core/lib/review-comment-target.ts @@ -0,0 +1,207 @@ +import type { + ChangedFile, + DiffRange, + DiffSection, + ProviderReviewCommentPosition, + ReviewCommentPosition, + Revision, +} from '../types.ts'; +import { parseSectionDiffWithOptions } from './diff.ts'; +import { isCommitRevision } from './review-history.ts'; + +type ReviewCommentTargetFailure = { + reason: + | 'anchor-not-in-target' + | 'ambiguous-target-range' + | 'file-not-in-target' + | 'missing-target-range' + | 'non-commit-target' + | 'section-not-in-target'; + status: 'read-only'; +}; + +export type ShareCommentTargetResolution = + | ReviewCommentTargetFailure + | { + position: ReviewCommentPosition; + sectionId?: never; + status: 'enabled'; + } + | { + position?: never; + sectionId: string; + status: 'enabled'; + }; + +export type ProviderCommentTargetResolution = + | ReviewCommentTargetFailure + | { + position: ProviderReviewCommentPosition; + status: 'enabled'; + }; + +type ReviewCommentTargetInput = { + anchor?: 'file' | 'line'; + file: ChangedFile; + lineNumber?: number; + section?: DiffSection; + showWhitespace: boolean; + side?: 'additions' | 'deletions'; + startLineNumber?: number; + startSide?: 'additions' | 'deletions'; +}; + +const revisionKind = (revision: Revision) => revision.kind ?? 'commit'; + +const revisionsMatch = (left: Revision | null, right: Revision | null) => { + if (!left || !right) { + return left === right; + } + const leftKind = revisionKind(left); + const rightKind = revisionKind(right); + return ( + leftKind === rightKind && + (leftKind !== 'commit' || ('sha' in left && 'sha' in right && left.sha === right.sha)) + ); +}; + +export const diffRangesMatch = (left: DiffRange | undefined, right: DiffRange | undefined) => + left != null && + right != null && + revisionsMatch(left.base, right.base) && + revisionsMatch(left.head, right.head); + +const lineExistsInSection = ( + file: ChangedFile, + section: DiffSection, + lineNumber: number, + side: 'additions' | 'deletions', + showWhitespace: boolean, +) => { + const parsed = parseSectionDiffWithOptions(file, section, showWhitespace); + return parsed.hunks.some((hunk) => { + let oldLine = hunk.deletionStart; + let newLine = hunk.additionStart; + for (const content of hunk.hunkContent) { + if (content.type === 'context') { + const start = side === 'additions' ? newLine : oldLine; + if (lineNumber >= start && lineNumber < start + content.lines) { + return true; + } + oldLine += content.lines; + newLine += content.lines; + continue; + } + const start = side === 'additions' ? newLine : oldLine; + const length = side === 'additions' ? content.additions : content.deletions; + if (lineNumber >= start && lineNumber < start + length) { + return true; + } + oldLine += content.deletions; + newLine += content.additions; + } + return false; + }); +}; + +const targetContainsAnchor = ({ + anchor, + file, + lineNumber, + section, + showWhitespace, + side, + startLineNumber, + startSide, +}: ReviewCommentTargetInput & { section: DiffSection }) => { + if (anchor === 'file' || lineNumber == null) { + return true; + } + const endSide = side ?? 'additions'; + const resolvedStartSide = startSide ?? endSide; + return ( + lineExistsInSection(file, section, lineNumber, endSide, showWhitespace) && + (startLineNumber == null || + lineExistsInSection(file, section, startLineNumber, resolvedStartSide, showWhitespace)) + ); +}; + +export const resolveShareCommentTarget = ({ + displayedFiles, + ...input +}: ReviewCommentTargetInput & { + displayedFiles: ReadonlyArray; +}): ShareCommentTargetResolution => { + const candidates = displayedFiles.filter( + (candidate) => candidate.path === input.file.path || candidate.oldPath === input.file.path, + ); + if (candidates.length === 0) { + return { reason: 'file-not-in-target', status: 'read-only' }; + } + if (!input.section) { + return { reason: 'section-not-in-target', status: 'read-only' }; + } + const targets = candidates.flatMap((file) => + file.sections + .filter((section) => section.id === input.section?.id) + .map((section) => ({ file, section })), + ); + if (targets.length === 0) { + return { reason: 'section-not-in-target', status: 'read-only' }; + } + if (targets.length > 1) { + return { reason: 'ambiguous-target-range', status: 'read-only' }; + } + const target = targets[0]!; + if (!targetContainsAnchor({ ...input, file: target.file, section: target.section })) { + return { reason: 'anchor-not-in-target', status: 'read-only' }; + } + return target.section.range + ? { position: { range: target.section.range }, status: 'enabled' } + : { sectionId: target.section.id, status: 'enabled' }; +}; + +export const resolveProviderCommentTarget = ({ + canonicalFiles, + ...input +}: ReviewCommentTargetInput & { + canonicalFiles: ReadonlyArray; +}): ProviderCommentTargetResolution => { + const canonicalFileCandidates = canonicalFiles.filter( + (candidate) => candidate.path === input.file.path || candidate.oldPath === input.file.path, + ); + if (canonicalFileCandidates.length === 0) { + return { reason: 'file-not-in-target', status: 'read-only' }; + } + const targetRange = input.section?.range; + if (!targetRange) { + return { reason: 'missing-target-range', status: 'read-only' }; + } + if (!isCommitRevision(targetRange.base) || !isCommitRevision(targetRange.head)) { + return { reason: 'non-commit-target', status: 'read-only' }; + } + const canonicalSections = canonicalFileCandidates.flatMap((file) => + file.sections.flatMap((section) => + diffRangesMatch(section.range, targetRange) ? [{ file, section }] : [], + ), + ); + if (canonicalSections.length === 0) { + return { reason: 'section-not-in-target', status: 'read-only' }; + } + if (canonicalSections.length > 1) { + return { reason: 'ambiguous-target-range', status: 'read-only' }; + } + const target = canonicalSections[0]!; + if (!targetContainsAnchor({ ...input, file: target.file, section: target.section })) { + return { reason: 'anchor-not-in-target', status: 'read-only' }; + } + return { + position: { + range: { + base: targetRange.base, + head: targetRange.head, + }, + }, + status: 'enabled', + }; +}; diff --git a/core/lib/review-comments.ts b/core/lib/review-comments.ts index 69607a45..cdfa68ad 100644 --- a/core/lib/review-comments.ts +++ b/core/lib/review-comments.ts @@ -1,12 +1,29 @@ import type { ChangedFile, DiffSection, + ProviderCommentSubmission, + ProviderReviewCommentPosition, PullRequestExistingReviewComment, - PullRequestReviewComment, RepositoryState, + ReviewCommentPosition, + ShareCommentSubmission, + SubmittedReviewComment, } from '../types.ts'; -import type { CodeViewInstance, ReviewComment } from './app-types.ts'; +import type { + CodeViewInstance, + LocalReviewNote, + ProviderCommentDraft, + ProviderInlineComment, + RenderedSubmittedReviewComment, + ReviewComment, + ReviewCommentCreation, + ReviewDraft, + ShareCommentDraft, + ShareInlineComment, +} from './app-types.ts'; import { parseSectionDiffWithOptions } from './diff.ts'; +import { diffRangesMatch } from './review-comment-target.ts'; +import { isCommitRevision } from './review-history.ts'; export const isInteractiveReviewEvent = (event: PointerEvent) => event.composedPath().some( @@ -71,9 +88,44 @@ export const hasActiveTextSelection = (target?: Node | null) => { return isExpandedSelection(selection, target); }; +export const isLocalReviewNote = (comment: ReviewComment): comment is LocalReviewNote => + comment.kind === 'local-note'; + +export const isProviderCommentDraft = (comment: ReviewComment): comment is ProviderCommentDraft => + comment.kind === 'provider-draft'; + +export const isShareCommentDraft = (comment: ReviewComment): comment is ShareCommentDraft => + comment.kind === 'share-draft'; + +export const isSubmittedReviewComment = ( + comment: ReviewComment, +): comment is RenderedSubmittedReviewComment => comment.kind === 'submitted-comment'; + +export const isProviderInlineComment = (comment: ReviewComment): comment is ProviderInlineComment => + isSubmittedReviewComment(comment) && comment.destination === 'provider'; + +export const isShareInlineComment = (comment: ReviewComment): comment is ShareInlineComment => + isSubmittedReviewComment(comment) && comment.destination === 'share'; + +export const isReviewDraft = (comment: ReviewComment): comment is ReviewDraft => + isLocalReviewNote(comment) || isProviderCommentDraft(comment) || isShareCommentDraft(comment); + +export const isProviderReviewCommentPosition = ( + position: ReviewCommentPosition, +): position is ProviderReviewCommentPosition => + isCommitRevision(position.range.base) && isCommitRevision(position.range.head); + +export const getReviewCommentRendererSectionId = (comment: ReviewComment) => + isReviewDraft(comment) ? comment.sectionId : comment.resolvedSectionId; + +export const reviewCommentRegionSectionPrefix = 'review-comment-region:'; + +export const isReviewCommentRegionSection = (section: Pick) => + section.id.startsWith(reviewCommentRegionSectionPrefix); + export const isFileReviewComment = ( comment: Pick, -) => comment.anchor === 'file' || comment.lineNumber == null || comment.side == null; +) => comment.anchor === 'file'; export const isLineReviewComment = ( comment: ReviewComment, @@ -127,6 +179,9 @@ export const getReviewCommentLineLabel = ( if (isFileReviewComment(comment)) { return 'File'; } + if (comment.lineNumber == null || comment.side == null) { + return 'Location unavailable'; + } const startLineNumber = comment.startLineNumber; const startSide = getReviewCommentStartSide(comment); if ( @@ -165,23 +220,59 @@ export const getReviewCommentRangeProps = ( : {}; }; -export const toPullRequestReviewComment = ( - comment: ReviewComment, - { includeSectionId = false }: { includeSectionId?: boolean } = {}, -): PullRequestReviewComment => ({ +const getCommentSubmissionFields = (comment: ReviewDraft) => ({ + ...(comment.anchor ? { anchor: comment.anchor } : {}), body: comment.body, filePath: comment.filePath, ...(comment.lineNumber != null ? { lineNumber: comment.lineNumber } : {}), - ...(includeSectionId ? { sectionId: comment.sectionId } : {}), ...(comment.side ? { side: comment.side } : {}), ...getReviewCommentRangeProps(comment), ...(comment.threadId ? { threadId: comment.threadId } : {}), }); -export const toSubmittedReviewComment = ( +export const toProviderCommentSubmission = ( + comment: ProviderCommentDraft, +): ProviderCommentSubmission => { + if (comment.threadId) { + return { + ...getCommentSubmissionFields(comment), + localDraftId: comment.id, + threadId: comment.threadId, + }; + } + + const position = comment.position; + if (!position || !isProviderReviewCommentPosition(position)) { + throw new Error('Provider comments require an exact immutable commit position.'); + } + + return { + ...getCommentSubmissionFields(comment), + localDraftId: comment.id, + position: { + range: { + base: position.range.base, + head: position.range.head, + }, + }, + }; +}; + +export const toShareCommentSubmission = (comment: ShareCommentDraft): ShareCommentSubmission => + comment.position + ? { + ...getCommentSubmissionFields(comment), + position: comment.position, + } + : { + ...getCommentSubmissionFields(comment), + sectionId: comment.sectionId, + }; + +export const toProviderSubmittedReviewComment = ( comment: PullRequestExistingReviewComment, - draft: ReviewComment, -): ReviewComment => ({ + submission: ProviderCommentSubmission, +): SubmittedReviewComment => ({ ...(comment.anchor ? { anchor: comment.anchor } : {}), author: comment.author, body: comment.body, @@ -189,13 +280,14 @@ export const toSubmittedReviewComment = ( ...(comment.canEdit ? { canEdit: true } : {}), ...(comment.canReplyThread === false ? { canReplyThread: false } : {}), ...(comment.canResolveThread ? { canResolveThread: true } : {}), + destination: 'provider', filePath: comment.filePath, id: comment.id, ...(comment.isOutdated ? { isOutdated: true } : {}), isReadOnly: true, ...(comment.isThreadResolved ? { isThreadResolved: true } : {}), ...(comment.lineNumber != null ? { lineNumber: comment.lineNumber } : {}), - sectionId: comment.sectionId ?? draft.sectionId, + ...(submission.position ? { position: submission.position } : {}), ...(comment.side ? { side: comment.side } : {}), ...(comment.startLineNumber != null ? { startLineNumber: comment.startLineNumber } : {}), ...(comment.startSide ? { startSide: comment.startSide } : {}), @@ -204,23 +296,60 @@ export const toSubmittedReviewComment = ( ...(comment.url ? { url: comment.url } : {}), }); +export const toRenderedSubmittedReviewComment = ( + comment: SubmittedReviewComment, + draft?: ProviderCommentDraft | ShareCommentDraft, +): RenderedSubmittedReviewComment => ({ + ...comment, + kind: 'submitted-comment', + ...(comment.resolvedSectionId + ? { resolvedSectionId: comment.resolvedSectionId } + : draft + ? { resolvedSectionId: draft.sectionId } + : {}), +}); + +export const toPullRequestExistingReviewComment = ( + comment: RenderedSubmittedReviewComment, +): PullRequestExistingReviewComment => ({ + ...(comment.anchor ? { anchor: comment.anchor } : {}), + author: comment.author, + body: comment.body, + ...(comment.canDelete ? { canDelete: true } : {}), + ...(comment.canEdit ? { canEdit: true } : {}), + ...(comment.canReplyThread === false ? { canReplyThread: false } : {}), + ...(comment.canResolveThread ? { canResolveThread: true } : {}), + filePath: comment.filePath, + id: comment.id, + ...(comment.isOutdated ? { isOutdated: true } : {}), + ...(comment.isThreadResolved ? { isThreadResolved: true } : {}), + ...(comment.lineNumber != null ? { lineNumber: comment.lineNumber } : {}), + ...(comment.position ? { position: comment.position } : {}), + ...(comment.sectionId ? { sectionId: comment.sectionId } : {}), + ...(comment.side ? { side: comment.side } : {}), + ...getReviewCommentRangeProps(comment), + ...(comment.submittedAt ? { submittedAt: comment.submittedAt } : {}), + ...(comment.threadId ? { threadId: comment.threadId } : {}), + ...(comment.url ? { url: comment.url } : {}), +}); + export const mergeReviewComments = ( - snapshotComments: ReadonlyArray, - localComments: ReadonlyArray, + snapshotComments: ReadonlyArray, + localComments: ReadonlyArray, ): ReadonlyArray => { const snapshotIds = new Set(snapshotComments.map((comment) => comment.id)); return [...snapshotComments, ...localComments.filter((comment) => !snapshotIds.has(comment.id))]; }; -const isPendingPullRequestReviewComment = (comment: ReviewComment) => - !comment.isReadOnly && +const isPendingPullRequestReviewComment = (comment: ProviderCommentDraft) => !comment.threadId && comment.remoteSubmit?.status !== 'submitting' && + comment.remoteSubmit?.status !== 'outcome-unknown' && comment.body.trim().length > 0; export const getPendingPullRequestReviewComments = ( - comments: ReadonlyArray, - activeDraft: Pick | null = null, + comments: ReadonlyArray, + activeDraft: Pick | null = null, ) => { return comments.flatMap((comment) => { const candidate = @@ -230,47 +359,46 @@ export const getPendingPullRequestReviewComments = ( }; export const findReusableReviewCommentDraft = ( - comments: ReadonlyArray, - activeDraft: Pick | null = null, + comments: ReadonlyArray, + activeDraft: Pick | null = null, ) => comments.find( (comment) => - !comment.isReadOnly && comment.body.length === 0 && !(activeDraft?.id === comment.id && activeDraft.body.trim().length > 0), ); -export const getCommentKey = ( - comment: Pick< - ReviewComment, - 'anchor' | 'lineNumber' | 'sectionId' | 'side' | 'startLineNumber' | 'startSide' - >, -) => - isFileReviewComment(comment) - ? `${comment.sectionId}:file` - : `${comment.sectionId}:${comment.side}:${comment.lineNumber}:${ +export const getCommentKey = (comment: ReviewComment | ReviewCommentCreation) => { + const sectionId = + 'kind' in comment + ? (getReviewCommentRendererSectionId(comment) ?? 'unresolved') + : comment.sectionId; + return isFileReviewComment(comment) + ? `${sectionId}:file` + : `${sectionId}:${comment.side}:${comment.lineNumber}:${ comment.startLineNumber ?? comment.lineNumber }:${comment.startSide ?? comment.side}`; +}; const getCommentTextDigest = (value: string | null | undefined) => value ? `${value.length},${value.split('\n').length}` : '0,0'; export const getReviewCommentsDigest = (comments: ReadonlyArray) => comments - .map( - (comment) => - `${comment.id}:${comment.sectionId}:${comment.side}:${comment.lineNumber}:${ - comment.startLineNumber ?? '' - }:${comment.startSide ?? ''}:${comment.anchor ?? ''}:${getCommentTextDigest( - comment.body, - )}:${comment.codexReply?.status ?? ''}:${getCommentTextDigest( - comment.codexReply?.body, - )}:${getCommentTextDigest(comment.codexReply?.error)}:${ - comment.remoteSubmit?.status ?? '' - }:${comment.remoteSubmit?.error ?? ''}:${comment.threadId ?? ''}:${ - comment.canResolveThread === true ? '1' : '0' - }:${comment.isThreadResolved === true ? '1' : '0'}`, - ) + .map((comment) => { + const sectionId = getReviewCommentRendererSectionId(comment) ?? comment.sectionId ?? ''; + return `${comment.id}:${sectionId}:${comment.side}:${comment.lineNumber}:${ + comment.startLineNumber ?? '' + }:${comment.startSide ?? ''}:${comment.anchor ?? ''}:${getCommentTextDigest( + comment.body, + )}:${comment.codexReply?.status ?? ''}:${getCommentTextDigest( + comment.codexReply?.body, + )}:${getCommentTextDigest(comment.codexReply?.error)}:${ + comment.remoteSubmit?.status ?? '' + }:${comment.remoteSubmit?.error ?? ''}:${comment.threadId ?? ''}:${ + comment.canResolveThread === true ? '1' : '0' + }:${comment.isThreadResolved === true ? '1' : '0'}`; + }) .join('\0'); const getMarkdownFence = (content: string) => { @@ -295,7 +423,7 @@ const trimReviewPatchLineTerminator = (line: string) => const getReviewPatchText = (lines: ReadonlyArray, index: number) => trimReviewPatchLineTerminator(lines[index] ?? ''); -const getReviewCommentPatchContext = ( +export const getReviewCommentPatchContext = ( file: ChangedFile, section: DiffSection, comment: ReviewComment, @@ -386,13 +514,160 @@ const getReviewCommentPatchContext = ( return section.summary?.reason || section.patch.trim() || 'No patch context available.'; }; +export type ReviewCommentSectionResolutionStrategy = + | 'coordinates' + | 'file' + | 'position' + | 'section-id'; + +export type ReviewCommentSectionResolution = + | { kind: 'resolved'; section: DiffSection } + | { + candidateSectionIds: ReadonlyArray; + kind: 'ambiguous' | 'unmapped'; + strategy: ReviewCommentSectionResolutionStrategy; + }; + +const resolveReviewCommentSectionCandidates = ( + sections: ReadonlyArray, + strategy: ReviewCommentSectionResolutionStrategy, +): ReviewCommentSectionResolution => + sections.length === 1 + ? { kind: 'resolved', section: sections[0]! } + : { + candidateSectionIds: sections.map((section) => section.id), + kind: sections.length === 0 ? 'unmapped' : 'ambiguous', + strategy, + }; + +const sectionContainsReviewCommentAnchor = ( + file: ChangedFile, + section: DiffSection, + comment: Pick, + showWhitespace: boolean, +) => { + if (isFileReviewComment(comment)) { + return true; + } + if (comment.lineNumber == null || comment.side == null) { + return false; + } + + const line = comment.lineNumber; + const side = comment.side; + const startLine = comment.startLineNumber ?? line; + const startSide = comment.startSide ?? side; + const parsed = parseSectionDiffWithOptions(file, section, showWhitespace); + return parsed.hunks.some((hunk) => { + let oldLine = hunk.deletionStart; + let newLine = hunk.additionStart; + let hasStart = false; + let hasEnd = false; + for (const content of hunk.hunkContent) { + if (content.type === 'context') { + if ( + (startSide === 'additions' && + startLine >= newLine && + startLine < newLine + content.lines) || + (startSide === 'deletions' && startLine >= oldLine && startLine < oldLine + content.lines) + ) { + hasStart = true; + } + if ( + (side === 'additions' && line >= newLine && line < newLine + content.lines) || + (side === 'deletions' && line >= oldLine && line < oldLine + content.lines) + ) { + hasEnd = true; + } + oldLine += content.lines; + newLine += content.lines; + continue; + } + if (side === 'deletions' && line >= oldLine && line < oldLine + content.deletions) { + hasEnd = true; + } + if ( + startSide === 'deletions' && + startLine >= oldLine && + startLine < oldLine + content.deletions + ) { + hasStart = true; + } + if (side === 'additions' && line >= newLine && line < newLine + content.additions) { + hasEnd = true; + } + if ( + startSide === 'additions' && + startLine >= newLine && + startLine < newLine + content.additions + ) { + hasStart = true; + } + oldLine += content.deletions; + newLine += content.additions; + } + return hasStart && hasEnd; + }); +}; + +export const resolveReviewCommentSection = ( + file: ChangedFile, + comment: Pick< + ReviewComment, + 'anchor' | 'lineNumber' | 'position' | 'side' | 'startLineNumber' | 'startSide' + > & { sectionId?: string }, + showWhitespace: boolean, +): ReviewCommentSectionResolution => { + const positionedRange = comment.position?.range; + if (positionedRange) { + return resolveReviewCommentSectionCandidates( + file.sections.filter( + (candidate) => + diffRangesMatch(candidate.range, positionedRange) && + sectionContainsReviewCommentAnchor(file, candidate, comment, showWhitespace), + ), + 'position', + ); + } + + if (comment.sectionId) { + const sections = file.sections.filter((candidate) => candidate.id === comment.sectionId); + if (sections.length > 0) { + return resolveReviewCommentSectionCandidates(sections, 'section-id'); + } + } + + if (isFileReviewComment(comment)) { + return resolveReviewCommentSectionCandidates(file.sections, 'file'); + } + + const matchingSections = file.sections.filter((section) => + sectionContainsReviewCommentAnchor(file, section, comment, showWhitespace), + ); + return resolveReviewCommentSectionCandidates(matchingSections, 'coordinates'); +}; + +export const getReviewCommentSection = ( + file: ChangedFile, + comment: Pick< + ReviewComment, + 'anchor' | 'lineNumber' | 'position' | 'side' | 'startLineNumber' | 'startSide' + > & { sectionId?: string }, + showWhitespace: boolean, +) => { + const resolution = resolveReviewCommentSection(file, comment, showWhitespace); + return resolution.kind === 'resolved' ? resolution.section : undefined; +}; + export const buildReviewCommentsMarkdown = ( files: ReadonlyArray, comments: ReadonlyArray, showWhitespace: boolean, prefix?: string, ) => { - const pendingComments = comments.filter((comment) => !comment.isReadOnly && comment.body.trim()); + const pendingComments = comments.filter( + (comment): comment is ReviewDraft => isReviewDraft(comment) && Boolean(comment.body.trim()), + ); const filesByPath = new Map(files.map((file) => [file.path, file])); const orderedComments = pendingComments.sort((left, right) => { const leftFileIndex = files.findIndex((file) => file.path === left.filePath); @@ -407,7 +682,7 @@ export const buildReviewCommentsMarkdown = ( const markdown = orderedComments .map((comment, index) => { const file = filesByPath.get(comment.filePath); - const section = file?.sections.find((candidate) => candidate.id === comment.sectionId); + const section = file ? getReviewCommentSection(file, comment, showWhitespace) : undefined; const context = file && section ? getReviewCommentPatchContext(file, section, comment, showWhitespace) @@ -429,36 +704,55 @@ export const buildReviewCommentsMarkdown = ( return markdown ? `${resolvedPrefix}${markdown}` : ''; }; -export const getReviewCommentsFromState = (state: RepositoryState): ReadonlyArray => - (state.reviewComments ?? []).flatMap((comment) => { +export const getReviewCommentsFromState = ( + state: RepositoryState, + destination: 'provider' | 'share' = 'provider', +): ReadonlyArray => + (state.reviewComments ?? []).map((comment) => { const file = state.files.find((candidate) => candidate.path === comment.filePath); + const hasProviderPosition = + comment.position != null && isProviderReviewCommentPosition(comment.position); const section = - file?.sections.find((candidate) => candidate.id === comment.sectionId) ?? file?.sections[0]; - return section - ? [ - { - author: comment.author, - body: comment.body, - ...(comment.canDelete ? { canDelete: true } : {}), - ...(comment.canEdit ? { canEdit: true } : {}), - ...(comment.canReplyThread === false ? { canReplyThread: false } : {}), - ...(comment.canResolveThread ? { canResolveThread: true } : {}), - filePath: comment.filePath, - id: comment.id, - ...(comment.isOutdated ? { isOutdated: true } : {}), - isReadOnly: true, - ...(comment.isThreadResolved ? { isThreadResolved: true } : {}), - ...(comment.anchor === 'file' ? { anchor: 'file' as const } : {}), - ...(comment.lineNumber != null ? { lineNumber: comment.lineNumber } : {}), - sectionId: section.id, - ...(comment.side ? { side: comment.side } : {}), - ...getReviewCommentRangeProps(comment), - submittedAt: comment.submittedAt, - ...(comment.threadId ? { threadId: comment.threadId } : {}), - url: comment.url, - }, - ] - : []; + file && (destination === 'share' || hasProviderPosition) + ? getReviewCommentSection(file, comment, false) + : undefined; + const common = { + author: comment.author, + body: comment.body, + ...(comment.canDelete ? { canDelete: true } : {}), + ...(comment.canEdit ? { canEdit: true } : {}), + ...(comment.canReplyThread === false ? { canReplyThread: false } : {}), + ...(comment.canResolveThread ? { canResolveThread: true } : {}), + filePath: comment.filePath, + id: comment.id, + ...(comment.isOutdated ? { isOutdated: true } : {}), + isReadOnly: true as const, + kind: 'submitted-comment' as const, + ...(comment.isThreadResolved ? { isThreadResolved: true } : {}), + ...(comment.anchor === 'file' ? { anchor: 'file' as const } : {}), + ...(comment.lineNumber != null ? { lineNumber: comment.lineNumber } : {}), + ...(section ? { resolvedSectionId: section.id } : {}), + ...(comment.side ? { side: comment.side } : {}), + ...getReviewCommentRangeProps(comment), + ...(comment.submittedAt ? { submittedAt: comment.submittedAt } : {}), + ...(comment.threadId ? { threadId: comment.threadId } : {}), + ...(comment.url ? { url: comment.url } : {}), + }; + + return destination === 'share' + ? { + ...common, + destination, + ...(comment.position ? { position: comment.position } : {}), + ...(comment.sectionId ? { sectionId: comment.sectionId } : {}), + } + : { + ...common, + destination, + ...(comment.position && isProviderReviewCommentPosition(comment.position) + ? { position: comment.position } + : {}), + }; }); export const getVisibleReviewComments = ( diff --git a/core/lib/review-commit-stack.ts b/core/lib/review-commit-stack.ts new file mode 100644 index 00000000..d28d404f --- /dev/null +++ b/core/lib/review-commit-stack.ts @@ -0,0 +1,92 @@ +import type { GitSha, ReviewCommitSummary } from '../types.ts'; + +/** + * A proposed change ordered from its external base toward its review head. + * Every included parent precedes its child. + */ +export type ReviewCommitStack = + ReadonlyArray; + +export type ReviewCommitStackItem = { + authoredAt?: string; + authoredDate?: string; + committedAt?: number | string; + parentShas: ReadonlyArray; + sha: GitSha; +}; + +const commitTime = (commit: ReviewCommitStackItem) => { + const value = commit.committedAt ?? commit.authoredAt ?? commit.authoredDate; + const parsed = typeof value === 'number' ? value : value ? Date.parse(value) : Number.NaN; + return Number.isFinite(parsed) ? parsed : Number.POSITIVE_INFINITY; +}; + +const compareReadyCommits = (first: ReviewCommitStackItem, second: ReviewCommitStackItem) => + commitTime(first) - commitTime(second) || first.sha.localeCompare(second.sha); + +const indexCommitGraph = (commits: ReadonlyArray) => { + const bySha = new Map(); + for (const commit of commits) { + if (bySha.has(commit.sha)) { + throw new Error(`Review commit stack contains duplicate SHA ${commit.sha}.`); + } + bySha.set(commit.sha, commit); + } + return bySha; +}; + +/** + * Project provider or Git output into the canonical parent-before-child stack. + * Parents outside the supplied set are valid external roots. + */ +export const orderReviewCommitStack = ( + commits: ReadonlyArray, +): ReviewCommitStack => { + const bySha = indexCommitGraph(commits); + const children = new Map>(); + const remainingParents = new Map(); + for (const commit of commits) { + const includedParents = commit.parentShas.filter((parentSha) => bySha.has(parentSha)); + remainingParents.set(commit.sha, includedParents.length); + for (const parentSha of includedParents) { + const childShas = children.get(parentSha) ?? []; + childShas.push(commit.sha); + children.set(parentSha, childShas); + } + } + + const ready = commits + .filter((commit) => remainingParents.get(commit.sha) === 0) + .toSorted(compareReadyCommits); + const ordered: Array = []; + while (ready.length > 0) { + const commit = ready.shift()!; + ordered.push(commit); + for (const childSha of children.get(commit.sha) ?? []) { + const remaining = (remainingParents.get(childSha) ?? 1) - 1; + remainingParents.set(childSha, remaining); + if (remaining === 0) { + ready.push(bySha.get(childSha)!); + ready.sort(compareReadyCommits); + } + } + } + + if (ordered.length !== commits.length) { + throw new Error('Review commit stack contains a cycle.'); + } + return ordered; +}; + +/** Assert that an existing stack already obeys the canonical ordering contract. */ +export const validateReviewCommitStack = ( + commits: ReadonlyArray, +): ReviewCommitStack => { + const ordered = orderReviewCommitStack(commits); + for (let index = 0; index < commits.length; index += 1) { + if (ordered[index]!.sha !== commits[index]!.sha) { + throw new Error(`Review commit stack is not parent-before-child at ${commits[index]!.sha}.`); + } + } + return commits; +}; diff --git a/core/lib/review-content.ts b/core/lib/review-content.ts new file mode 100644 index 00000000..7da2ea9e --- /dev/null +++ b/core/lib/review-content.ts @@ -0,0 +1,352 @@ +import type { FileDiffLoadedFiles } from '@pierre/diffs'; +import type { + ChangedFile, + DiffImageContentResult, + DiffImageRevision, + DiffRange, + DiffSection, + ResolvedReviewSource, + ResolvedRevisionBytes, + Revision, + RevisionContentBatchRequest, + RevisionContentBatchResult, + RevisionContentItemResult, + RevisionContentRequest, +} from '../types.ts'; +import { getSourceRevisionKey } from './source.ts'; + +export const manualTextFileLimit = 2 * 1024 * 1024; +export const imageFileLimit = 32 * 1024 * 1024; + +type WithoutKey = Result extends unknown ? Omit : never; +type RevisionReadResult = WithoutKey; + +export type ReviewContentTransport = ( + request: RevisionContentBatchRequest, +) => Promise; + +export type ReviewContentRunDiagnostics = { + cacheHits: number; + sourceCalls: number; + sourceReads: Readonly>; +}; + +export type ReviewContentRun = { + abort(reason?: unknown): void; + diagnostics(): ReviewContentRunDiagnostics; + readDiffBytes( + file: Pick, + section: Pick, + maxBytes?: number, + ): Promise<{ newBytes: ResolvedRevisionBytes | null; oldBytes: ResolvedRevisionBytes | null }>; + readDiffBytesBatch( + requests: ReadonlyArray<{ + file: Pick; + section: Pick; + }>, + maxBytes?: number, + ): Promise< + ReadonlyArray<{ + newBytes: ResolvedRevisionBytes | null; + oldBytes: ResolvedRevisionBytes | null; + }> + >; + resolveImage( + file: Pick, + section: Pick, + ): Promise; + resolveSectionContents(file: ChangedFile, section: DiffSection): Promise; + resolveSectionContentsBatch( + requests: ReadonlyArray<{ file: ChangedFile; section: DiffSection }>, + ): Promise>; +}; + +const revisionKind = (revision: Revision) => revision.kind ?? 'commit'; + +const revisionCoordinate = (revision: Revision) => + revisionKind(revision) === 'commit' + ? `commit:${'sha' in revision ? revision.sha.toLowerCase() : ''}` + : revision.kind === 'index' + ? `index:${revision.stage ?? 0}` + : 'working-copy'; + +const contentKey = ( + source: ResolvedReviewSource, + generation: string, + revision: Revision, + path: string, + maxBytes: number, +) => { + const kind = revisionKind(revision); + const mutable = kind === 'commit' ? '' : `:${generation}`; + return `${getSourceRevisionKey(source)}:${revisionCoordinate(revision)}:${path}:${maxBytes}${mutable}`; +}; + +const getEffectiveRange = ( + file: Pick, + range: DiffRange | undefined, +): DiffRange => { + if (!range) { + throw new Error('Exact contents are unavailable because this section has no revision range.'); + } + return { + base: file.status === 'added' || file.status === 'untracked' ? null : range.base, + head: file.status === 'deleted' ? null : range.head, + }; +}; + +const getImageMimeType = (path: string) => { + const extension = path.slice(path.lastIndexOf('.')).toLowerCase(); + return new Map([ + ['.apng', 'image/apng'], + ['.avif', 'image/avif'], + ['.bmp', 'image/bmp'], + ['.gif', 'image/gif'], + ['.ico', 'image/x-icon'], + ['.jpeg', 'image/jpeg'], + ['.jpg', 'image/jpeg'], + ['.png', 'image/png'], + ['.webp', 'image/webp'], + ]).get(extension); +}; + +const bytesToBase64 = (bytes: Uint8Array) => { + let binary = ''; + const chunkSize = 0x80_00; + for (let index = 0; index < bytes.length; index += chunkSize) { + binary += String.fromCharCode(...bytes.subarray(index, index + chunkSize)); + } + return btoa(binary); +}; + +export const decodeImageRevision = ( + value: ResolvedRevisionBytes | null, + path: string, +): DiffImageRevision | null => { + if (!value) { + return null; + } + const mimeType = getImageMimeType(path); + if (!mimeType) { + throw new Error('Unsupported image file type.'); + } + if (value.size > imageFileLimit) { + throw new Error( + `Image is larger than ${imageFileLimit} bytes, so Codiff skipped rendering it.`, + ); + } + return { + dataUrl: `data:${mimeType};base64,${bytesToBase64(value.bytes)}`, + mimeType, + name: path, + size: value.size, + }; +}; + +export const decodeTextRevision = ( + value: ResolvedRevisionBytes | null, + path: string, + emptyCacheKey: string, +) => { + if (!value) { + return { cacheKey: emptyCacheKey, contents: '', name: path }; + } + if (value.bytes.includes(0)) { + throw new Error(`Full review context is unavailable for '${path}': the file is binary.`); + } + return { + cacheKey: value.cacheKey, + contents: new TextDecoder().decode(value.bytes), + name: path, + }; +}; + +export const createReviewContentRun = ({ + generation, + source, + transport, +}: { + generation: string; + source: ResolvedReviewSource; + transport: ReviewContentTransport; +}): ReviewContentRun => { + const controller = new AbortController(); + const values = new Map(); + const pending = new Map>(); + const sourceReads = new Map(); + let cacheHits = 0; + let sourceCalls = 0; + + const readRevisionBytesBatch = async ( + requests: ReadonlyArray>, + ) => { + controller.signal.throwIfAborted(); + const keyed = requests.map((request) => ({ + ...request, + key: contentKey(source, generation, request.revision, request.path, request.maxBytes), + })); + const misses = [ + ...new Map( + keyed + .filter((request) => { + if (values.has(request.key) || pending.has(request.key)) { + cacheHits += 1; + return false; + } + return true; + }) + .map((request) => [request.key, request]), + ).values(), + ]; + + if (misses.length > 0) { + sourceCalls += 1; + for (const request of misses) { + sourceReads.set(request.key, (sourceReads.get(request.key) ?? 0) + 1); + } + const batch = transport({ generation, requests: misses, source }).then((result) => { + controller.signal.throwIfAborted(); + return new Map(result.results.map((item) => [item.key, item])); + }); + for (const request of misses) { + const promise = batch + .then((results) => { + const result = results.get(request.key) ?? { + key: request.key, + reason: 'The content reader did not return this revision coordinate.', + status: 'unavailable' as const, + }; + const normalized = + result.status === 'ready' + ? { status: result.status, value: result.value } + : result.status === 'missing' + ? { status: result.status } + : { reason: result.reason, status: result.status }; + values.set(request.key, normalized); + return normalized; + }) + .finally(() => pending.delete(request.key)); + pending.set(request.key, promise); + } + } + + const results = await Promise.all( + keyed.map(async (request) => { + const result = values.get(request.key) ?? (await pending.get(request.key)); + if (!result) { + throw new Error('Revision content cache entry is unavailable.'); + } + if (result.status === 'unavailable') { + throw new Error(result.reason); + } + return result.status === 'ready' ? result.value : null; + }), + ); + controller.signal.throwIfAborted(); + return results; + }; + + const readDiffBytesBatch: ReviewContentRun['readDiffBytesBatch'] = async ( + requests, + maxBytes = manualTextFileLimit, + ) => { + const descriptors = requests.map(({ file, section }) => { + const range = getEffectiveRange(file, section.range); + return { + newRequest: range.head ? { maxBytes, path: file.path, revision: range.head } : null, + oldRequest: range.base + ? { maxBytes, path: file.oldPath ?? file.path, revision: range.base } + : null, + }; + }); + const flatRequests = descriptors.flatMap(({ newRequest, oldRequest }) => [ + ...(newRequest ? [newRequest] : []), + ...(oldRequest ? [oldRequest] : []), + ]); + const flatResults = await readRevisionBytesBatch(flatRequests); + let cursor = 0; + return descriptors.map(({ newRequest, oldRequest }) => ({ + newBytes: newRequest ? (flatResults[cursor++] ?? null) : null, + oldBytes: oldRequest ? (flatResults[cursor++] ?? null) : null, + })); + }; + + const readDiffBytes: ReviewContentRun['readDiffBytes'] = async (file, section, maxBytes) => { + const [result] = await readDiffBytesBatch([{ file, section }], maxBytes); + if (!result) { + throw new Error('Diff content result is unavailable.'); + } + return result; + }; + + const resolveLoadedFiles = ( + file: ChangedFile, + section: DiffSection, + { + newBytes, + oldBytes, + }: { + newBytes: ResolvedRevisionBytes | null; + oldBytes: ResolvedRevisionBytes | null; + }, + ): FileDiffLoadedFiles => { + if (!newBytes && !oldBytes && file.status !== 'added' && file.status !== 'deleted') { + throw new Error(`Full review context is unavailable for '${file.path}'.`); + } + return { + newFile: decodeTextRevision( + newBytes, + file.path, + `${getSourceRevisionKey(source)}:${generation}:${file.path}:empty`, + ), + oldFile: + section.range?.base == null || file.status === 'added' || file.status === 'untracked' + ? null + : decodeTextRevision( + oldBytes, + file.oldPath ?? file.path, + `${getSourceRevisionKey(source)}:${generation}:${file.oldPath ?? file.path}:empty`, + ), + }; + }; + + return { + abort: (reason) => controller.abort(reason), + diagnostics: () => ({ + cacheHits, + sourceCalls, + sourceReads: Object.fromEntries(sourceReads), + }), + readDiffBytes, + readDiffBytesBatch, + resolveImage: async (file, section) => { + try { + const { newBytes, oldBytes } = await readDiffBytes(file, section, imageFileLimit); + const oldImage = decodeImageRevision(oldBytes, file.oldPath ?? file.path); + const newImage = decodeImageRevision(newBytes, file.path); + return oldImage || newImage + ? { + ...(newImage ? { newImage } : {}), + ...(oldImage ? { oldImage } : {}), + status: 'ready', + } + : { reason: 'Codiff could not load either side of this image.', status: 'unavailable' }; + } catch (error) { + return { + reason: error instanceof Error ? error.message : 'Codiff could not load this image.', + status: 'unavailable', + }; + } + }, + resolveSectionContents: async (file, section) => { + const result = await readDiffBytes(file, section); + return resolveLoadedFiles(file, section, result); + }, + resolveSectionContentsBatch: async (requests) => { + const results = await readDiffBytesBatch(requests); + return requests.map(({ file, section }, index) => + resolveLoadedFiles(file, section, results[index]!), + ); + }, + }; +}; diff --git a/core/lib/review-context-expansion.ts b/core/lib/review-context-expansion.ts new file mode 100644 index 00000000..4b7697bf --- /dev/null +++ b/core/lib/review-context-expansion.ts @@ -0,0 +1,45 @@ +import type { ExpansionDirections } from '@pierre/diffs'; + +export type ReviewContextExpansionRegion = { + fromEnd: number; + fromStart: number; +}; + +export type ReviewContextExpansionState = ReadonlyMap; + +const emptyExpansionState = new Map(); + +export const getReviewContextExpansionState = ( + state: ReviewContextExpansionState | undefined, + hunkIndex: number, + direction: ExpansionDirections, + expansionLineCount: number, + expandAll: boolean, +): ReviewContextExpansionState => { + const current = state?.get(hunkIndex) ?? { fromEnd: 0, fromStart: 0 }; + const amount = expandAll ? Number.POSITIVE_INFINITY : expansionLineCount; + const next = new Map(state ?? emptyExpansionState); + next.set(hunkIndex, { + fromEnd: + direction === 'down' || direction === 'both' + ? Math.max(current.fromEnd, amount) + : current.fromEnd, + fromStart: + direction === 'up' || direction === 'both' + ? Math.max(current.fromStart, amount) + : current.fromStart, + }); + return next; +}; + +export const reviewContextExpansionDigest = (state: ReviewContextExpansionState | undefined) => + [...(state ?? emptyExpansionState).entries()] + .toSorted(([firstIndex], [secondIndex]) => firstIndex - secondIndex) + .map(([index, region]) => `${index}:${region.fromStart}:${region.fromEnd}`) + .join('|'); + +export const reviewContextExpansionProjectionKey = ( + projectionKey: string, + fileFingerprint: string, + sectionId: string, +) => `${projectionKey}:${fileFingerprint}:${sectionId}`; diff --git a/core/lib/review-history.ts b/core/lib/review-history.ts new file mode 100644 index 00000000..c257c409 --- /dev/null +++ b/core/lib/review-history.ts @@ -0,0 +1,18 @@ +import type { DiffRange, GitSha, Revision } from '../types.ts'; + +export const isCommitRevision = ( + revision: Revision | null, +): revision is Extract => + revision != null && revision.kind !== 'index' && revision.kind !== 'working-copy'; + +export const shaForRevision = (revision: Revision | null): GitSha => { + if (!isCommitRevision(revision)) { + throw new Error(`Expected a commit revision, received ${revision?.kind ?? 'an absent side'}.`); + } + return revision.sha; +}; + +export const diffRange = (base: Revision | null, head: Revision | null): DiffRange => ({ + base, + head, +}); diff --git a/core/lib/review-source-codec.cjs b/core/lib/review-source-codec.cjs new file mode 100644 index 00000000..f4fc6189 --- /dev/null +++ b/core/lib/review-source-codec.cjs @@ -0,0 +1,218 @@ +// @ts-nocheck + +/** @param {unknown} value */ +const isObject = (value) => typeof value === 'object' && value != null && !Array.isArray(value); +/** @param {unknown} value */ +const isOptionalString = (value) => value == null || typeof value === 'string'; +/** @param {unknown} value */ +const isOptionalNumber = (value) => value == null || typeof value === 'number'; + +/** @param {string} value */ +const parseReviewUrl = (value) => { + try { + const url = new globalThis.URL(value); + const host = url.hostname.toLowerCase(); + const github = url.pathname.match(/^\/([^/]+)\/([^/]+)\/pull\/(\d+)(?:\/.*)?$/i); + if (github) { + const owner = github[1]; + const repo = github[2].replace(/\.git$/i, ''); + const number = Number(github[3]); + return { + host, + number, + owner, + projectPath: `${owner}/${repo}`, + provider: /** @type {const} */ ('github'), + repo, + url: `${url.protocol}//${url.host}/${owner}/${repo}/pull/${number}`, + }; + } + const gitlab = url.pathname.match(/^\/(.+?)\/-\/merge_requests\/(\d+)(?:\/.*)?$/i); + if (gitlab) { + const projectPath = gitlab[1].replace(/\.git$/i, ''); + const number = Number(gitlab[2]); + return { + host, + number, + projectPath, + provider: /** @type {const} */ ('gitlab'), + url: `${url.protocol}//${url.host}/${projectPath}/-/merge_requests/${number}`, + }; + } + } catch { + // Invalid and non-review URLs are rejected by the codec. + } + return null; +}; + +/** @param {Record} value */ +const decodePullRequestSource = (value) => { + if ( + typeof value.url !== 'string' || + !isOptionalNumber(value.number) || + !isOptionalString(value.headSha) || + !isOptionalString(value.host) || + !isOptionalString(value.owner) || + !isOptionalString(value.projectPath) || + !isOptionalString(value.repo) || + !isOptionalString(value.title) + ) { + return null; + } + const parsed = parseReviewUrl(value.url); + const provider = value.provider || parsed?.provider; + if (provider !== 'github' && provider !== 'gitlab') { + return null; + } + return { + ...value, + ...(parsed || {}), + ...(typeof value.headSha === 'string' ? { headSha: value.headSha.toLowerCase() } : {}), + provider, + type: /** @type {const} */ ('pull-request'), + }; +}; + +/** @param {unknown} input */ +const decodeReviewSource = (input) => { + if (!isObject(input) || typeof input.type !== 'string') { + return null; + } + const value = /** @type {Record} */ (input); + if (value.type === 'working-tree') { + return { type: /** @type {const} */ ('working-tree') }; + } + if (value.type === 'commit') { + return typeof value.ref === 'string' + ? { ref: value.ref, type: /** @type {const} */ ('commit') } + : null; + } + if (value.type === 'branch') { + return typeof value.ref === 'string' + ? { ref: value.ref, type: /** @type {const} */ ('branch') } + : null; + } + if (value.type === 'branch-diff') { + return typeof value.ref === 'string' && + typeof value.baseSha === 'string' && + typeof value.headSha === 'string' + ? { + baseSha: value.baseSha.toLowerCase(), + headSha: value.headSha.toLowerCase(), + ref: value.ref, + type: /** @type {const} */ ('branch-diff'), + } + : null; + } + if (value.type === 'branch-working-tree') { + if ( + typeof value.ref !== 'string' || + !isOptionalString(value.baseSha) || + !isOptionalString(value.headSha) + ) { + return null; + } + const hasBase = typeof value.baseSha === 'string' && value.baseSha.length > 0; + const hasHead = typeof value.headSha === 'string' && value.headSha.length > 0; + if (hasBase !== hasHead) { + return null; + } + return { + ...(hasBase + ? { baseSha: value.baseSha.toLowerCase(), headSha: value.headSha.toLowerCase() } + : {}), + ref: value.ref, + type: /** @type {const} */ ('branch-working-tree'), + }; + } + if (value.type === 'range') { + return typeof value.base === 'string' && + typeof value.head === 'string' && + typeof value.symmetric === 'boolean' + ? { + base: value.base, + head: value.head, + symmetric: value.symmetric, + type: /** @type {const} */ ('range'), + } + : null; + } + return value.type === 'pull-request' ? decodePullRequestSource(value) : null; +}; + +/** @param {unknown} input */ +const decodeResolvedReviewSource = (input) => { + if (!isObject(input) || typeof input.type !== 'string') { + return null; + } + const value = /** @type {Record} */ (input); + if (value.type === 'commit') { + return typeof value.sha === 'string' + ? { sha: value.sha.toLowerCase(), type: /** @type {const} */ ('commit') } + : null; + } + const source = decodeReviewSource(input); + if (!source || source.type === 'branch') { + return null; + } + if ( + source.type === 'branch-working-tree' && + (!('baseSha' in source) || !source.baseSha || !source.headSha) + ) { + return null; + } + return source; +}; + +/** @param {ReturnType | ReturnType} source */ +const formatReviewSourceIdentity = (source) => { + if (!source) { + return null; + } + if (source.type === 'working-tree') { + return 'working-tree'; + } + if (source.type === 'commit') { + return `commit:${'sha' in source ? source.sha : source.ref}`; + } + if (source.type === 'branch') { + return `branch:${source.ref}`; + } + if (source.type === 'branch-diff') { + return `branch-diff:${source.ref}:${source.baseSha}:${source.headSha}`; + } + if (source.type === 'branch-working-tree') { + return 'baseSha' in source && source.baseSha && source.headSha + ? `branch-working-tree:${source.ref}:${source.baseSha}:${source.headSha}` + : `branch-working-tree:${source.ref}:unresolved`; + } + if (source.type === 'range') { + return `range:${source.base}${source.symmetric ? '...' : '..'}${source.head}`; + } + const parsed = parseReviewUrl(source.url); + const provider = source.provider || parsed?.provider || ''; + const host = (source.host || parsed?.host || '').toLowerCase(); + const projectPath = ( + source.projectPath || + (source.owner && source.repo ? `${source.owner}/${source.repo}` : parsed?.projectPath) || + '' + ).toLowerCase(); + return `pull-request:${provider}:${host}:${projectPath}#${source.number || parsed?.number || source.url}`; +}; + +/** @param {ReturnType} source */ +const formatResolvedSourceIdentity = (source) => { + const logical = formatReviewSourceIdentity(source); + return logical && source?.type === 'pull-request' + ? `${logical}:${source.headSha || 'unresolved-head'}` + : logical; +}; + +// eslint-disable-next-line no-undef +module.exports = { + decodeResolvedReviewSource, + decodeReviewSource, + formatResolvedSourceIdentity, + formatReviewSourceIdentity, + parseReviewUrl, +}; diff --git a/core/lib/review-source-codec.ts b/core/lib/review-source-codec.ts new file mode 100644 index 00000000..17da876e --- /dev/null +++ b/core/lib/review-source-codec.ts @@ -0,0 +1,21 @@ +import type { ResolvedReviewSource, ReviewSource } from '../types.ts'; +import codec from './review-source-codec.cjs'; + +const reviewSourceCodec = codec as { + decodeResolvedReviewSource(value: unknown): unknown; + decodeReviewSource(value: unknown): unknown; + formatResolvedSourceIdentity(source: ResolvedReviewSource): string | null; + formatReviewSourceIdentity(source: ResolvedReviewSource | ReviewSource): string | null; +}; + +export const decodeReviewSource = (value: unknown): ReviewSource | null => + reviewSourceCodec.decodeReviewSource(value) as ReviewSource | null; + +export const decodeResolvedReviewSource = (value: unknown): ResolvedReviewSource | null => + reviewSourceCodec.decodeResolvedReviewSource(value) as ResolvedReviewSource | null; + +export const formatReviewSourceIdentity = (source: ResolvedReviewSource | ReviewSource): string => + reviewSourceCodec.formatReviewSourceIdentity(source) as string; + +export const formatResolvedSourceIdentity = (source: ResolvedReviewSource): string => + reviewSourceCodec.formatResolvedSourceIdentity(source) as string; diff --git a/core/lib/source-description.ts b/core/lib/source-description.ts new file mode 100644 index 00000000..18a9c37f --- /dev/null +++ b/core/lib/source-description.ts @@ -0,0 +1,88 @@ +import type { CommitMetadata, ResolvedReviewSource, ReviewAuthor } from '../types.ts'; + +export type SourceDescriptionAuthor = { + avatarUrl?: string; + displayName: string; + title?: string; +}; + +export type SourceDescriptionModel = { + allowsBodyEdit: boolean; + allowsTitleEdit: boolean; + ariaLabel: string; + author?: SourceDescriptionAuthor; + body: string; + defaultCollapsed: boolean; + identity: string; + kind: 'commit' | 'pull-request'; + label: string; + title: string; +}; + +const getPullRequestDescriptionLabel = ( + source: Extract, +) => + source.provider === 'github' + ? 'PR description' + : source.provider === 'gitlab' + ? 'MR description' + : 'Description'; + +const getPullRequestDescriptionAuthor = (author: ReviewAuthor): SourceDescriptionAuthor => ({ + avatarUrl: author.avatarUrl, + displayName: author.name || `@${author.login}`, + title: `@${author.login}`, +}); + +const getCommitDescriptionAuthor = (author: CommitMetadata['author']): SourceDescriptionAuthor => ({ + avatarUrl: author.gravatarUrl, + displayName: author.name || author.email || 'Unknown author', + title: author.email || undefined, +}); + +export const buildSourceDescriptionModel = ({ + commitMetadata, + showPullRequestDescription = true, + source, +}: { + commitMetadata: CommitMetadata | null; + showPullRequestDescription?: boolean; + source: ResolvedReviewSource; +}): SourceDescriptionModel | null => { + if (source.type === 'commit' && commitMetadata) { + return { + allowsBodyEdit: false, + allowsTitleEdit: false, + ariaLabel: 'Preview commit message', + author: getCommitDescriptionAuthor(commitMetadata.author), + body: commitMetadata.body.trim(), + defaultCollapsed: false, + identity: `commit-message:${source.sha}`, + kind: 'commit', + label: 'Commit', + title: commitMetadata.subject.trim() || commitMetadata.shortSha, + }; + } + + if (source.type !== 'pull-request' || !showPullRequestDescription) { + return null; + } + const body = source.description?.trim() ?? ''; + const title = source.title?.trim() ?? ''; + if (!body && !title) { + return null; + } + const allowsBodyEdit = source.canEditDescription === true; + return { + allowsBodyEdit, + allowsTitleEdit: source.canEditTitle === true || allowsBodyEdit, + ariaLabel: 'Preview source description', + ...(source.author ? { author: getPullRequestDescriptionAuthor(source.author) } : {}), + body, + defaultCollapsed: false, + identity: `source-description:${source.provider ?? ''}:${source.url}:${source.headSha ?? 'unresolved-head'}`, + kind: 'pull-request', + label: getPullRequestDescriptionLabel(source), + title, + }; +}; diff --git a/core/lib/source.ts b/core/lib/source.ts index 93872710..5c569634 100644 --- a/core/lib/source.ts +++ b/core/lib/source.ts @@ -1,6 +1,7 @@ -import type { ReviewSource } from '../types.ts'; +import type { ResolvedReviewSource, ReviewSource } from '../types.ts'; import type { RepositoryLoadError } from './app-types.ts'; import { abbreviateHomePath } from './files.ts'; +import { formatResolvedSourceIdentity, formatReviewSourceIdentity } from './review-source-codec.ts'; const rangeLabel = (source: Extract) => `${source.base}${source.symmetric ? '...' : '..'}${source.head}`; @@ -9,7 +10,6 @@ type SourceCapabilities = { emptyTitle: string; historySource: boolean; lazyDiffContent: boolean; - preloadDiffSearchContent: boolean; startInHistoryWhenEmpty: boolean; viewedFileState: boolean; }; @@ -19,7 +19,6 @@ const sourceCapabilitiesByType = { emptyTitle: 'No branch changes', historySource: true, lazyDiffContent: true, - preloadDiffSearchContent: true, startInHistoryWhenEmpty: true, viewedFileState: false, }, @@ -27,7 +26,6 @@ const sourceCapabilitiesByType = { emptyTitle: 'No branch changes', historySource: true, lazyDiffContent: true, - preloadDiffSearchContent: true, startInHistoryWhenEmpty: true, viewedFileState: false, }, @@ -35,7 +33,6 @@ const sourceCapabilitiesByType = { emptyTitle: 'No changes', historySource: true, lazyDiffContent: true, - preloadDiffSearchContent: true, startInHistoryWhenEmpty: true, viewedFileState: true, }, @@ -43,15 +40,13 @@ const sourceCapabilitiesByType = { emptyTitle: 'No changes in commit', historySource: false, lazyDiffContent: true, - preloadDiffSearchContent: false, startInHistoryWhenEmpty: false, viewedFileState: false, }, 'pull-request': { emptyTitle: 'No review changes', historySource: true, - lazyDiffContent: false, - preloadDiffSearchContent: false, + lazyDiffContent: true, startInHistoryWhenEmpty: false, viewedFileState: false, }, @@ -59,7 +54,6 @@ const sourceCapabilitiesByType = { emptyTitle: 'No changes in range', historySource: false, lazyDiffContent: true, - preloadDiffSearchContent: false, startInHistoryWhenEmpty: false, viewedFileState: false, }, @@ -67,28 +61,27 @@ const sourceCapabilitiesByType = { emptyTitle: 'No local changes', historySource: false, lazyDiffContent: true, - preloadDiffSearchContent: true, startInHistoryWhenEmpty: true, viewedFileState: true, }, } satisfies Record; -const getSourceCapabilities = (source: ReviewSource) => sourceCapabilitiesByType[source.type]; +type DisplayReviewSource = ResolvedReviewSource | ReviewSource; -export const getSourceKey = (source: ReviewSource) => - source.type === 'commit' - ? `commit:${source.ref}` - : source.type === 'branch-diff' - ? `branch-diff:${source.ref}:${source.baseRef}:${source.headRef}` - : source.type === 'branch-working-tree' - ? `branch-working-tree:${source.ref}:${source.baseRef}:${source.headRef}` - : source.type === 'branch' - ? `branch:${source.ref}` - : source.type === 'range' - ? `range:${rangeLabel(source)}` - : source.type === 'pull-request' - ? `pull-request:${source.provider ?? ''}:${source.host ?? ''}:${source.projectPath ?? `${source.owner ?? ''}/${source.repo ?? ''}`}#${source.number ?? source.url}` - : 'working-tree'; +const getSourceCapabilities = (source: DisplayReviewSource) => + sourceCapabilitiesByType[source.type]; + +export const getSourceKey = (source: DisplayReviewSource) => formatReviewSourceIdentity(source); + +/** + * Identifies the exact revision currently rendered for asynchronous work. + * A pull request's logical source key stays stable as its head moves, while + * deferred results must never cross that immutable head boundary. + */ +export const getSourceRevisionKey = (source: DisplayReviewSource) => + 'sha' in source || source.type !== 'commit' + ? formatResolvedSourceIdentity(source as ResolvedReviewSource) + : formatReviewSourceIdentity(source); const getErrorMessage = (error: unknown) => error instanceof Error ? error.message : String(error); @@ -109,9 +102,9 @@ export const getRepositoryLoadError = (error: unknown): RepositoryLoadError => { export const getShortRef = (ref: string) => ref.slice(0, 7); -export const getSourceLabel = (source: ReviewSource) => +export const getSourceLabel = (source: DisplayReviewSource) => source.type === 'commit' - ? getShortRef(source.ref) + ? getShortRef('sha' in source ? source.sha : source.ref) : source.type === 'branch' || source.type === 'branch-diff' ? `Branch vs ${source.ref}` : source.type === 'branch-working-tree' @@ -126,38 +119,37 @@ export const getSourceLabel = (source: ReviewSource) => : 'Pull request' : 'Uncommitted'; -export const getHistorySource = (source: ReviewSource): ReviewSource | undefined => - getSourceCapabilities(source).historySource ? source : undefined; +export const getHistorySource = (source: DisplayReviewSource): ReviewSource | undefined => + getSourceCapabilities(source).historySource ? (source as ReviewSource) : undefined; -export const getRefreshSource = (source: ReviewSource): ReviewSource => - source.type === 'branch-working-tree' - ? { - ref: source.ref, - type: 'branch-working-tree', - } - : source; +export const getRefreshSource = (source: DisplayReviewSource): ReviewSource => + source.type === 'commit' && 'sha' in source + ? { ref: source.sha, type: 'commit' } + : source.type === 'branch-working-tree' + ? { + ref: source.ref, + type: 'branch-working-tree', + } + : (source as ReviewSource); -export const supportsLazyDiffContent = (source: ReviewSource) => +export const supportsLazyDiffContent = (source: DisplayReviewSource) => getSourceCapabilities(source).lazyDiffContent; -export const supportsDiffSearchContentPreload = (source: ReviewSource) => - getSourceCapabilities(source).preloadDiffSearchContent; - -export const shouldStartInHistoryWhenEmpty = (source: ReviewSource) => +export const shouldStartInHistoryWhenEmpty = (source: DisplayReviewSource) => getSourceCapabilities(source).startInHistoryWhenEmpty; -export const usesViewedFileState = (source: ReviewSource) => +export const usesViewedFileState = (source: DisplayReviewSource) => getSourceCapabilities(source).viewedFileState; -export const getEmptySourceTitle = (source: ReviewSource) => +export const getEmptySourceTitle = (source: DisplayReviewSource) => getSourceCapabilities(source).emptyTitle; export const getEmptySourceDetail = ( - source: ReviewSource, + source: DisplayReviewSource, root: string, ): { kind: 'code' | 'text'; text: string; title?: string } => source.type === 'commit' - ? { kind: 'text', text: getShortRef(source.ref) } + ? { kind: 'text', text: getShortRef('sha' in source ? source.sha : source.ref) } : source.type === 'branch' || source.type === 'branch-diff' || source.type === 'branch-working-tree' diff --git a/core/lib/walkthrough-generation-tasks.ts b/core/lib/walkthrough-generation-tasks.ts new file mode 100644 index 00000000..7964ab4a --- /dev/null +++ b/core/lib/walkthrough-generation-tasks.ts @@ -0,0 +1,271 @@ +import type { + GenerationMetadata, + GenerationProfile, + WalkthroughGenerationProgress, + WalkthroughGenerationUnitProgress, +} from '../types.ts'; + +export const walkthroughGenerationConcurrency = 3; + +export type WalkthroughGenerationTask = { + id: string; + identity: Identity; + label: string; + profile: GenerationProfile; + run: (input: { + profile: GenerationProfile; + semanticInput: SemanticInput; + signal: AbortSignal; + }) => Promise<{ generationMetadata: GenerationMetadata; output: Output }>; + semanticInput: SemanticInput; +}; + +export type ReusableWalkthroughGenerationComponent = { + generationMetadata: GenerationMetadata; + identity: Identity; + output: Output; + profile: GenerationProfile; + semanticInput: SemanticInput; +}; + +export type WalkthroughGenerationFailure = { + error: string; + identity: Identity; + label: string; +}; + +export type RunWalkthroughGenerationTasksInput = { + concurrency?: number; + onProgress?: (progress: WalkthroughGenerationProgress) => void; + reusableComponents?: ReadonlyArray< + ReusableWalkthroughGenerationComponent + >; + signal?: AbortSignal; + tasks: ReadonlyArray>; +}; + +export type RunWalkthroughGenerationTasksResult = + | { + components: ReadonlyArray< + ReusableWalkthroughGenerationComponent + >; + status: 'ready'; + } + | { + components: ReadonlyArray< + ReusableWalkthroughGenerationComponent + >; + failures: ReadonlyArray>; + reason: string; + status: 'failed'; + } + | { + components: ReadonlyArray< + ReusableWalkthroughGenerationComponent + >; + reason: string; + status: 'cancelled'; + }; + +const stableValue = (value: unknown): unknown => { + if (Array.isArray(value)) { + return value.map(stableValue); + } + if (value && typeof value === 'object') { + return Object.fromEntries( + Object.entries(value) + .sort(([first], [second]) => first.localeCompare(second)) + .map(([key, child]) => [key, stableValue(child)]), + ); + } + return value; +}; + +const inputsEqual = (first: unknown, second: unknown) => + JSON.stringify(stableValue(first)) === JSON.stringify(stableValue(second)); + +const failureReason = (error: unknown) => (error instanceof Error ? error.message : String(error)); + +const cancellationReason = (signal: AbortSignal) => + signal.reason instanceof Error + ? signal.reason.message + : typeof signal.reason === 'string' && signal.reason + ? signal.reason + : 'Walkthrough generation was cancelled.'; + +const runWithCancellation = async ( + signal: AbortSignal, + run: () => Promise, +): Promise => { + signal.throwIfAborted(); + let rejectCancellation: ((reason: unknown) => void) | null = null; + const cancelled = new Promise((_, reject) => { + rejectCancellation = reject; + }); + const onAbort = () => + rejectCancellation?.(signal.reason ?? new Error(cancellationReason(signal))); + signal.addEventListener('abort', onAbort, { once: true }); + try { + return await Promise.race([run(), cancelled]); + } finally { + signal.removeEventListener('abort', onAbort); + } +}; + +const validateGenerationMetadata = (metadata: GenerationMetadata, profile: GenerationProfile) => { + if (!inputsEqual(metadata.profile, profile)) { + throw new Error('Successful generation metadata does not match the requested profile.'); + } + if (metadata.agent !== profile.agent) { + throw new Error('The successful agent does not match the requested profile.'); + } + if (!profile.modelCandidates.includes(metadata.model)) { + throw new Error('The successful model is not in the requested fallback chain.'); + } +}; + +const findReusable = ( + reusable: ReadonlyArray>, + task: WalkthroughGenerationTask, +) => + reusable.find((component) => { + if ( + !inputsEqual(component.identity, task.identity) || + !inputsEqual(component.semanticInput, task.semanticInput) || + !inputsEqual(component.profile, task.profile) + ) { + return false; + } + try { + validateGenerationMetadata(component.generationMetadata, task.profile); + return true; + } catch { + return false; + } + }); + +/** + * Run format-neutral model tasks with bounded concurrency. Successful + * components remain reusable after another task fails, so a retry invokes only + * the failed or invalidated tasks. Cancellation prevents queued work from + * starting and suppresses a ready result. + */ +export async function runWalkthroughGenerationTasks( + input: RunWalkthroughGenerationTasksInput, +): Promise> { + const signal = input.signal ?? new AbortController().signal; + const reusableComponents = input.reusableComponents ?? []; + const progressUnits: Array = input.tasks.map((task) => ({ + id: task.id, + label: task.label, + status: 'pending', + })); + const outcomes: Array< + | ReusableWalkthroughGenerationComponent + | WalkthroughGenerationFailure + | undefined + > = new Array(input.tasks.length); + const emitProgress = (phase: WalkthroughGenerationProgress['phase'], summary: string) => + input.onProgress?.({ + completed: progressUnits.filter((unit) => unit.status === 'ready').length, + phase, + summary, + total: progressUnits.length, + units: progressUnits.map((unit) => ({ ...unit })), + }); + + emitProgress('preparing', `Preparing ${input.tasks.length} walkthrough generation tasks.`); + const pending: Array = []; + input.tasks.forEach((task, index) => { + const reused = findReusable(reusableComponents, task); + if (!reused) { + pending.push(index); + return; + } + outcomes[index] = reused; + progressUnits[index] = { ...progressUnits[index]!, status: 'ready' }; + }); + + let nextPending = 0; + let cancelled = signal.aborted; + const worker = async () => { + while (nextPending < pending.length && !signal.aborted) { + const index = pending[nextPending]!; + nextPending += 1; + const task = input.tasks[index]!; + progressUnits[index] = { ...progressUnits[index]!, status: 'generating' }; + emitProgress( + input.tasks.length === 1 ? 'generating' : 'generating-units', + `Generating ${task.label}.`, + ); + try { + const result = await runWithCancellation(signal, () => + task.run({ profile: task.profile, semanticInput: task.semanticInput, signal }), + ); + validateGenerationMetadata(result.generationMetadata, task.profile); + const component = { + generationMetadata: result.generationMetadata, + identity: task.identity, + output: result.output, + profile: task.profile, + semanticInput: task.semanticInput, + } satisfies ReusableWalkthroughGenerationComponent; + outcomes[index] = component; + progressUnits[index] = { ...progressUnits[index]!, status: 'ready' }; + emitProgress( + input.tasks.length === 1 ? 'generating' : 'generating-units', + `Completed ${task.label}.`, + ); + } catch (error: unknown) { + if (signal.aborted) { + cancelled = true; + progressUnits[index] = { + ...progressUnits[index]!, + detail: cancellationReason(signal), + status: 'failed', + }; + break; + } + const failure = { error: failureReason(error), identity: task.identity, label: task.label }; + outcomes[index] = failure; + progressUnits[index] = { + ...progressUnits[index]!, + detail: failure.error, + status: 'failed', + }; + emitProgress( + input.tasks.length === 1 ? 'generating' : 'generating-units', + `Failed ${task.label}.`, + ); + } + } + }; + const concurrency = Math.max( + 1, + Math.min(Math.floor(input.concurrency ?? walkthroughGenerationConcurrency), pending.length), + ); + await Promise.all(Array.from({ length: concurrency }, () => worker())); + + const completedComponents = outcomes.flatMap((outcome) => + outcome && !('error' in outcome) ? [outcome] : [], + ); + + if (cancelled || signal.aborted) { + const reason = cancellationReason(signal); + emitProgress('generating', reason); + return { components: completedComponents, reason, status: 'cancelled' }; + } + + const failures = outcomes.flatMap((outcome) => (outcome && 'error' in outcome ? [outcome] : [])); + if (failures.length > 0) { + return { + components: completedComponents, + failures, + reason: failures.map((failure) => `${failure.label}: ${failure.error}`).join('; '), + status: 'failed', + }; + } + + emitProgress('combining', 'All walkthrough generation tasks are ready.'); + return { components: completedComponents, status: 'ready' }; +} diff --git a/core/package.json b/core/package.json index c0c36fd4..6bb6100c 100644 --- a/core/package.json +++ b/core/package.json @@ -13,7 +13,15 @@ "directory": "core" }, "files": [ - "dist" + "dist", + "*.css", + "*.ts", + "*.tsx", + "app", + "config", + "fonts", + "lib", + "types" ], "type": "module", "sideEffects": [ @@ -44,14 +52,24 @@ "@nkzw/codiff-source": "./App.css", "default": "./dist/style.css" }, + "./types": { + "types": "./dist/types.d.ts", + "@nkzw/codiff-source": "./types.ts", + "default": "./dist/types.d.ts" + }, "./walkthrough": { "types": "./dist/lib/narrative-walkthrough-diff.d.ts", "@nkzw/codiff-source": "./lib/narrative-walkthrough-diff.js", "default": "./dist/lib/narrative-walkthrough-diff.mjs" + }, + "./walkthrough-generation": { + "types": "./dist/walkthrough-generation.d.ts", + "@nkzw/codiff-source": "./walkthrough-generation.ts", + "default": "./dist/walkthrough-generation.mjs" } }, "scripts": { - "build": "rm -rf dist && vp pack -d dist --target=node24 index.ts react.ts share.ts lib/narrative-walkthrough-diff.js App.css && vp exec tsc -p tsconfig.build.json" + "build": "rm -rf dist && vp pack -d dist --target=node24 index.ts react.ts share.ts walkthrough-generation.ts lib/narrative-walkthrough-diff.js App.css && vp exec tsc -p tsconfig.build.json" }, "dependencies": { "@nkzw/mdx-editor": "^1.0.1", diff --git a/core/react.ts b/core/react.ts index ea6e4a3a..d70e84e7 100644 --- a/core/react.ts +++ b/core/react.ts @@ -11,8 +11,40 @@ export { export { ReadOnlyGeneralCommentCard, ReviewSurface, + type CommentAnchorPolicy, + type CommentDestination, + type ControlledReviewDrafts, + type ControlledReviewPreferences, + type ControlledReviewValue, + type LocalReviewNoteCapabilities, + type ProviderReviewCommentCapabilities, + type ProviderReviewOutcome, + type ProviderReviewSessionCapabilities, type ReviewCommenting, + type ReviewCommentCapabilities, + type ReviewContentCapabilities, + type ReviewDesktopCapabilities, + type ReviewHistoryModel, type ReviewMode, + type ReviewSourceNavigation, + type ReviewSurfaceCapabilities, + type ReviewSurfaceCommandBridge, type ReviewSurfaceProps, + type ReviewWalkthroughCapabilities, type ReviewWalkthroughStatus, -} from './SharedWalkthroughApp.tsx'; + type ShareReviewCommentCapabilities, + type SubmitProviderReviewRequest, + type SubmitProviderReviewResult, +} from './ReviewSurface.tsx'; +export type { + LocalReviewNote, + ProviderCommentDraft, + ProviderInlineComment, + RenderedReviewCommentTarget, + RenderedSubmittedReviewComment, + ReviewComment, + ReviewCommentDraftTarget, + ReviewDraft, + ShareCommentDraft, + ShareInlineComment, +} from './lib/app-types.ts'; diff --git a/core/tsconfig.build.json b/core/tsconfig.build.json index 9ed75c10..f4aed047 100644 --- a/core/tsconfig.build.json +++ b/core/tsconfig.build.json @@ -16,12 +16,15 @@ "index.ts", "react.ts", "share.ts", + "walkthrough-generation.ts", + "walkthrough-progress.ts", "types.ts", "SharedPlanApp.tsx", - "SharedWalkthroughApp.tsx", + "ReviewSurface.tsx", "app/**/*.ts", "app/**/*.tsx", "config/**/*.ts", + "lib/**/*.cjs", "lib/**/*.js", "lib/**/*.ts" ] diff --git a/core/types.ts b/core/types.ts index e5ca564a..8cc3798f 100644 --- a/core/types.ts +++ b/core/types.ts @@ -1,778 +1,16 @@ -import type { MarkdownAnnotationAnchor } from '@nkzw/mdx-editor'; import type { CodiffDiffStyle } from './config/types.ts'; -export type DiffSection = { - binary: boolean; - id: string; - kind: 'commit' | 'pull-request' | 'staged' | 'unstaged'; - loadState?: 'binary' | 'deferred' | 'directory' | 'error' | 'ready' | 'too-large'; - newFile?: { - cacheKey?: string; - contents: string; - name: string; - }; - oldFile?: { - cacheKey?: string; - contents: string; - name: string; - }; - patch: string; - summary?: { - canLoad?: boolean; - fileCount?: number; - fingerprint?: string; - limit?: number; - reason: string; - size?: number; - }; -}; - -export type GitFileStatus = - | 'added' - | 'conflicted' - | 'deleted' - | 'modified' - | 'renamed' - | 'untracked'; - -export type ChangedFile = { - fingerprint: string; - generated?: boolean; - oldPath?: string; - path: string; - sections: ReadonlyArray; - status: GitFileStatus; -}; - -export type ReviewAuthor = { - avatarUrl?: string; - login: string; - name?: string; - url?: string; -}; - -export type PullRequestReviewer = ReviewAuthor & { - approved: boolean; - id: string; -}; - -export type PullRequestReviewActionStatus = { - disabled?: boolean; - reason?: string; -}; - -export type PullRequestReviewStatus = { - approve?: PullRequestReviewActionStatus; - close?: PullRequestReviewActionStatus; - comment?: PullRequestReviewActionStatus; - markReady?: PullRequestReviewActionStatus; - requestChanges?: PullRequestReviewActionStatus; -}; - -export type PullRequestMergeCheckStatus = 'failed' | 'neutral' | 'pending' | 'success'; - -export type PullRequestMergeCheck = { - detail?: string; - label: string; - status: PullRequestMergeCheckStatus; - url?: string; -}; - -export type PullRequestMergeOptions = { - removeSourceBranch: boolean; - squash: boolean; -}; - -export type PullRequestCodeQualityFinding = { - description: string; - engineName?: string; - filePath: string; - fingerprint: string; - lineNumber: number; - severity: 'blocker' | 'critical' | 'info' | 'major' | 'minor' | 'unknown'; - status: 'existing' | 'new' | 'resolved'; - url?: string; -}; - -export type PullRequestMergeState = { - autoMergeEnabled: boolean; - canCancelAutoMerge: boolean; - canMerge: boolean; - canSetAutoMerge: boolean; - checks: ReadonlyArray; - detailedStatus?: string; - forceRemoveSourceBranch: boolean; - mergeError?: string; - options: PullRequestMergeOptions; - reason?: string; - sha: string; - status: 'blocked' | 'checking' | 'closed' | 'merged' | 'ready' | 'waiting'; - statusLabel: string; -}; - -export type ReviewSource = - | { - type: 'working-tree'; - } - | { - ref: string; - type: 'commit'; - } - | { - ref: string; - type: 'branch'; - } - | { - /** Resolved base commit for a branch diff snapshot. */ - baseRef: string; - /** Resolved head commit for a branch diff snapshot. */ - headRef: string; - /** Target branch the current branch was compared against. */ - ref: string; - type: 'branch-diff'; - } - | { - /** - * Resolved base commit for the branch part of the comparison. Optional - * because the CLI can construct this source from just a branch name - * (`codiff main`), before merge-base resolution happens; the resolved - * state's `source` always carries a concrete value. - */ - baseRef?: string; - /** Resolved head commit for the branch part of the comparison. See {@link baseRef}. */ - headRef?: string; - /** Target branch the current branch was compared against. */ - ref: string; - type: 'branch-working-tree'; - } - | { - /** Base ref (left side). For symmetric ranges the diff starts at its merge-base with head. */ - base: string; - /** Head ref (right side). */ - head: string; - /** `true` for `base...head` (merge-base), `false` for `base..head` (direct). */ - symmetric: boolean; - type: 'range'; - } - | { - author?: ReviewAuthor; - canEditDescription?: boolean; - canEditReviewers?: boolean; - canEditTitle?: boolean; - description?: string; - draft?: boolean; - headSha?: string; - host?: string; - mergeState?: PullRequestMergeState; - number?: number; - owner?: string; - projectPath?: string; - provider?: 'github' | 'gitlab'; - repo?: string; - reviewers?: ReadonlyArray; - reviewStatus?: PullRequestReviewStatus; - title?: string; - type: 'pull-request'; - url: string; - }; - -/** Sources that can be entered from the palette or native application menu. */ -export type OpenReviewSourceKind = 'branch' | 'commit' | 'pull-request'; - -export type HistoryEntry = { - author: string; - committedAt: number; - gravatarUrl?: string; - parents: ReadonlyArray; - ref: string; - scope?: 'base' | 'pull-request'; - subject: string; -}; - -export type CommitMetadataPerson = { - date: string; - email: string; - gravatarUrl?: string; - name: string; -}; - -export type CommitMetadataFile = { - additions?: number; - binary: boolean; - deletions?: number; - oldPath?: string; - path: string; - status: GitFileStatus; -}; - -export type CommitMetadata = { - author: CommitMetadataPerson; - body: string; - committer: CommitMetadataPerson; - files: ReadonlyArray; - parents: ReadonlyArray; - ref: string; - refs: ReadonlyArray; - shortRef: string; - signature: { - key?: string; - signer?: string; - status: string; - }; - stats: { - additions: number; - binaryFiles: number; - deletions: number; - files: number; - renamedFiles: number; - }; - subject: string; - trailers: ReadonlyArray<{ - key: string; - value: string; - }>; -}; - -export type RepositoryHistory = { - entries: ReadonlyArray; - root: string; -}; - -export type RepositoryState = { - branch: string | null; - codeQualityFindings?: ReadonlyArray; - commitMetadata?: CommitMetadata; - files: ReadonlyArray; - generalComments?: ReadonlyArray; - generatedAt: number; - launchPath: string; - reviewComments?: ReadonlyArray; - root: string; - source: ReviewSource; -}; +export type * from './types/generation.ts'; +export type * from './types/review-comments.ts'; +export type * from './types/review-history.ts'; +export type * from './types/review-identity.ts'; +export type * from './types/walkthrough.ts'; export type CodiffFeatureFlags = { planSharing: boolean; walkthroughSharing: boolean; }; -export type WalkthroughProgressPhase = 'agent-generation' | 'response-received'; - -export type WalkthroughProgressEvent = { - phase: WalkthroughProgressPhase; -}; - -export type CodiffMarkdownDocument = { - content: string; - id: string; - kind: 'plan' | 'repository'; - path: string; - version: string; -}; - -export type SaveMarkdownDocumentRequest = { - baseVersion: string; - content: string; - kind: CodiffMarkdownDocument['kind']; - path: string; -}; - -export type SaveMarkdownDocumentResult = - | { - document: CodiffMarkdownDocument; - status: 'conflict'; - } - | { - document: CodiffMarkdownDocument; - status: 'saved'; - }; - -export type PlanCommentAuthor = { - avatarUrl?: string; - email?: string; - id: string; - name: string; - username?: string; -}; - -export type PlanCommentMessage = { - author: PlanCommentAuthor; - body: string; - canDelete?: boolean; - canEdit?: boolean; - createdAt: string; - id: string; - updatedAt: string; -}; - -export type PlanCommentThread = { - anchor: MarkdownAnnotationAnchor; - canReply?: boolean; - canResolve?: boolean; - createdAt: string; - createdBy: PlanCommentAuthor; - id: string; - messages: ReadonlyArray; - resolution?: { - reason: 'agent-handled' | 'anchor-removed'; - resolvedAt: string; - }; - status: 'open' | 'resolved'; - updatedAt: string; -}; - -export type PlanReview = { - document: { - id: string; - path: string; - version: string; - }; - threads: ReadonlyArray; - version: 1; -}; - -export type PlanHandoffStatus = 'closed' | 'done'; - -export type SharedWalkthroughSnapshot = { - branch: string | null; - codeQualityFindings?: ReadonlyArray; - codiffVersion: string; - exportedAt: string; - files: ReadonlyArray; - kind: 'codiff-walkthrough-share'; - preferences: Pick< - CodiffPreferences, - 'codeFontFamily' | 'codeFontSize' | 'diffStyle' | 'showWhitespace' | 'theme' | 'wordWrap' - >; - repository: { - generalComments?: ReadonlyArray; - root: string; - source: ReviewSource; - title?: string; - }; - reviewComments?: ReadonlyArray; - version: 1; - walkthrough: NarrativeWalkthrough; -}; - -export type SharedPlanSnapshot = { - codiffVersion: string; - document: { - content: string; - name: string; - title: string; - }; - exportedAt: string; - kind: 'codiff-plan-share'; - preferences: Pick; - review: { - threads: ReadonlyArray; - version: 1; - }; - source?: { - agent?: 'claude' | 'codex' | 'opencode' | 'pi'; - sessionId?: string; - }; - version: 1; -}; - -export type WalkthroughShareManifestV1 = SharedWalkthroughSnapshot; - -export type ShareResult = - | { - status: 'uploaded'; - url: string; - } - | { - reason: string; - status: 'failed'; - }; - -export type SharePlanResult = ShareResult; -export type ShareWalkthroughResult = ShareResult; - -export type WalkthroughContext = { - changedFiles?: ReadonlyArray<{ - path: string; - rationale?: string; - role: string; - }>; - constraints?: ReadonlyArray; - decisions?: ReadonlyArray; - implementationSummary?: string; - messages?: ReadonlyArray<{ - role: 'assistant' | 'user'; - text: string; - }>; - objective?: string; - risks?: ReadonlyArray; - source: { - generatedAt: string; - threadId?: string; - type: - | 'codex-session' - | 'codex-session-excerpt' - | 'claude-session' - | 'claude-session-excerpt' - | 'opencode-session' - | 'opencode-session-excerpt' - | 'pi-session' - | 'pi-session-excerpt'; - }; - validation?: ReadonlyArray; - version: 1; -}; - -export type CodiffLaunchOptions = { - agentBackend?: 'codex' | 'claude' | 'opencode' | 'pi'; - applyUpdate?: boolean; - claudeSessionId?: string; - codexSessionId?: string; - opencodeSessionId?: string; - piSessionId?: string; - /** Exact Markdown file opened by the blocking plan handoff. */ - planFile?: string; - /** Result file used to resume the waiting agent process. */ - planResultFile?: string; - repositoryPathProvided: boolean; - source?: ReviewSource; - walkthrough: boolean; - walkthroughContext?: WalkthroughContext; - /** Path to a pre-authored {@link NarrativeWalkthrough} JSON file (--walkthrough-file). */ - walkthroughFile?: string; -}; - -export type AgentSkillStatus = { - installed: boolean; - path: string; -}; - -/** @deprecated Use {@link AgentSkillStatus}. */ -export type CodexSkillStatus = AgentSkillStatus; - -export type TerminalHelperStatus = { - command: string; - installed: boolean; - path: string; -}; - -/** - * Narrative Walkthrough. The agent authors chapters, stops, and support groups - * around deterministic hunk ids. Codiff resolves those ids against the live diff - * and computes file paths, anchors, and line counts. - */ -export type WalkthroughIcon = 'bug' | 'wrench' | 'path' | 'flask' | 'beaker' | 'doc' | 'gear'; - -/** Where a walkthrough hunk points into the live diff. */ -export type WalkthroughAnchor = { - /** Human-readable location, e.g. 'src/App.tsx:311' or 'src/hooks/useHunkOrder.ts (new)'. */ - display: string; - /** End line on the {@link side} (inclusive). */ - endLine?: number; - /** Matches {@link DiffSection.id}, e.g. 'src/App.tsx:staged'. */ - sectionId?: string; - sectionKind?: DiffSection['kind']; - side?: 'additions' | 'deletions' | 'both'; - /** Start line on the {@link side}. */ - startLine?: number; -}; - -/** A short header note rendered above one focused walkthrough hunk diff. */ -export type WalkthroughHunkNote = { - body: string; - hunkId: string; -}; - -/** - * Change-type tag shown on a file row in the commit composer. Mirrors the - * walkthrough's narrative roles so a reviewer recognises each file at a glance. - */ -export type WalkthroughChangeType = - | 'fix' - | 'feature' - | 'refactor' - | 'test' - | 'generated' - | 'lockfile' - | 'snapshot' - | 'i18n' - | 'docs'; - -/** One resolved hunk selected by a walkthrough item, in agent-requested order. */ -export type WalkthroughHunk = { - added: number; - additionEnd?: number; - additionStart?: number; - anchor: WalkthroughAnchor; - deleted: number; - deletionEnd?: number; - deletionStart?: number; - id: string; - /** `synthetic` hunks represent binary, deferred, or metadata-only review units. */ - kind?: 'patch' | 'synthetic'; - oldPath?: string; - path: string; - status: GitFileStatus; -}; - -/** Shared hunk-backed fields for a stop or support group. */ -export type WalkthroughHunkGroup = { - added: number; - /** Change-type tag for the commit composer's file row. */ - changeType?: WalkthroughChangeType; - /** One-line note the generated commit body uses for this file (falls back to {@link summary}). */ - commitNote?: string; - deleted: number; - /** Deterministic hunk ids selected by the authoring agent, in display order. */ - hunkIds: ReadonlyArray; - /** Resolved hunks with Codiff-computed anchors, file paths, status, and line counts. */ - hunks: ReadonlyArray; - /** Stable within the document, e.g. 's1'. */ - id: string; - /** Optional header notes for individual hunk ids in this item. */ - notes?: ReadonlyArray; - /** Short, plain-text gist of the slice. */ - summary?: string; - title?: string; -}; - -/** One stop in the main walkthrough path. */ -export type WalkthroughStop = WalkthroughHunkGroup & { - importance: 'critical' | 'normal' | 'context'; - /** Agent narration (markdown / inline code). */ - prose: string; -}; - -/** A changed hunk group kept off the main path. */ -export type WalkthroughSupportGroup = WalkthroughHunkGroup & { - note?: string; - /** Why it is off the path, e.g. 'Generated' | 'Lockfile' | 'Snapshot' | 'Mechanical'. */ - reason: string; -}; - -/** A named chapter in the walkthrough. */ -export type WalkthroughChapter = { - blurb: string; - icon: WalkthroughIcon; - id: string; - stops: ReadonlyArray; - title: string; -}; - -/** - * Marks the walkthrough's diff as a staging set that can be committed and seeds - * the commit composer Codiff renders as the walkthrough's terminal stop. Only - * honored when {@link NarrativeWalkthrough.source} is a working tree — you can - * only commit a live staging set, never a past commit, branch, or pull request. - */ -export type WalkthroughCommit = { - /** - * The agent-drafted commit body — a few paragraphs of prose describing the - * change as a whole. Shown editable by default; the reviewer can rewrite it, - * or ask the agent to regenerate it for a narrowed file selection. - */ - body?: string; - /** Suggested first line for the commit message. */ - title?: string; -}; - -export type NarrativeWalkthrough = { - agent: 'codex' | 'claude' | 'opencode' | 'pi'; - chapters: ReadonlyArray; - /** - * When present, the diff is a committable staging set: Codiff adds a commit - * composer at the end of the walkthrough. Stripped unless `source` is a working tree. - */ - commit?: WalkthroughCommit; - /** The originating conversation, embedded for in-app Q&A. */ - context?: WalkthroughContext; - /** 1–2 sentence summary of the change. */ - focus: string; - /** ISO timestamp. */ - generatedAt: string; - kind: 'narrative'; - /** Display string, e.g. '6 stops · 4 chapters'. */ - meta?: string; - repo: { - branch: string | null; - root: string; - }; - source: ReviewSource; - support: ReadonlyArray; - title: string; - version: 4; -}; - -export type NarrativeWalkthroughResult = - | { - status: 'ready'; - walkthrough: NarrativeWalkthrough; - } - | { - code?: 'CODEX_NOT_FOUND' | 'CLAUDE_NOT_FOUND' | 'OPENCODE_NOT_FOUND' | 'PI_NOT_FOUND'; - reason: string; - status: 'unavailable'; - }; - -export type NarrativeWalkthroughRequestOptions = { - /** Ignore an exact cache hit and replace it with a newly generated result. */ - force?: boolean; - /** - * The walkthrough currently shown. Regeneration uses its prose as continuity - * while re-anchoring every stop against the current diff. - */ - previousWalkthrough?: NarrativeWalkthrough; -}; - -/** Commit the selected files from a walkthrough's staging set. */ -export type WalkthroughCommitRequest = { - /** Body of the commit message (everything after the subject line). */ - body: string; - /** Repo-relative paths to commit; other staged changes are left untouched. */ - paths: ReadonlyArray; - source?: ReviewSource; - /** First line of the commit message. */ - subject: string; -}; - -export type WalkthroughCommitResult = - | { - /** Full SHA of the new commit. */ - hash: string; - status: 'committed'; - } - | { - reason: string; - status: 'failed'; - }; - -/** - * Ask the connected agent to rewrite the commit message for the current file - * selection — used when the reviewer drops files from the staging set and the - * pre-drafted body no longer matches what is being committed. - */ -export type WalkthroughCommitMessageRequest = { - /** The current body, given to the agent as the message to revise. */ - body: string; - /** Repo-relative paths still selected for the commit. */ - paths: ReadonlyArray; - source?: ReviewSource; - /** The current subject line. */ - subject: string; -}; - -export type WalkthroughCommitMessageResult = - | { - body: string; - status: 'ready'; - subject: string; - } - | { - reason: string; - status: 'unavailable'; - }; - -export type ReviewAssistantRequest = { - comment: { - anchor?: 'file' | 'line'; - body: string; - filePath: string; - lineNumber?: number; - sectionId: string; - side?: 'additions' | 'deletions'; - startLineNumber?: number; - startSide?: 'additions' | 'deletions'; - }; - source?: ReviewSource; - walkthroughNote?: { - action: 'review' | 'scan' | 'skim'; - context: string; - groupReason: string; - groupTitle: string; - impact: 'wide' | 'contained' | 'mechanical'; - reason: string; - }; -}; - -export type ReviewAssistantResult = - | { - reply: string; - status: 'ready'; - } - | { - code?: 'CODEX_NOT_FOUND' | 'CLAUDE_NOT_FOUND' | 'OPENCODE_NOT_FOUND' | 'PI_NOT_FOUND'; - reason: string; - status: 'unavailable'; - }; - -export type GitIdentity = { - email: string; - gravatarUrl?: string; - name: string; - username?: string; -}; - -export type DiffSectionContentRequest = { - force?: boolean; - kind: DiffSection['kind']; - path: string; - showWhitespace?: boolean; - source?: ReviewSource; -}; - -export type DefinitionSearchRequest = { - identifier: string; - kind: DiffSection['kind']; - lineNumber: number; - path: string; - side: 'additions' | 'deletions'; - source: ReviewSource; -}; - -export type DefinitionCandidate = { - canOpenInEditor: boolean; - kind: string; - line: string; - lineNumber: number; - path: string; - side: 'additions' | 'deletions'; -}; - -export type DefinitionSearchResult = - | { - candidates: ReadonlyArray; - identifier: string; - status: 'ready'; - } - | { - reason: string; - status: 'unavailable'; - }; - -export type DiffImageContentRequest = { - kind: DiffSection['kind']; - path: string; - source?: ReviewSource; -}; - -export type DiffImageRevision = { - dataUrl: string; - mimeType: string; - name: string; - size: number; -}; - -export type DiffImageContentResult = - | { - newImage?: DiffImageRevision; - oldImage?: DiffImageRevision; - status: 'ready'; - } - | { - reason: string; - status: 'unavailable'; - }; - export type CodiffTheme = 'system' | 'light' | 'dark'; export type CodiffPreferences = { @@ -790,7 +28,6 @@ export type CodiffPreferences = { reviewCommentsPrefix: string; showOutdated: boolean; showWhitespace: boolean; - sidebarPosition: 'left' | 'right'; theme: CodiffTheme; walkthroughPrompt: string; wordWrap: boolean; @@ -810,60 +47,3 @@ export type CodiffUpdateStatus = { strategy?: 'download' | 'manual' | 'squirrel'; version?: string; }; - -export type PullRequestReviewComment = { - anchor?: 'file' | 'line'; - body: string; - filePath: string; - lineNumber?: number; - sectionId?: string; - side?: 'additions' | 'deletions'; - startLineNumber?: number; - startSide?: 'additions' | 'deletions'; - threadId?: string; -}; - -export type PullRequestExistingReviewComment = PullRequestReviewComment & { - author: ReviewAuthor; - canDelete?: boolean; - canEdit?: boolean; - canReplyThread?: boolean; - canResolveThread?: boolean; - id: string; - isOutdated?: boolean; - isThreadResolved?: boolean; - submittedAt?: string; - url?: string; -}; - -export type PullRequestGeneralComment = { - author: ReviewAuthor; - body: string; - canDelete?: boolean; - canEdit?: boolean; - id: string; - submittedAt?: string; - url?: string; -}; - -export type PullRequestGeneralCommentThread = { - canReply?: boolean; - canResolve?: boolean; - comments: ReadonlyArray; - id: string; - isResolved?: boolean; -}; - -export type PullRequestReviewEvent = 'APPROVE' | 'COMMENT' | 'REQUEST_CHANGES'; - -export type SubmitPullRequestCommentRequest = { - comment: PullRequestReviewComment; - source: Extract; -}; - -export type SubmitPullRequestReviewRequest = { - body?: string; - comments: ReadonlyArray; - event: PullRequestReviewEvent; - source: Extract; -}; diff --git a/core/types/generation.ts b/core/types/generation.ts new file mode 100644 index 00000000..e514b119 --- /dev/null +++ b/core/types/generation.ts @@ -0,0 +1,102 @@ +import type { GitSha, ResolvedReviewSource } from './review-identity.ts'; +import type { NarrativeWalkthrough } from './walkthrough.ts'; + +export type GenerationSettings = Readonly>; + +/** Safe model policy that materially affects one generated component. */ +export type GenerationProfile = { + agent: NarrativeWalkthrough['agent']; + authoringVersion: string; + modelCandidates: ReadonlyArray; + settings?: GenerationSettings; +}; + +/** Provenance for one successful model-produced component. */ +export type GenerationMetadata = { + agent: NarrativeWalkthrough['agent']; + generatedAt: string; + model: string; + profile: GenerationProfile; +}; + +export type WalkthroughGenerationUnitProgress = { + detail?: string; + id: string; + label: string; + status: 'failed' | 'generating' | 'pending' | 'preparing' | 'ready'; +}; + +/** Format-neutral progress for one in-flight walkthrough generation. */ +export type WalkthroughGenerationProgress = { + completed?: number; + phase: 'combining' | 'generating' | 'generating-units' | 'preparing'; + summary: string; + total?: number; + units?: ReadonlyArray; +}; + +export type WalkthroughProgressPhase = 'agent-generation' | 'response-received'; +export type WalkthroughProgressEvent = { + generation?: WalkthroughGenerationProgress; + phase?: WalkthroughProgressPhase; +}; + +export type NarrativeWalkthroughResult = + | { status: 'ready'; walkthrough: NarrativeWalkthrough } + | { + code?: 'CODEX_NOT_FOUND' | 'CLAUDE_NOT_FOUND' | 'OPENCODE_NOT_FOUND' | 'PI_NOT_FOUND'; + reason: string; + status: 'unavailable'; + }; +export type NarrativeWalkthroughRequestOptions = { + force?: boolean; + previousWalkthrough?: NarrativeWalkthrough; +}; + +export type WalkthroughCommitRequest = { + body: string; + paths: ReadonlyArray; + source?: ResolvedReviewSource; + subject: string; +}; +export type WalkthroughCommitResult = + | { sha: GitSha; status: 'committed' } + | { reason: string; status: 'failed' }; +export type WalkthroughCommitMessageRequest = { + body: string; + paths: ReadonlyArray; + source?: ResolvedReviewSource; + subject: string; +}; +export type WalkthroughCommitMessageResult = + | { body: string; status: 'ready'; subject: string } + | { reason: string; status: 'unavailable' }; + +export type ReviewAssistantRequest = { + comment: { + anchor?: 'file' | 'line'; + body: string; + filePath: string; + lineNumber?: number; + sectionId: string; + side?: 'additions' | 'deletions'; + startLineNumber?: number; + startSide?: 'additions' | 'deletions'; + }; + source?: ResolvedReviewSource; + walkthroughNote?: { + action: 'review' | 'scan' | 'skim'; + context: string; + groupReason: string; + groupTitle: string; + impact: 'wide' | 'contained' | 'mechanical'; + reason: string; + }; +}; +export type ReviewAssistantResult = + | { reply: string; status: 'ready' } + | { + code?: 'CODEX_NOT_FOUND' | 'CLAUDE_NOT_FOUND' | 'OPENCODE_NOT_FOUND' | 'PI_NOT_FOUND'; + reason: string; + status: 'unavailable'; + }; diff --git a/core/types/review-comments.ts b/core/types/review-comments.ts new file mode 100644 index 00000000..2f028315 --- /dev/null +++ b/core/types/review-comments.ts @@ -0,0 +1,196 @@ +import type { MarkdownAnnotationAnchor } from '@nkzw/mdx-editor'; +import type { ReviewAuthor } from './review-history.ts'; +import type { DiffRange, ReviewSource, Revision } from './review-identity.ts'; + +export type PlanCommentAuthor = { + avatarUrl?: string; + email?: string; + id: string; + name: string; + username?: string; +}; + +export type PlanCommentMessage = { + author: PlanCommentAuthor; + body: string; + canDelete?: boolean; + canEdit?: boolean; + createdAt: string; + id: string; + updatedAt: string; +}; + +export type PlanCommentThread = { + anchor: MarkdownAnnotationAnchor; + canReply?: boolean; + canResolve?: boolean; + createdAt: string; + createdBy: PlanCommentAuthor; + id: string; + messages: ReadonlyArray; + resolution?: { reason: 'agent-handled' | 'anchor-removed'; resolvedAt: string }; + status: 'open' | 'resolved'; + updatedAt: string; +}; + +export type PlanReview = { + document: { id: string; path: string; version: string }; + threads: ReadonlyArray; + version: 1; +}; + +export type PlanHandoffStatus = 'closed' | 'done'; + +/** Durable persisted comment identity independent of a renderer section ID. */ +export type ReviewCommentPosition = { + range: DiffRange; +}; + +export type PullRequestReviewComment = { + anchor?: 'file' | 'line'; + body: string; + filePath: string; + lineNumber?: number; + /** Local-only draft identity; provider payload builders deliberately omit it. */ + localDraftId?: string; + position?: ReviewCommentPosition; + sectionId?: string; + side?: 'additions' | 'deletions'; + startLineNumber?: number; + startSide?: 'additions' | 'deletions'; + threadId?: string; +}; + +export type PullRequestExistingReviewComment = PullRequestReviewComment & { + author: ReviewAuthor; + canDelete?: boolean; + canEdit?: boolean; + canReplyThread?: boolean; + canResolveThread?: boolean; + id: string; + isOutdated?: boolean; + isThreadResolved?: boolean; + submittedAt?: string; + url?: string; +}; + +type ReviewCommentSubmissionBase = { + anchor?: 'file' | 'line'; + body: string; + filePath: string; + lineNumber?: number; + side?: 'additions' | 'deletions'; + startLineNumber?: number; + startSide?: 'additions' | 'deletions'; + threadId?: string; +}; + +type CommitRevision = Extract; + +export type ProviderReviewCommentPosition = { + range: { + base: CommitRevision; + head: CommitRevision; + }; +}; + +export type ProviderCommentSubmission = ReviewCommentSubmissionBase & { + /** Host-only identity used to account for partial review submission. */ + localDraftId: string; + sectionId?: never; +} & ({ position: ProviderReviewCommentPosition } | { position?: never; threadId: string }); + +export type ShareCommentSubmission = ReviewCommentSubmissionBase & + ( + | { + position: ReviewCommentPosition; + sectionId?: never; + } + | { + position?: never; + sectionId: string; + } + ); + +type SubmittedReviewCommentBase = ReviewCommentSubmissionBase & { + author: ReviewAuthor; + canDelete?: boolean; + canEdit?: boolean; + canReplyThread?: boolean; + canResolveThread?: boolean; + id: string; + isOutdated?: boolean; + isReadOnly: true; + isThreadResolved?: boolean; + resolvedSectionId?: string; + submittedAt?: string; + url?: string; +}; + +export type SubmittedReviewComment = SubmittedReviewCommentBase & + ( + | { + destination: 'provider'; + position?: ProviderReviewCommentPosition; + sectionId?: never; + } + | { + destination: 'share'; + position?: ReviewCommentPosition; + sectionId?: string; + } + ); + +export type PullRequestGeneralComment = { + author: ReviewAuthor; + body: string; + canDelete?: boolean; + canEdit?: boolean; + id: string; + submittedAt?: string; + url?: string; +}; + +export type PullRequestGeneralCommentThread = { + canReply?: boolean; + canResolve?: boolean; + comments: ReadonlyArray; + id: string; + isResolved?: boolean; +}; + +export type ReviewCommenting = { + canComment?: boolean; + onDeleteComment?: (commentId: string) => Promise; + onDeleteGeneralComment?: (commentId: string) => Promise; + onReplyGeneralComment?: (threadId: string, body: string) => Promise; + onResolveDiscussion?: (discussionId: string, resolved: boolean) => Promise; + onSignIn?: () => Promise | void; + onSubmitGeneralComment?: (body: string) => Promise; + onUpdateComment?: (commentId: string, body: string) => Promise; + onUpdateGeneralComment?: (commentId: string, body: string) => Promise; +}; + +export type PullRequestReviewEvent = 'APPROVE' | 'COMMENT' | 'REQUEST_CHANGES'; +export type SubmitPullRequestCommentRequest = { + comment: ProviderCommentSubmission; + source: Extract; +}; +export type SubmitPullRequestReviewRequest = { + body?: string; + comments: ReadonlyArray; + event: PullRequestReviewEvent; + source: Extract; +}; + +export type SubmitPullRequestReviewResult = + | { + status: 'submitted'; + submittedDraftIds: ReadonlyArray; + } + | { + outcomeUnknownDraftIds?: ReadonlyArray; + reason: string; + status: 'failed'; + submittedDraftIds: ReadonlyArray; + }; diff --git a/core/types/review-history.ts b/core/types/review-history.ts new file mode 100644 index 00000000..09427328 --- /dev/null +++ b/core/types/review-history.ts @@ -0,0 +1,137 @@ +import type { + PullRequestExistingReviewComment, + PullRequestGeneralCommentThread, +} from './review-comments.ts'; +import type { + ChangedFile, + GitFileStatus, + GitSha, + ResolvedReviewSource, +} from './review-identity.ts'; + +export type ReviewAuthor = { + avatarUrl?: string; + login: string; + name?: string; + url?: string; +}; + +export type PullRequestReviewer = ReviewAuthor & { approved: boolean; id: string }; +export type PullRequestReviewActionStatus = { disabled?: boolean; reason?: string }; +export type PullRequestReviewStatus = { + approve?: PullRequestReviewActionStatus; + close?: PullRequestReviewActionStatus; + comment?: PullRequestReviewActionStatus; + markReady?: PullRequestReviewActionStatus; + requestChanges?: PullRequestReviewActionStatus; +}; + +export type PullRequestMergeCheckStatus = 'failed' | 'neutral' | 'pending' | 'success'; +export type PullRequestMergeCheck = { + detail?: string; + label: string; + status: PullRequestMergeCheckStatus; + url?: string; +}; +export type PullRequestMergeOptions = { removeSourceBranch: boolean; squash: boolean }; +export type PullRequestMergeState = { + autoMergeEnabled: boolean; + canCancelAutoMerge: boolean; + canMerge: boolean; + canSetAutoMerge: boolean; + checks: ReadonlyArray; + detailedStatus?: string; + forceRemoveSourceBranch: boolean; + mergeError?: string; + options: PullRequestMergeOptions; + reason?: string; + sha: string; + status: 'blocked' | 'checking' | 'closed' | 'merged' | 'ready' | 'waiting'; + statusLabel: string; +}; + +export type PullRequestCodeQualityFinding = { + description: string; + engineName?: string; + filePath: string; + fingerprint: string; + lineNumber: number; + severity: 'blocker' | 'critical' | 'info' | 'major' | 'minor' | 'unknown'; + status: 'existing' | 'new' | 'resolved'; + url?: string; +}; + +export type HistoryEntry = { + author: string; + committedAt: number; + gravatarUrl?: string; + parentShas: ReadonlyArray; + scope?: 'base' | 'pull-request'; + sha: GitSha; + subject: string; +}; + +/** Canonical provider-neutral commit item in a current review stack. */ +export type ReviewCommitSummary = { + authoredAt: string; + authorName: string; + parentShas: ReadonlyArray; + sha: GitSha; + shortSha: string; + subject: string; + webUrl?: string; +}; + +export type CommitMetadataPerson = { + date: string; + email: string; + gravatarUrl?: string; + name: string; +}; + +export type CommitMetadataFile = { + additions?: number; + binary: boolean; + deletions?: number; + oldPath?: string; + path: string; + status: GitFileStatus; +}; + +export type CommitMetadata = { + author: CommitMetadataPerson; + body: string; + committer: CommitMetadataPerson; + files: ReadonlyArray; + parentShas: ReadonlyArray; + refs: ReadonlyArray; + sha: GitSha; + shortSha: string; + signature: { key?: string; signer?: string; status: string }; + stats: { + additions: number; + binaryFiles: number; + deletions: number; + files: number; + renamedFiles: number; + }; + subject: string; + trailers: ReadonlyArray<{ key: string; value: string }>; +}; + +export type RepositoryHistory = { entries: ReadonlyArray; root: string }; + +export type RepositoryState = { + branch: string | null; + codeQualityFindings?: ReadonlyArray; + commitMetadata?: CommitMetadata; + files: ReadonlyArray; + generalComments?: ReadonlyArray; + generatedAt: number; + launchPath: string; + reviewComments?: ReadonlyArray; + reviewCommentsError?: string; + reviewCommentsLoadState?: 'failed' | 'loaded' | 'not-loaded'; + root: string; + source: ResolvedReviewSource; +}; diff --git a/core/types/review-identity.ts b/core/types/review-identity.ts new file mode 100644 index 00000000..5d573f47 --- /dev/null +++ b/core/types/review-identity.ts @@ -0,0 +1,206 @@ +import type { + PullRequestMergeState, + PullRequestReviewStatus, + PullRequestReviewer, + ReviewAuthor, +} from './review-history.ts'; + +export type DiffSection = { + binary: boolean; + id: string; + kind: 'commit' | 'pull-request' | 'staged' | 'unstaged'; + lineCount?: { additions: number; deletions: number }; + loadState?: 'binary' | 'deferred' | 'directory' | 'error' | 'ready' | 'too-large'; + newFile?: { cacheKey?: string; contents: string; name: string }; + oldFile?: { cacheKey?: string; contents: string; name: string }; + patch: string; + range?: DiffRange; + summary?: { + canLoad?: boolean; + fileCount?: number; + fingerprint?: string; + limit?: number; + reason: string; + size?: number; + }; +}; + +export type GitFileStatus = + | 'added' + | 'conflicted' + | 'deleted' + | 'modified' + | 'renamed' + | 'untracked'; + +export type ChangedFile = { + fingerprint: string; + generated?: boolean; + oldPath?: string; + path: string; + sections: ReadonlyArray; + status: GitFileStatus; +}; + +export type GitSha = string & { readonly __gitSha: unique symbol }; + +/** Sources that can be entered from the palette or native application menu. */ +export type OpenReviewSourceKind = 'branch' | 'commit' | 'pull-request'; + +export type ReviewSource = + | { type: 'working-tree' } + | { ref: string; type: 'commit' } + | { ref: string; type: 'branch' } + | { baseSha: GitSha; headSha: GitSha; ref: string; type: 'branch-diff' } + | { + baseSha?: GitSha; + headSha?: GitSha; + ref: string; + type: 'branch-working-tree'; + } + | { base: string; head: string; symmetric: boolean; type: 'range' } + | { + author?: ReviewAuthor; + canEditDescription?: boolean; + canEditReviewers?: boolean; + canEditTitle?: boolean; + description?: string; + draft?: boolean; + headSha?: string; + host?: string; + mergeState?: PullRequestMergeState; + number?: number; + owner?: string; + projectPath?: string; + provider?: 'github' | 'gitlab'; + repo?: string; + reviewers?: ReadonlyArray; + reviewStatus?: PullRequestReviewStatus; + targetBranch?: string; + title?: string; + type: 'pull-request'; + url: string; + }; + +export type ResolvedReviewSource = + | Exclude + | { sha: GitSha; type: 'commit' } + | { baseSha: GitSha; headSha: GitSha; ref: string; type: 'branch-working-tree' }; + +export type RevisionLabel = { + kind: 'bookmark' | 'branch' | 'commit' | 'review-marker' | 'tag' | 'version'; + text: string; + url?: string; +}; + +export type Revision = + | { aliases?: ReadonlyArray; kind?: 'commit'; label: RevisionLabel; sha: GitSha } + | { + aliases?: ReadonlyArray; + kind: 'index'; + label: RevisionLabel; + stage?: 1 | 2 | 3; + } + | { aliases?: ReadonlyArray; kind: 'working-copy'; label: RevisionLabel }; + +/** A null endpoint represents an absent file side, such as an unborn-repository addition. */ +export type DiffRange = { base: Revision | null; head: Revision | null }; + +export type RevisionContentRequest = { + key: string; + maxBytes: number; + path: string; + revision: Revision; +}; + +export type ResolvedRevisionBytes = { + bytes: Uint8Array; + cacheKey: string; + objectId?: string; + path: string; + provenance: 'filesystem' | 'git-index' | 'github-api' | 'gitlab-api' | 'native-git'; + size: number; +}; + +export type RevisionContentItemResult = + | { key: string; status: 'missing' } + | { key: string; reason: string; status: 'unavailable' } + | { key: string; status: 'ready'; value: ResolvedRevisionBytes }; + +export type RevisionContentBatchRequest = { + generation: string; + requestId?: string; + requests: ReadonlyArray; + source: ResolvedReviewSource; +}; + +export type RevisionContentBatchResult = { + results: ReadonlyArray; +}; + +export type DiffImageRevision = { dataUrl: string; mimeType: string; name: string; size: number }; + +export type DiffImageContentResult = + | { newImage?: DiffImageRevision; oldImage?: DiffImageRevision; status: 'ready' } + | { reason: string; status: 'unavailable' }; + +export type GitIdentity = { + email: string; + gravatarUrl?: string; + name: string; + username?: string; +}; + +export type DiffSectionContentRequest = { + force?: boolean; + kind: DiffSection['kind']; + path: string; + requestId?: string; + showWhitespace?: boolean; + source?: ResolvedReviewSource; +}; +export type DiffSectionsContentRequest = { + requestId?: string; + source: Extract; +}; + +export type DiffSectionsContentResult = { + headSha?: GitSha; + sections: ReadonlyArray<{ path: string; section: DiffSection }>; +}; + +export type DefinitionSearchRequest = { + identifier: string; + kind: DiffSection['kind']; + lineNumber: number; + path: string; + side: 'additions' | 'deletions'; + source: ResolvedReviewSource; +}; + +export type DefinitionCandidate = { + canOpenInEditor: boolean; + kind: string; + line: string; + lineNumber: number; + path: string; + side: 'additions' | 'deletions'; +}; + +export type DefinitionSearchResult = + | { + candidates: ReadonlyArray; + identifier: string; + status: 'ready'; + } + | { + reason: string; + status: 'unavailable'; + }; + +export type DiffImageContentRequest = { + kind: DiffSection['kind']; + path: string; + requestId?: string; + source?: ResolvedReviewSource; +}; diff --git a/core/types/walkthrough.ts b/core/types/walkthrough.ts new file mode 100644 index 00000000..45a9ceac --- /dev/null +++ b/core/types/walkthrough.ts @@ -0,0 +1,191 @@ +import type { CodiffPreferences } from '../types.ts'; +import type { + PlanCommentThread, + PullRequestExistingReviewComment, + PullRequestGeneralCommentThread, +} from './review-comments.ts'; +import type { CommitMetadata, PullRequestCodeQualityFinding } from './review-history.ts'; +import type { + ChangedFile, + DiffSection, + GitFileStatus, + ResolvedReviewSource, + ReviewSource, +} from './review-identity.ts'; + +export type CodiffMarkdownDocument = { + content: string; + id: string; + kind: 'plan' | 'repository'; + path: string; + version: string; +}; +export type SaveMarkdownDocumentRequest = { + baseVersion: string; + content: string; + kind: CodiffMarkdownDocument['kind']; + path: string; +}; +export type SaveMarkdownDocumentResult = + | { document: CodiffMarkdownDocument; status: 'conflict' } + | { document: CodiffMarkdownDocument; status: 'saved' }; + +export type WalkthroughContext = { + changedFiles?: ReadonlyArray<{ path: string; rationale?: string; role: string }>; + constraints?: ReadonlyArray; + decisions?: ReadonlyArray; + implementationSummary?: string; + messages?: ReadonlyArray<{ role: 'assistant' | 'user'; text: string }>; + objective?: string; + risks?: ReadonlyArray; + source: { + generatedAt: string; + threadId?: string; + type: + | 'codex-session' + | 'codex-session-excerpt' + | 'claude-session' + | 'claude-session-excerpt' + | 'opencode-session' + | 'opencode-session-excerpt' + | 'pi-session' + | 'pi-session-excerpt'; + }; + validation?: ReadonlyArray; + version: 1; +}; + +export type CodiffLaunchOptions = { + agentBackend?: 'codex' | 'claude' | 'opencode' | 'pi'; + applyUpdate?: boolean; + claudeSessionId?: string; + codexSessionId?: string; + opencodeSessionId?: string; + piSessionId?: string; + planFile?: string; + planResultFile?: string; + repositoryPathProvided: boolean; + source?: ReviewSource; + walkthrough: boolean; + walkthroughContext?: WalkthroughContext; + walkthroughFile?: string; +}; + +export type AgentSkillStatus = { installed: boolean; path: string }; +/** @deprecated Use AgentSkillStatus. */ +export type CodexSkillStatus = AgentSkillStatus; +export type TerminalHelperStatus = { command: string; installed: boolean; path: string }; + +export type WalkthroughIcon = 'bug' | 'wrench' | 'path' | 'flask' | 'beaker' | 'doc' | 'gear'; +export type WalkthroughAnchor = { + display: string; + endLine?: number; + sectionId?: string; + sectionKind?: DiffSection['kind']; + side?: 'additions' | 'deletions' | 'both'; + startLine?: number; +}; +export type WalkthroughHunkNote = { body: string; hunkId: string }; +export type WalkthroughChangeType = + | 'fix' + | 'feature' + | 'refactor' + | 'test' + | 'generated' + | 'lockfile' + | 'snapshot' + | 'i18n' + | 'docs'; +export type WalkthroughHunk = { + added: number; + additionEnd?: number; + additionStart?: number; + anchor: WalkthroughAnchor; + deleted: number; + deletionEnd?: number; + deletionStart?: number; + id: string; + kind?: 'patch' | 'synthetic'; + oldPath?: string; + path: string; + status: GitFileStatus; +}; +export type WalkthroughHunkGroup = { + added: number; + changeType?: WalkthroughChangeType; + commitNote?: string; + deleted: number; + hunkIds: ReadonlyArray; + hunks: ReadonlyArray; + id: string; + notes?: ReadonlyArray; + summary?: string; + title?: string; +}; +export type WalkthroughStop = WalkthroughHunkGroup & { + importance: 'critical' | 'normal' | 'context'; + prose: string; +}; +export type WalkthroughSupportGroup = WalkthroughHunkGroup & { note?: string; reason: string }; +export type WalkthroughChapter = { + blurb: string; + icon: WalkthroughIcon; + id: string; + stops: ReadonlyArray; + title: string; +}; +export type WalkthroughCommit = { body?: string; title?: string }; +export type NarrativeWalkthrough = { + agent: 'codex' | 'claude' | 'opencode' | 'pi'; + chapters: ReadonlyArray; + commit?: WalkthroughCommit; + context?: WalkthroughContext; + focus: string; + generatedAt: string; + kind: 'narrative'; + meta?: string; + repo: { branch: string | null; root: string }; + source: ResolvedReviewSource; + support: ReadonlyArray; + title: string; + version: 4; +}; + +export type SharedWalkthroughSnapshot = { + branch: string | null; + codeQualityFindings?: ReadonlyArray; + codiffVersion: string; + commitMetadata?: CommitMetadata; + exportedAt: string; + files: ReadonlyArray; + kind: 'codiff-walkthrough-share'; + preferences: Pick< + CodiffPreferences, + 'codeFontFamily' | 'codeFontSize' | 'diffStyle' | 'showWhitespace' | 'theme' | 'wordWrap' + >; + repository: { + generalComments?: ReadonlyArray; + root: string; + source: ResolvedReviewSource; + title?: string; + }; + reviewComments?: ReadonlyArray; + version: 1; + walkthrough: NarrativeWalkthrough; +}; +export type SharedPlanSnapshot = { + codiffVersion: string; + document: { content: string; name: string; title: string }; + exportedAt: string; + kind: 'codiff-plan-share'; + preferences: Pick; + review: { threads: ReadonlyArray; version: 1 }; + source?: { agent?: 'claude' | 'codex' | 'opencode' | 'pi'; sessionId?: string }; + version: 1; +}; +export type WalkthroughShareManifestV1 = SharedWalkthroughSnapshot; +export type ShareResult = + | { status: 'uploaded'; url: string } + | { reason: string; status: 'failed' }; +export type SharePlanResult = ShareResult; +export type ShareWalkthroughResult = ShareResult; diff --git a/core/walkthrough-generation.ts b/core/walkthrough-generation.ts new file mode 100644 index 00000000..15436d90 --- /dev/null +++ b/core/walkthrough-generation.ts @@ -0,0 +1,9 @@ +export { + runWalkthroughGenerationTasks, + walkthroughGenerationConcurrency, + type ReusableWalkthroughGenerationComponent, + type RunWalkthroughGenerationTasksInput, + type RunWalkthroughGenerationTasksResult, + type WalkthroughGenerationFailure, + type WalkthroughGenerationTask, +} from './lib/walkthrough-generation-tasks.ts'; diff --git a/docs/review-history-packages.md b/docs/review-history-packages.md new file mode 100644 index 00000000..f6f55ac3 --- /dev/null +++ b/docs/review-history-packages.md @@ -0,0 +1,53 @@ +# Review-history package boundaries + +Core owns commit-diff request keys, stack/range/blob schemas, and +request-local reuse. Provider packages turn GitHub and GitLab responses into +those schemas. Hosts own authentication, process I/O, and UI. + +## Ownership + +| Boundary | Responsibility | +| ------------------- | --------------------------------------------------------------------------- | +| `@nkzw/codiff-core` | Commit+parent request keys, artifact schemas, validation, and one-run reuse | +| Provider packages | Current-review normalization and `ReviewArtifactSource` implementations | +| Hosts | Authentication, network and process I/O, caching, cancellation, and UI | + +Provider wire records do not cross into Core algorithms or shared UI. Provider +packages do not authenticate requests or spawn local processes. + +## Commit diffs + +A merge commit `M` with parents `A` and `B` has two different diffs: `M` vs +`A`, and `M` vs `B`. If a cache or in-flight map is keyed only by `M`, the +second read overwrites the first. The request key is `commitSha:parentSha` +(`M:A`, `M:B`). A root commit uses `M:root`. + +GitHub compare is a second case. Asking for `requestedBase...head` may return +a different `merge_base_commit`. Dedupe the in-flight read by the pair we +asked for. Record the effective base GitHub actually used on the returned +range and stack. Two selectors that resolve to the same merge-base must not +share one pending request. + +## Immutable review artifacts + +- `StackSnapshot` records a parent-first commit stack for one exact range. +- `CommitArtifact` records the change from one selected parent. +- `RangeArtifact` records the net tree change for one base/head pair. +- `BlobArtifact` records bounded full-file bytes by Git object identity. +- `FileBlobArtifactRequest` resolves a path at an exact commit SHA when the + caller does not yet know the object ID. + +Every stack, commit, and range artifact carries provenance and explicit +completeness. Missing or truncated evidence is never represented as a complete +empty patch. + +`ReviewArtifactSource.readStackAndRange` accepts a typed request whose +`requestedBaseSha` is the selector sent to the provider and whose `headSha` is +the requested review head. Providers may resolve that selector to an effective +base: `RangeArtifact.baseSha` and `StackSnapshot.baseSha` always record that +effective base, while their heads must remain the requested head. A +same-commit request stays an empty same-commit result. + +`createReviewArtifactRun` deduplicates overlapping reads only within one +request. Range reads are cached and diagnosed by their requested selector +pair, not by a provider-resolved effective pair. diff --git a/electron/__tests__/agent.test.ts b/electron/__tests__/agent.test.ts index 416ca109..8612a9fa 100644 --- a/electron/__tests__/agent.test.ts +++ b/electron/__tests__/agent.test.ts @@ -13,6 +13,9 @@ const { isAvailable?: (agent: { id: 'codex' | 'claude' | 'opencode' | 'pi' }) => boolean, ) => string; getAgent: (backendId: unknown) => { + defaultModel: string; + fallbackModel: string; + getModelCandidates: (model: unknown) => ReadonlyArray; id: string; isAvailable: () => boolean; label: string; @@ -114,3 +117,20 @@ test('shows a custom configured model in the agent model menu', () => { test('falls back to the default backend for unknown ids', () => { expect(getAgent('unknown').id).toBe('codex'); }); + +test('constructs normalized fallback chains for every agent backend', () => { + const codex = getAgent('codex'); + expect(codex.getModelCandidates('gpt-5.6-sol')).toEqual([ + 'gpt-5.6-sol', + 'gpt-5.6-terra', + 'gpt-5.5', + ]); + + for (const backend of ['claude', 'opencode', 'pi']) { + const agent = getAgent(backend); + const candidates = agent.getModelCandidates(agent.defaultModel); + expect(candidates[0]).toBe(agent.defaultModel); + expect(candidates).toContain(agent.fallbackModel); + expect(new Set(candidates).size).toBe(candidates.length); + } +}); diff --git a/electron/__tests__/command-log.test.ts b/electron/__tests__/command-log.test.ts new file mode 100644 index 00000000..a25d49cc --- /dev/null +++ b/electron/__tests__/command-log.test.ts @@ -0,0 +1,302 @@ +import { chmod, readFile, stat } from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import process from 'node:process'; +import { afterEach, expect, test } from 'vite-plus/test'; +import { createTemporaryGitRepository } from './helpers/git-repository.ts'; + +const require = createRequire(import.meta.url); +const { + configureCommandLog, + disableCommandLog, + flushCommandLog, + recordCommandMilestone, + runWithCommandAction, + sanitizeArgs, + startCommandAction, + startCommandTiming, +} = require('../command-log.cjs') as { + configureCommandLog: (logsDirectory: string) => string; + disableCommandLog: () => void; + flushCommandLog: () => Promise; + recordCommandMilestone: (name: string) => void; + runWithCommandAction: ( + input: { command: string; details?: Record }, + callback: () => Promise, + ) => Promise; + startCommandAction: (input: { command: string; details?: Record }) => { + cancel: () => void; + run: (callback: () => Value) => Value; + signal: AbortSignal; + }; + sanitizeArgs: (args: ReadonlyArray) => ReadonlyArray; + startCommandTiming: (input: { + args?: ReadonlyArray; + command: string; + cwd?: string; + details?: Record; + kind?: 'action' | 'command'; + }) => { finish: (result?: { error?: unknown; exitCode?: number | null }) => void }; +}; +const { git } = require('../git-state/common.cjs') as { + git: ( + repoPath: string, + args: ReadonlyArray, + options?: { signal?: AbortSignal }, + ) => Promise; +}; + +type CommandLogRecord = { + args?: ReadonlyArray; + command?: string; + details?: Record; + durationMs?: number; + exitCode?: number; + actionId?: number; + event: 'finish' | 'milestone' | 'session-start' | 'start'; + id?: number; + kind?: 'action' | 'command'; + status?: 'canceled' | 'error' | 'ok' | 'timeout'; +}; + +type TemporaryGitRepository = Awaited>; + +const temporaryRepositories: Array = []; + +const createCommandLogRepository = async () => { + const repository = await createTemporaryGitRepository('codiff-command-log-'); + temporaryRepositories.push(repository); + return repository.path; +}; + +afterEach(async () => { + await flushCommandLog(); + disableCommandLog(); + await Promise.all( + temporaryRepositories.splice(0).map((repository) => repository[Symbol.asyncDispose]()), + ); +}); + +test('records action and Git command timings without affecting command results', async () => { + const repository = await createCommandLogRepository(); + const path = configureCommandLog(repository); + + await runWithCommandAction( + { + command: 'review-load', + details: { sourceType: 'pull-request' }, + }, + async () => { + recordCommandMilestone('evidence-ready'); + await expect(git(repository, ['rev-parse', '--show-toplevel'])).resolves.toContain( + 'codiff-command-log-', + ); + await expect( + git(repository, ['rev-parse', '--verify', 'refs/codiff-tests/definitely-missing']), + ).rejects.toThrow(); + }, + ); + await flushCommandLog(); + + const records = (await readFile(path, 'utf8')) + .trim() + .split('\n') + .map((line) => JSON.parse(line) as CommandLogRecord); + expect(records[0]).toEqual( + expect.objectContaining({ + event: 'session-start', + }), + ); + + const actionStart = records.find( + (record) => record.event === 'start' && record.command === 'review-load', + ); + expect(actionStart).toEqual( + expect.objectContaining({ + details: { sourceType: 'pull-request' }, + kind: 'action', + }), + ); + expect(records).toContainEqual( + expect.objectContaining({ + command: 'review-load', + durationMs: expect.any(Number), + event: 'finish', + id: actionStart?.id, + status: 'ok', + }), + ); + + const gitStarts = records.filter( + (record) => record.event === 'start' && record.command === 'git', + ); + expect(gitStarts).toHaveLength(2); + expect(gitStarts.every((record) => record.actionId === actionStart?.id)).toBe(true); + expect(gitStarts[0]?.args).toEqual(expect.arrayContaining(['rev-parse', '--show-toplevel'])); + expect(records).toContainEqual( + expect.objectContaining({ + command: 'git', + durationMs: expect.any(Number), + event: 'finish', + exitCode: 0, + id: gitStarts[0]?.id, + status: 'ok', + }), + ); + expect(records).toContainEqual( + expect.objectContaining({ + command: 'git', + durationMs: expect.any(Number), + event: 'finish', + exitCode: expect.any(Number), + id: gitStarts[1]?.id, + status: 'error', + }), + ); +}); + +test('records canceled Git commands at a standalone action boundary', async () => { + const repository = await createCommandLogRepository(); + const path = configureCommandLog(repository); + const controller = new AbortController(); + controller.abort(); + + await expect( + git(repository, ['rev-parse', '--show-toplevel'], { signal: controller.signal }), + ).rejects.toThrow(); + await flushCommandLog(); + + const records = (await readFile(path, 'utf8')) + .trim() + .split('\n') + .map((line) => JSON.parse(line) as CommandLogRecord); + const commandStart = records.find( + (record) => record.event === 'start' && record.command === 'git', + ); + expect(commandStart?.actionId).toEqual(expect.any(Number)); + expect(records).toContainEqual( + expect.objectContaining({ + actionId: commandStart?.actionId, + command: 'git', + event: 'finish', + id: commandStart?.id, + status: 'canceled', + }), + ); + expect(records).toContainEqual( + expect.objectContaining({ + command: 'standalone-command', + event: 'finish', + id: commandStart?.actionId, + status: 'canceled', + }), + ); +}); + +test('cancels Git commands through their enclosing action signal', async () => { + const repository = await createCommandLogRepository(); + const path = configureCommandLog(repository); + const action = startCommandAction({ command: 'initial-load' }); + + action.cancel(); + expect(action.signal.aborted).toBe(true); + await expect( + action.run(() => git(repository, ['rev-parse', '--show-toplevel'])), + ).rejects.toMatchObject({ name: 'AbortError' }); + await flushCommandLog(); + + const records = (await readFile(path, 'utf8')) + .trim() + .split('\n') + .map((line) => JSON.parse(line) as CommandLogRecord); + const actionStart = records.find( + (record) => record.event === 'start' && record.command === 'initial-load', + ); + expect(records).toContainEqual( + expect.objectContaining({ + actionId: actionStart?.id, + command: 'git', + event: 'finish', + status: 'canceled', + }), + ); + expect(records).toContainEqual( + expect.objectContaining({ + command: 'initial-load', + event: 'finish', + id: actionStart?.id, + status: 'canceled', + }), + ); +}); + +test('redacts inline headers, URL userinfo, arrays, and nested details', async () => { + const secrets = [ + 'header-secret', + 'inline-header-secret', + 'short-header-secret', + 'url-user-secret', + 'url-password-secret', + 'query-secret', + 'array-secret', + 'nested-secret', + ]; + const args = sanitizeArgs([ + 'api', + '--header', + 'Authorization: Bearer header-secret', + '--header=PRIVATE-TOKEN: inline-header-secret', + '-HAuthorization: Bearer short-header-secret', + 'https://url-user-secret:url-password-secret@provider.example.test/path?private_token=query-secret&ref=main', + ]); + expect(JSON.stringify(args)).not.toContain(secrets.join('|')); + for (const secret of secrets.slice(0, 6)) { + expect(JSON.stringify(args)).not.toContain(secret); + } + + const repository = await createCommandLogRepository(); + const path = configureCommandLog(repository); + const timing = startCommandTiming({ + command: 'security-shape', + details: { + array: ['safe', { token: 'array-secret' }], + nested: { authorization: 'nested-secret' }, + }, + }); + timing.finish(); + await flushCommandLog(); + const contents = await readFile(path, 'utf8'); + expect(contents).not.toContain('array-secret'); + expect(contents).not.toContain('nested-secret'); + expect(contents).toContain('[REDACTED]'); +}); + +test('command log initialization is idempotent', async () => { + const repository = await createCommandLogRepository(); + const path = configureCommandLog(repository); + const timing = startCommandTiming({ command: 'primary-sentinel' }); + timing.finish(); + await flushCommandLog(); + expect(configureCommandLog(repository)).toBe(path); + await flushCommandLog(); + const contents = await readFile(path, 'utf8'); + expect(contents.match(/"event":"session-start"/g)).toHaveLength(1); + expect(contents).toContain('primary-sentinel'); +}); + +test('creates command logs with owner-only permissions under a permissive umask', async () => { + if (process.platform === 'win32') { + return; + } + const repository = await createCommandLogRepository(); + await chmod(repository, 0o777); + const previousUmask = process.umask(0); + let path = ''; + try { + path = configureCommandLog(repository); + await flushCommandLog(); + } finally { + process.umask(previousUmask); + } + expect((await stat(repository)).mode & 0o077).toBe(0); + expect((await stat(path)).mode & 0o077).toBe(0); +}); diff --git a/electron/__tests__/definition-search.test.ts b/electron/__tests__/definition-search.test.ts index 4eb0ca48..d0deeda7 100644 --- a/electron/__tests__/definition-search.test.ts +++ b/electron/__tests__/definition-search.test.ts @@ -86,7 +86,7 @@ test('marks historical snapshot candidates as unsafe for editor fallback', async const result = await findDefinitions(directory.path, { ...request, kind: 'commit', - source: { ref: 'HEAD', type: 'commit' }, + source: { sha: 'HEAD' as import('../../core/types.ts').GitSha, type: 'commit' }, }); expect(result.status).toBe('ready'); diff --git a/electron/__tests__/diff-content-cancellation.test.ts b/electron/__tests__/diff-content-cancellation.test.ts new file mode 100644 index 00000000..76c8e699 --- /dev/null +++ b/electron/__tests__/diff-content-cancellation.test.ts @@ -0,0 +1,56 @@ +import { chmod, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { expect, test } from 'vite-plus/test'; + +const require = createRequire(import.meta.url); +const { git, runWithCommandSignal } = require('../git-state/common.cjs') as { + git: (repoPath: string, args: ReadonlyArray) => Promise; + runWithCommandSignal: (signal: AbortSignal, callback: () => Value) => Value; +}; + +const waitForFile = async (path: string) => { + for (let index = 0; index < 100; index += 1) { + try { + return await readFile(path, 'utf8'); + } catch { + await new Promise((resolve) => setTimeout(resolve, 10)); + } + } + throw new Error(`Timed out waiting for ${path}.`); +}; + +test('diff-content cancellation terminates the underlying Git command', async () => { + const directory = await mkdtemp(join(tmpdir(), 'codiff-diff-cancel-')); + const command = join(directory, 'git'); + const pidPath = join(directory, 'pid'); + const signalPath = join(directory, 'signal'); + const previousPath = process.env.PATH; + try { + await writeFile( + command, + `#!/usr/bin/env node +const { writeFileSync } = require('node:fs'); +writeFileSync(${JSON.stringify(pidPath)}, String(process.pid)); +process.on('SIGTERM', () => { + writeFileSync(${JSON.stringify(signalPath)}, 'SIGTERM'); + process.exit(0); +}); +setInterval(() => {}, 1000); +`, + ); + await chmod(command, 0o755); + process.env.PATH = `${directory}:${previousPath ?? ''}`; + const controller = new AbortController(); + const pending = runWithCommandSignal(controller.signal, () => git(directory, ['status'])); + await waitForFile(pidPath); + controller.abort(); + + await expect(pending).rejects.toMatchObject({ name: 'AbortError' }); + await expect(waitForFile(signalPath)).resolves.toBe('SIGTERM'); + } finally { + process.env.PATH = previousPath; + await rm(directory, { force: true, recursive: true }); + } +}); diff --git a/electron/__tests__/forge-package.test.ts b/electron/__tests__/forge-package.test.ts new file mode 100644 index 00000000..2d068dbb --- /dev/null +++ b/electron/__tests__/forge-package.test.ts @@ -0,0 +1,59 @@ +import { access, mkdtemp, rm } from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { expect, test } from 'vite-plus/test'; + +const require = createRequire(import.meta.url); +const forge = require('../../forge.config.cjs') as { + hooks: { + packageAfterCopy: (_config: unknown, buildPath: string) => Promise; + }; + packagerConfig: { + ignore: ReadonlyArray; + }; +}; + +const isIgnored = (path: string) => + forge.packagerConfig.ignore.some((pattern) => pattern.test(path)); + +test('Forge excludes development, eval, scenario, and test inputs', () => { + for (const path of [ + '/.jj/repo/store', + '/AGENTS.md', + '/CONTRIBUTING.md', + '/core/src.ts', + '/electron-squirrel-startup.d.ts', + '/electron/__tests__/command-log.test.ts', + '/evals/fixtures/test-scenario-provider-mocks/current/github.json', + '/github/src/index.ts', + '/gitlab/src/index.ts', + '/scripts/test-scenarios.mjs', + '/service/api.ts', + '/test/sharing.integration.ts', + '/test-scenarios/shared/patches/000-base.diff', + '/vitest.cloudflare.config.ts', + '/web/src/index.tsx', + ]) { + expect(isIgnored(path), path).toBe(true); + } + expect(isIgnored('/electron/main.cjs')).toBe(false); + expect(isIgnored('/dist/index.html')).toBe(false); +}); + +test('Forge restores built runtime artifacts after copy', async () => { + const directory = await mkdtemp(join(tmpdir(), 'codiff-forge-runtime-')); + try { + await forge.hooks.packageAfterCopy({}, directory); + for (const path of [ + 'core/dist/walkthrough-generation.mjs', + 'core/lib/narrative-walkthrough-diff.cjs', + 'github/dist/index.mjs', + 'gitlab/dist/index.mjs', + ]) { + await expect(access(join(directory, path))).resolves.toBeUndefined(); + } + } finally { + await rm(directory, { force: true, recursive: true }); + } +}); diff --git a/electron/__tests__/gh-github-transport.test.ts b/electron/__tests__/gh-github-transport.test.ts new file mode 100644 index 00000000..459092bb --- /dev/null +++ b/electron/__tests__/gh-github-transport.test.ts @@ -0,0 +1,328 @@ +import { chmod, mkdtemp, readFile, symlink, writeFile } from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, expect, test } from 'vite-plus/test'; + +const require = createRequire(import.meta.url); +const { createGhGitHubTransport } = + require('../git-state/github-history/gh-github-transport.cjs') as { + createGhGitHubTransport: (options: { repoRoot: string }) => { + graphql: (request: { + maxBytes?: number; + query: string; + variables: Record; + }) => Promise; + request: (request: { + maxBytes?: number; + method?: string; + paginate?: boolean; + path: string; + query?: Record; + }) => Promise; + requestBuffer: (request: { + accept?: string; + maxBytes?: number; + path: string; + query?: Record; + signal?: AbortSignal; + }) => Promise; + requestText: (request: { + accept?: string; + maxBytes?: number; + paginate?: boolean; + path: string; + query?: Record; + }) => Promise; + }; + }; +const { configureCommandLog, disableCommandLog, flushCommandLog, startCommandAction } = + require('../command-log.cjs') as { + configureCommandLog: (logsDirectory: string) => string; + disableCommandLog: () => void; + flushCommandLog: () => Promise; + startCommandAction: (input: { command: string }) => { + cancel: () => void; + run: (callback: () => Value) => Value; + signal: AbortSignal; + }; + }; + +const previousGhPath = process.env.CODIFF_GH_PATH; +const previousCallsPath = process.env.CODIFF_GH_TEST_CALLS; + +afterEach(async () => { + await flushCommandLog(); + disableCommandLog(); + if (previousGhPath == null) delete process.env.CODIFF_GH_PATH; + else process.env.CODIFF_GH_PATH = previousGhPath; + if (previousCallsPath == null) delete process.env.CODIFF_GH_TEST_CALLS; + else process.env.CODIFF_GH_TEST_CALLS = previousCallsPath; +}); + +test('createGhGitHubTransport terminates oversized JSON, text, binary, paginated, and GraphQL responses', async () => { + const directory = await mkdtemp(join(tmpdir(), 'codiff-gh-transport-')); + const fakeGhPath = join(directory, 'gh'); + const callsPath = join(directory, 'calls.txt'); + await writeFile( + fakeGhPath, + `#!/usr/bin/env node +const fs = require('node:fs'); +fs.appendFileSync(process.env.CODIFF_GH_TEST_CALLS, 'call\\n'); +process.on('SIGTERM', () => { + fs.appendFileSync(process.env.CODIFF_GH_TEST_CALLS, 'term\\n'); + process.exit(0); +}); +const chunk = Buffer.alloc(16 * 1024, 120); +const write = () => { + if (!process.stdout.write(chunk)) { + process.stdout.once('drain', write); + return; + } + setImmediate(write); +}; +write(); +`, + 'utf8', + ); + await chmod(fakeGhPath, 0o755); + process.env.CODIFF_GH_PATH = fakeGhPath; + process.env.CODIFF_GH_TEST_CALLS = callsPath; + + const transport = createGhGitHubTransport({ repoRoot: directory }); + const results = await Promise.allSettled([ + transport.request({ maxBytes: 8, path: '/repos/nkzw-tech/codiff/pulls/1' }), + transport.requestText({ + maxBytes: 8, + path: '/repos/nkzw-tech/codiff/readme', + }), + transport.requestBuffer({ + maxBytes: 8, + path: '/repos/nkzw-tech/codiff/git/blobs/deadbeef', + }), + transport.request({ + maxBytes: 8, + paginate: true, + path: '/repos/nkzw-tech/codiff/pulls/1/comments', + }), + transport.graphql({ + maxBytes: 8, + query: 'query { viewer { login } }', + variables: {}, + }), + ]); + + expect(results).toHaveLength(5); + for (const result of results) { + expect(result).toMatchObject({ + reason: expect.objectContaining({ + message: 'gh api response exceeded the 8-byte safety limit.', + name: 'ProviderOutputLimitError', + }), + status: 'rejected', + }); + } + const events = (await readFile(callsPath, 'utf8')).trim().split('\n'); + expect(events.filter((event) => event === 'call')).toHaveLength(5); + expect(events.filter((event) => event === 'term')).toHaveLength(5); +}); + +test('createGhGitHubTransport enforces each response bound on one shared GET', async () => { + const directory = await mkdtemp(join(tmpdir(), 'codiff-gh-transport-')); + const fakeGhPath = join(directory, 'gh'); + const callsPath = join(directory, 'calls.txt'); + await writeFile( + fakeGhPath, + `#!/usr/bin/env node +const fs = require('node:fs'); +fs.appendFileSync(process.env.CODIFF_GH_TEST_CALLS, 'call\\n'); +setTimeout(() => process.stdout.write('[{"id":1}]'), 25); +`, + 'utf8', + ); + await chmod(fakeGhPath, 0o755); + process.env.CODIFF_GH_PATH = fakeGhPath; + process.env.CODIFF_GH_TEST_CALLS = callsPath; + + const first = createGhGitHubTransport({ repoRoot: directory }); + const second = createGhGitHubTransport({ repoRoot: directory }); + const path = '/repos/nkzw-tech/codiff/pulls/1/comments'; + const [smallBound, largeBound] = await Promise.allSettled([ + first.request({ maxBytes: 2, paginate: true, path }), + second.request({ maxBytes: 1024, paginate: true, path }), + ]); + + expect(smallBound).toMatchObject({ + reason: expect.objectContaining({ name: 'ProviderOutputLimitError' }), + status: 'rejected', + }); + expect(largeBound).toEqual({ status: 'fulfilled', value: [{ id: 1 }] }); + expect((await readFile(callsPath, 'utf8')).trim()).toBe('call'); +}); + +test('createGhGitHubTransport shares paginated GETs across aliases, page sizes, and response bounds', async () => { + const directory = await mkdtemp(join(tmpdir(), 'codiff-gh-transport-')); + const alias = `${directory}-alias`; + const fakeGhPath = join(directory, 'gh'); + const callsPath = join(directory, 'calls.txt'); + await symlink(directory, alias); + await writeFile( + fakeGhPath, + `#!/usr/bin/env node +const fs = require('node:fs'); +fs.appendFileSync(process.env.CODIFF_GH_TEST_CALLS, 'call\\n'); +setTimeout(() => process.stdout.write('[]'), 25); +`, + 'utf8', + ); + await chmod(fakeGhPath, 0o755); + process.env.CODIFF_GH_PATH = fakeGhPath; + process.env.CODIFF_GH_TEST_CALLS = callsPath; + + const first = createGhGitHubTransport({ repoRoot: directory }); + const second = createGhGitHubTransport({ repoRoot: alias }); + const path = '/repos/nkzw-tech/codiff/pulls/7/comments'; + await expect( + Promise.all([ + first.request({ maxBytes: 8 * 1024 * 1024, paginate: true, path, query: { per_page: 100 } }), + second.request({ maxBytes: 2 * 1024 * 1024, paginate: true, path, query: { per_page: 50 } }), + ]), + ).resolves.toEqual([[], []]); + expect((await readFile(callsPath, 'utf8')).trim().split('\n')).toHaveLength(1); +}); + +test('createGhGitHubTransport preserves exact binary response bytes', async () => { + const directory = await mkdtemp(join(tmpdir(), 'codiff-gh-transport-')); + const fakeGhPath = join(directory, 'gh'); + await writeFile( + fakeGhPath, + `#!/usr/bin/env node +process.stdout.write(Buffer.from([0, 255, 10, 128])); +`, + 'utf8', + ); + await chmod(fakeGhPath, 0o755); + process.env.CODIFF_GH_PATH = fakeGhPath; + + const transport = createGhGitHubTransport({ repoRoot: directory }); + const bytes = await transport.requestBuffer({ + path: '/repos/nkzw-tech/codiff/git/blobs/deadbeef', + }); + + expect([...bytes]).toEqual([0, 255, 10, 128]); +}); + +test('createGhGitHubTransport flattens all paginated response documents', async () => { + const directory = await mkdtemp(join(tmpdir(), 'codiff-gh-transport-')); + const fakeGhPath = join(directory, 'gh'); + const callsPath = join(directory, 'calls.txt'); + await writeFile( + fakeGhPath, + `#!/usr/bin/env node +const fs = require('node:fs'); +fs.appendFileSync(process.env.CODIFF_GH_TEST_CALLS, 'call\\n'); +process.stdout.write('[\\n {"id": 1, "label": "nested } and \\\\"quoted\\\\" text"}\\n][{"id": 2}]'); +`, + 'utf8', + ); + await chmod(fakeGhPath, 0o755); + process.env.CODIFF_GH_PATH = fakeGhPath; + process.env.CODIFF_GH_TEST_CALLS = callsPath; + + const first = createGhGitHubTransport({ repoRoot: directory }); + const second = createGhGitHubTransport({ repoRoot: directory }); + const path = '/repos/nkzw-tech/codiff/issues'; + const pages = await Promise.all([ + first.request({ paginate: true, path }), + second.request({ paginate: true, path }), + ]); + + expect(pages).toEqual([ + [{ id: 1, label: 'nested } and "quoted" text' }, { id: 2 }], + [{ id: 1, label: 'nested } and "quoted" text' }, { id: 2 }], + ]); + expect((await readFile(callsPath, 'utf8')).trim()).toBe('call'); +}); + +test('createGhGitHubTransport inherits cancellation from its enclosing action', async () => { + const directory = await mkdtemp(join(tmpdir(), 'codiff-gh-transport-')); + const fakeGhPath = join(directory, 'gh'); + await writeFile( + fakeGhPath, + `#!/usr/bin/env node +setInterval(() => {}, 1_000); +`, + 'utf8', + ); + await chmod(fakeGhPath, 0o755); + process.env.CODIFF_GH_PATH = fakeGhPath; + const commandLogPath = configureCommandLog(directory); + + const transport = createGhGitHubTransport({ repoRoot: directory }); + const action = startCommandAction({ command: 'initial-load' }); + const pending = action.run(() => + transport.requestBuffer({ + path: '/repos/nkzw-tech/codiff/git/blobs/deadbeef', + }), + ); + setTimeout(() => action.cancel(), 25); + + await expect(pending).rejects.toMatchObject({ name: 'AbortError' }); + expect(action.signal.aborted).toBe(true); + await flushCommandLog(); + const commandLog = (await readFile(commandLogPath, 'utf8')) + .trim() + .split('\n') + .map((line) => JSON.parse(line) as Record); + const actionStart = commandLog.find( + (record) => record.event === 'start' && record.command === 'initial-load', + ); + const finishes = commandLog.filter( + (record) => record.event === 'finish' && record.command === fakeGhPath, + ); + expect(finishes).toEqual([ + expect.objectContaining({ + actionId: actionStart?.id, + canceled: true, + errorName: 'AbortError', + status: 'canceled', + }), + ]); +}); + +test('createGhGitHubTransport records cancellation before spawning a provider process', async () => { + const directory = await mkdtemp(join(tmpdir(), 'codiff-gh-transport-')); + const fakeGhPath = join(directory, 'gh'); + await writeFile(fakeGhPath, '#!/usr/bin/env node\n', 'utf8'); + await chmod(fakeGhPath, 0o755); + process.env.CODIFF_GH_PATH = fakeGhPath; + const commandLogPath = configureCommandLog(directory); + const controller = new AbortController(); + controller.abort(); + + const transport = createGhGitHubTransport({ repoRoot: directory }); + await expect( + transport.requestBuffer({ + path: '/repos/nkzw-tech/codiff/git/blobs/deadbeef', + signal: controller.signal, + }), + ).rejects.toMatchObject({ name: 'AbortError' }); + await flushCommandLog(); + + const commandLog = (await readFile(commandLogPath, 'utf8')) + .trim() + .split('\n') + .map((line) => JSON.parse(line) as Record); + expect( + commandLog.filter((record) => record.event === 'start' && record.command === fakeGhPath), + ).toHaveLength(1); + expect(commandLog).toContainEqual( + expect.objectContaining({ + canceled: true, + command: fakeGhPath, + errorName: 'AbortError', + event: 'finish', + status: 'canceled', + }), + ); +}); diff --git a/electron/__tests__/git-identity.test.ts b/electron/__tests__/git-identity.test.ts index 6c839e0e..89746a49 100644 --- a/electron/__tests__/git-identity.test.ts +++ b/electron/__tests__/git-identity.test.ts @@ -1,7 +1,7 @@ import { execFile } from 'node:child_process'; -import { writeFile } from 'node:fs/promises'; +import { chmod, mkdir, readFile, writeFile } from 'node:fs/promises'; import { createRequire } from 'node:module'; -import { join } from 'node:path'; +import { delimiter, join } from 'node:path'; import { promisify } from 'node:util'; import { expect, test } from 'vite-plus/test'; import { @@ -13,6 +13,13 @@ const require = createRequire(import.meta.url); const { readGitIdentity } = require('../git-state/working-tree.cjs') as { readGitIdentity: (path: string) => Promise<{ email: string; name: string }>; }; +const { startCommandAction } = require('../command-log.cjs') as { + startCommandAction: (input: { command: string }) => { + cancel: () => void; + run: (callback: () => Value) => Value; + signal: AbortSignal; + }; +}; const execFileAsync = promisify(execFile); const git = async (repo: string, args: ReadonlyArray) => { @@ -47,7 +54,7 @@ test('uses configured git identity without inferring it from the current commit email: '', name: '', }); -}); +}, 30_000); test('reads the global git identity outside a repository', async () => { await using directory = await createTemporaryDirectory('codiff-global-git-identity-'); @@ -60,3 +67,56 @@ test('reads the global git identity outside a repository', async () => { name: 'Global User', }); }); + +test.sequential('single-flights concurrent Git identity reads without retaining stale values', async () => { + await using directory = await createTemporaryDirectory('codiff-git-identity-single-flight-'); + const fakeBin = join(directory.path, 'bin'); + const fakeGit = join(fakeBin, 'git'); + const callsPath = join(directory.path, 'calls.txt'); + await mkdir(fakeBin); + await writeFile( + fakeGit, + `#!/usr/bin/env node +const fs = require('node:fs'); +const key = process.argv.at(-1); +fs.appendFileSync(process.env.CODIFF_GIT_IDENTITY_CALLS, key + '\\n'); +setTimeout(() => process.stdout.write(key === 'user.name' ? 'Codiff User\\n' : 'codiff@example.com\\n'), 150); +`, + 'utf8', + ); + await chmod(fakeGit, 0o755); + await using _environment = createTemporaryEnvironment({ + CODIFF_GIT_IDENTITY_CALLS: callsPath, + PATH: `${fakeBin}${delimiter}${process.env.PATH ?? ''}`, + }); + + await expect( + Promise.all([readGitIdentity(directory.path), readGitIdentity(directory.path)]), + ).resolves.toEqual([ + expect.objectContaining({ email: 'codiff@example.com', name: 'Codiff User' }), + expect.objectContaining({ email: 'codiff@example.com', name: 'Codiff User' }), + ]); + expect((await readFile(callsPath, 'utf8')).trim().split('\n').sort()).toEqual([ + 'user.email', + 'user.name', + ]); + + await expect(readGitIdentity(directory.path)).resolves.toMatchObject({ + email: 'codiff@example.com', + name: 'Codiff User', + }); + expect((await readFile(callsPath, 'utf8')).trim().split('\n')).toHaveLength(4); + + const canceledAction = startCommandAction({ command: 'initial-load' }); + const activeAction = startCommandAction({ command: 'initial-load' }); + const canceledRead = canceledAction.run(() => readGitIdentity(directory.path)); + const activeRead = activeAction.run(() => readGitIdentity(directory.path)); + setTimeout(() => canceledAction.cancel(), 25); + + await expect(canceledRead).rejects.toMatchObject({ name: 'AbortError' }); + expect(canceledAction.signal.aborted).toBe(true); + await expect(activeRead).resolves.toMatchObject({ + email: 'codiff@example.com', + name: 'Codiff User', + }); +}); diff --git a/electron/__tests__/github-review-mutations.test.ts b/electron/__tests__/github-review-mutations.test.ts new file mode 100644 index 00000000..6759ed2f --- /dev/null +++ b/electron/__tests__/github-review-mutations.test.ts @@ -0,0 +1,206 @@ +import { createRequire } from 'node:module'; +import { expect, test, vi } from 'vite-plus/test'; +import type { + GitSha, + SubmitPullRequestCommentRequest, + SubmitPullRequestReviewRequest, +} from '../../core/types.ts'; +import { createTemporaryGitRepository } from './helpers/git-repository.ts'; + +const require = createRequire(import.meta.url); +const { createGitHubReviewMutations } = require('../git-state/github-review-mutations.cjs') as { + createGitHubReviewMutations: (dependencies: Record) => { + submitPullRequestComment: ( + launchPath: string, + request: SubmitPullRequestCommentRequest, + ) => Promise; + submitPullRequestReview: ( + launchPath: string, + request: SubmitPullRequestReviewRequest, + ) => Promise; + }; +}; + +const baseSha = 'a'.repeat(40) as GitSha; +const headSha = 'b'.repeat(40) as GitSha; +const source = { + headSha, + provider: 'github' as const, + type: 'pull-request' as const, + url: 'https://github.com/nkzw-tech/codiff/pull/12', +}; +const comment = { + body: 'Please keep this explicit.', + filePath: 'src/app.ts', + lineNumber: 7, + localDraftId: 'draft-1', + position: { + range: { + base: { label: { kind: 'commit' as const, text: 'aaaaaaa' }, sha: baseSha }, + head: { label: { kind: 'commit' as const, text: 'bbbbbbb' }, sha: headSha }, + }, + }, + side: 'additions' as const, +}; + +const createHarness = () => { + const request = vi.fn(async () => ({})); + const mutations = createGitHubReviewMutations({ + assertPullRequestMatchesRepository: async () => undefined, + createTransport: () => ({ request }), + normalizeGitHubReviewComment: (value: Record) => + value.id + ? { + author: { login: 'reviewer' }, + body: String(value.body || ''), + filePath: String(value.path || 'src/app.ts'), + id: `github:${String(value.id)}`, + lineNumber: Number(value.line || 7), + side: 'additions', + threadId: String(value.in_reply_to_id || value.id), + } + : null, + parseGitHubPullRequestUrl: () => ({ + number: 12, + owner: 'nkzw-tech', + repo: 'codiff', + url: source.url, + }), + readCurrentTarget: async () => ({ + baseSha, + files: [{ newPath: 'src/app.ts', patch: '@@ -7 +7 @@\n-old\n+new\n' }], + headSha, + }), + }); + return { mutations, request }; +}; + +test('submits GitHub replies with provider thread identity only', async () => { + await using repository = await createTemporaryGitRepository('codiff-github-mutation-'); + const { mutations, request } = createHarness(); + request.mockResolvedValueOnce({ + body: 'Reply in the existing thread.', + id: 8, + in_reply_to_id: 7, + line: 7, + path: 'src/app.ts', + }); + + await expect( + mutations.submitPullRequestComment(repository.path, { + comment: { + body: 'Reply in the existing thread.', + filePath: 'src/app.ts', + localDraftId: 'reply-draft', + threadId: '7', + }, + source, + }), + ).resolves.toMatchObject({ id: 'github:8', threadId: '7' }); + expect(request).toHaveBeenCalledWith({ + body: { body: 'Reply in the existing thread.', in_reply_to: 7 }, + method: 'POST', + path: 'repos/nkzw-tech/codiff/pulls/12/comments', + }); +}); + +test('submits a review against the exact validated GitHub head', async () => { + await using repository = await createTemporaryGitRepository('codiff-github-mutation-'); + const { mutations, request } = createHarness(); + + await expect( + mutations.submitPullRequestReview(repository.path, { + comments: [comment], + event: 'COMMENT', + source, + }), + ).resolves.toEqual({ status: 'submitted', submittedDraftIds: ['draft-1'] }); + expect(request).toHaveBeenCalledWith({ + body: { + body: 'Review comments.', + comments: [ + { + body: comment.body, + line: 7, + path: 'src/app.ts', + side: 'RIGHT', + }, + ], + commit_id: headSha, + event: 'COMMENT', + }, + method: 'POST', + path: 'repos/nkzw-tech/codiff/pulls/12/reviews', + }); +}); + +test('rejects a stale GitHub range before mutating the provider', async () => { + await using repository = await createTemporaryGitRepository('codiff-github-mutation-'); + const { mutations, request } = createHarness(); + const stale = { + ...comment, + position: { + range: { + ...comment.position.range, + head: { ...comment.position.range.head, sha: 'c'.repeat(40) as GitSha }, + }, + }, + }; + + await expect( + mutations.submitPullRequestReview(repository.path, { + comments: [stale], + event: 'COMMENT', + source, + }), + ).resolves.toMatchObject({ + reason: expect.stringContaining('draft range no longer matches'), + status: 'failed', + submittedDraftIds: [], + }); + expect(request).not.toHaveBeenCalled(); +}); + +test('rejects a GitHub line absent from the fresh target diff', async () => { + await using repository = await createTemporaryGitRepository('codiff-github-mutation-'); + const { mutations, request } = createHarness(); + + await expect( + mutations.submitPullRequestReview(repository.path, { + comments: [{ ...comment, lineNumber: 99 }], + event: 'COMMENT', + source, + }), + ).resolves.toMatchObject({ + reason: expect.stringContaining('Line 99'), + status: 'failed', + submittedDraftIds: [], + }); + expect(request).not.toHaveBeenCalled(); +}); + +test.each(['APPROVE', 'REQUEST_CHANGES'] as const)( + 'batches GitHub inline comments and summary with the %s outcome', + async (event) => { + await using repository = await createTemporaryGitRepository('codiff-github-mutation-'); + const { mutations, request } = createHarness(); + + await expect( + mutations.submitPullRequestReview(repository.path, { + body: 'Outcome summary.', + comments: [comment], + event, + source, + }), + ).resolves.toEqual({ status: 'submitted', submittedDraftIds: ['draft-1'] }); + expect(request).toHaveBeenCalledWith({ + body: expect.objectContaining({ + body: 'Outcome summary.', + commit_id: headSha, + event, + }), + method: 'POST', + path: 'repos/nkzw-tech/codiff/pulls/12/reviews', + }); + }, +); diff --git a/electron/__tests__/gitlab-review-mutations.test.ts b/electron/__tests__/gitlab-review-mutations.test.ts new file mode 100644 index 00000000..b18f9a26 --- /dev/null +++ b/electron/__tests__/gitlab-review-mutations.test.ts @@ -0,0 +1,448 @@ +import { createRequire } from 'node:module'; +import { expect, test, vi } from 'vite-plus/test'; +import type { + GitSha, + SubmitPullRequestCommentRequest, + SubmitPullRequestReviewRequest, +} from '../../core/types.ts'; +import { createTemporaryGitRepository } from './helpers/git-repository.ts'; + +const require = createRequire(import.meta.url); +const { createGitLabReviewMutations } = require('../git-state/gitlab-review-mutations.cjs') as { + createGitLabReviewMutations: (dependencies: Record) => { + submitMergeRequestComment: ( + launchPath: string, + request: SubmitPullRequestCommentRequest, + ) => Promise; + submitMergeRequestReview: ( + launchPath: string, + request: SubmitPullRequestReviewRequest, + ) => Promise; + }; +}; + +const baseSha = 'a'.repeat(40) as GitSha; +const headSha = 'b'.repeat(40) as GitSha; +const source = { + headSha, + provider: 'gitlab' as const, + type: 'pull-request' as const, + url: 'https://gitlab.example.com/group/project/-/merge_requests/23', +}; +const comment = { + body: 'Keep this explicit.', + filePath: 'src/new.ts', + lineNumber: 12, + localDraftId: 'draft-1', + position: { + range: { + base: { label: { kind: 'commit' as const, text: 'aaaaaaa' }, sha: baseSha }, + head: { label: { kind: 'commit' as const, text: 'bbbbbbb' }, sha: headSha }, + }, + }, + side: 'additions' as const, +}; +const secondComment = { + ...comment, + body: 'Keep this second detail explicit.', + localDraftId: 'draft-2', +}; + +type MutationRequest = { body?: unknown; method?: 'DELETE' | 'GET' | 'POST'; path: string }; + +const createHarness = ({ + failRequest, + normalizeSubmittedGitLabReviewComment = () => null, +}: { + failRequest?: (request: MutationRequest, index: number) => Error | undefined; + normalizeSubmittedGitLabReviewComment?: ( + note: Record, + submittedComment: typeof comment, + url: string, + threadId?: string, + ) => unknown; +} = {}) => { + let nextRemoteDraftId = 1; + let requestIndex = 0; + const publishedDraftBatches: Array> = []; + const remoteDrafts = new Map(); + const request = vi.fn(async (nextRequest: MutationRequest) => { + requestIndex += 1; + const failure = failRequest?.(nextRequest, requestIndex); + if (failure) { + throw failure; + } + if (nextRequest.method === 'POST' && nextRequest.path.endsWith('/draft_notes')) { + const id = String(nextRemoteDraftId); + nextRemoteDraftId += 1; + const note = (nextRequest.body as { note?: unknown } | undefined)?.note; + remoteDrafts.set(id, typeof note === 'string' ? note : ''); + return { id }; + } + if (nextRequest.method === 'DELETE') { + const id = nextRequest.path.split('/').at(-1); + if (id) remoteDrafts.delete(decodeURIComponent(id)); + return {}; + } + if ( + nextRequest.method === 'POST' && + (nextRequest.path.endsWith('/draft_notes/bulk_publish') || + nextRequest.path.endsWith('/notes')) + ) { + publishedDraftBatches.push([...remoteDrafts.values()]); + remoteDrafts.clear(); + } + return {}; + }); + const readMergeRequestDiffs = vi.fn(async () => [ + { + diff: '@@ -10,2 +12,2 @@\n context\n-old\n+new\n', + new_path: 'src/new.ts', + old_path: 'src/old.ts', + }, + ]); + const readMergeRequestMetadata = vi.fn(async () => ({ + diff_refs: { base_sha: baseSha, head_sha: headSha, start_sha: baseSha }, + sha: headSha, + })); + const mutations = createGitLabReviewMutations({ + createTransport: () => ({ request }), + getDiscussionReplyEndpoint: () => '', + mergeRequestEndpoint: (_mergeRequest: unknown, suffix = '') => `merge-request${suffix}`, + normalizeSubmittedGitLabReviewComment, + parseGitLabMergeRequestUrl: () => ({ url: source.url }), + readMergeRequestDiffs, + readMergeRequestMetadata, + selectMergeRequestRemote: () => undefined, + }); + return { + mutations, + publishedDraftBatches, + readMergeRequestDiffs, + readMergeRequestMetadata, + remoteDrafts, + request, + }; +}; + +const expectNoNeutralQuickAction = ( + calls: ReadonlyArray]>, +) => { + expect(calls.some(([nextRequest]) => nextRequest.path.endsWith('/notes'))).toBe(false); + expect(JSON.stringify(calls)).not.toContain('/submit_review approve'); + expect(JSON.stringify(calls)).not.toContain('/submit_review request_changes'); +}; + +test('returns the GitLab discussion identity for a newly submitted comment', async () => { + await using repository = await createTemporaryGitRepository('codiff-gitlab-mutation-'); + const normalizeSubmittedGitLabReviewComment = vi.fn( + ( + note: Record, + submittedComment: typeof comment, + url: string, + threadId?: string, + ) => ({ + ...submittedComment, + author: { login: 'reviewer' }, + id: `gitlab:${String(note.id)}`, + threadId, + url, + }), + ); + const { mutations, request } = createHarness({ normalizeSubmittedGitLabReviewComment }); + request.mockResolvedValueOnce({ + id: 'discussion-42', + notes: [{ body: comment.body, id: 91 }], + }); + + await expect( + mutations.submitMergeRequestComment(repository.path, { comment, source }), + ).resolves.toMatchObject({ id: 'gitlab:91', threadId: 'discussion-42' }); + expect(normalizeSubmittedGitLabReviewComment).toHaveBeenCalledWith( + expect.objectContaining({ id: 91 }), + comment, + source.url, + 'discussion-42', + ); +}); + +test('bulk-publishes an inline-only neutral GitLab review', async () => { + await using repository = await createTemporaryGitRepository('codiff-gitlab-mutation-'); + const { mutations, request } = createHarness(); + + await expect( + mutations.submitMergeRequestReview(repository.path, { + comments: [comment], + event: 'COMMENT', + source, + }), + ).resolves.toEqual({ status: 'submitted', submittedDraftIds: ['draft-1'] }); + expect(request).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ method: 'POST', path: 'merge-request/draft_notes' }), + ); + expect(request).toHaveBeenNthCalledWith(2, { + body: { reviewer_state: 'reviewed' }, + method: 'POST', + path: 'merge-request/draft_notes/bulk_publish', + }); + expectNoNeutralQuickAction(request.mock.calls); +}); + +test('includes the optional summary in neutral GitLab publication', async () => { + await using repository = await createTemporaryGitRepository('codiff-gitlab-mutation-'); + const { mutations, request } = createHarness(); + + await expect( + mutations.submitMergeRequestReview(repository.path, { + body: 'Review summary.', + comments: [comment], + event: 'COMMENT', + source, + }), + ).resolves.toEqual({ status: 'submitted', submittedDraftIds: ['draft-1'] }); + expect(request).toHaveBeenLastCalledWith({ + body: { note: 'Review summary.', reviewer_state: 'reviewed' }, + method: 'POST', + path: 'merge-request/draft_notes/bulk_publish', + }); + expectNoNeutralQuickAction(request.mock.calls); +}); + +test('bulk-publishes a summary-only neutral GitLab review without a regular note', async () => { + await using repository = await createTemporaryGitRepository('codiff-gitlab-mutation-'); + const { mutations, request } = createHarness(); + + await expect( + mutations.submitMergeRequestReview(repository.path, { + body: 'Summary only.', + comments: [], + event: 'COMMENT', + source, + }), + ).resolves.toEqual({ status: 'submitted', submittedDraftIds: [] }); + expect(request).toHaveBeenCalledOnce(); + expect(request).toHaveBeenCalledWith({ + body: { note: 'Summary only.', reviewer_state: 'reviewed' }, + method: 'POST', + path: 'merge-request/draft_notes/bulk_publish', + }); + expectNoNeutralQuickAction(request.mock.calls); +}); + +test('rejects an empty neutral GitLab review before provider calls', async () => { + await using repository = await createTemporaryGitRepository('codiff-gitlab-mutation-'); + const { mutations, readMergeRequestDiffs, readMergeRequestMetadata, request } = createHarness(); + + await expect( + mutations.submitMergeRequestReview(repository.path, { + comments: [], + event: 'COMMENT', + source, + }), + ).resolves.toEqual({ + reason: 'A neutral review requires an inline comment or summary.', + status: 'failed', + submittedDraftIds: [], + }); + expect(readMergeRequestMetadata).not.toHaveBeenCalled(); + expect(readMergeRequestDiffs).not.toHaveBeenCalled(); + expect(request).not.toHaveBeenCalled(); +}); + +test('blocks retry when GitLab accepts a draft without returning its identity', async () => { + await using repository = await createTemporaryGitRepository('codiff-gitlab-mutation-'); + const { mutations, request } = createHarness(); + request.mockImplementation(async ({ method, path }) => { + if (method === 'POST' && path.endsWith('/draft_notes')) { + return {}; + } + return {}; + }); + + await expect( + mutations.submitMergeRequestReview(repository.path, { + comments: [comment], + event: 'COMMENT', + source, + }), + ).resolves.toEqual({ + outcomeUnknownDraftIds: ['draft-1'], + reason: 'GitLab accepted a draft note but did not return its ID.', + status: 'failed', + submittedDraftIds: [], + }); +}); + +test('removes accepted GitLab drafts when neutral publication fails', async () => { + await using repository = await createTemporaryGitRepository('codiff-gitlab-mutation-'); + const { mutations, remoteDrafts, request } = createHarness({ + failRequest: ({ path }) => + path.endsWith('/draft_notes/bulk_publish') + ? new Error('GitLab rejected bulk publication.') + : undefined, + }); + + await expect( + mutations.submitMergeRequestReview(repository.path, { + comments: [comment], + event: 'COMMENT', + source, + }), + ).resolves.toEqual({ + reason: 'GitLab rejected bulk publication.', + status: 'failed', + submittedDraftIds: [], + }); + expect(remoteDrafts.size).toBe(0); + expect(request).toHaveBeenCalledWith({ + method: 'DELETE', + path: 'merge-request/draft_notes/1', + }); +}); + +test('cleans partial GitLab drafts before retrying the review', async () => { + await using repository = await createTemporaryGitRepository('codiff-gitlab-mutation-'); + let draftRequests = 0; + const { mutations, publishedDraftBatches, remoteDrafts, request } = createHarness({ + failRequest: ({ method, path }) => { + if (method !== 'POST' || !path.endsWith('/draft_notes')) { + return undefined; + } + draftRequests += 1; + return draftRequests === 2 ? new Error('GitLab rejected the second draft.') : undefined; + }, + }); + const review = { + comments: [comment, secondComment], + event: 'COMMENT' as const, + source, + }; + + await expect(mutations.submitMergeRequestReview(repository.path, review)).resolves.toEqual({ + outcomeUnknownDraftIds: ['draft-2'], + reason: 'GitLab rejected the second draft.', + status: 'failed', + submittedDraftIds: [], + }); + expect(remoteDrafts.size).toBe(0); + expect(request).toHaveBeenCalledWith({ + method: 'DELETE', + path: 'merge-request/draft_notes/1', + }); + expect( + request.mock.calls.some(([nextRequest]) => + nextRequest.path.endsWith('/draft_notes/bulk_publish'), + ), + ).toBe(false); + + await expect(mutations.submitMergeRequestReview(repository.path, review)).resolves.toEqual({ + status: 'submitted', + submittedDraftIds: ['draft-1', 'draft-2'], + }); + expect(publishedDraftBatches).toEqual([ + ['Keep this explicit.', 'Keep this second detail explicit.'], + ]); + expect(remoteDrafts.size).toBe(0); +}); + +test('reports remote draft ownership when GitLab cleanup also fails', async () => { + await using repository = await createTemporaryGitRepository('codiff-gitlab-mutation-'); + const { mutations, remoteDrafts } = createHarness({ + failRequest: ({ method, path }) => { + if (path.endsWith('/draft_notes/bulk_publish')) { + return new Error('GitLab rejected bulk publication.'); + } + return method === 'DELETE' ? new Error('GitLab rejected draft cleanup.') : undefined; + }, + }); + + await expect( + mutations.submitMergeRequestReview(repository.path, { + comments: [comment], + event: 'COMMENT', + source, + }), + ).resolves.toEqual({ + outcomeUnknownDraftIds: ['draft-1'], + reason: + 'GitLab rejected bulk publication. GitLab draft cleanup also failed: GitLab rejected draft cleanup.', + status: 'failed', + submittedDraftIds: [], + }); + expect([...remoteDrafts.values()]).toEqual(['Keep this explicit.']); +}); + +test.each(['APPROVE', 'REQUEST_CHANGES'] as const)( + 'keeps GitLab draft IDs unconfirmed when the final %s outcome fails', + async (event) => { + await using repository = await createTemporaryGitRepository('codiff-gitlab-mutation-'); + const { mutations } = createHarness({ + failRequest: ({ path }) => + path.endsWith('/notes') ? new Error('GitLab rejected the final outcome.') : undefined, + }); + + await expect( + mutations.submitMergeRequestReview(repository.path, { + comments: [comment], + event, + source, + }), + ).resolves.toEqual({ + reason: 'GitLab rejected the final outcome.', + status: 'failed', + submittedDraftIds: [], + }); + }, +); + +test.each([ + ['APPROVE', '/submit_review approve'], + ['REQUEST_CHANGES', '/submit_review request_changes'], +] as const)( + 'confirms all GitLab draft IDs after the final %s outcome succeeds', + async (event, action) => { + await using repository = await createTemporaryGitRepository('codiff-gitlab-mutation-'); + const { mutations, request } = createHarness(); + + await expect( + mutations.submitMergeRequestReview(repository.path, { + body: 'Outcome summary.', + comments: [comment, secondComment], + event, + source, + }), + ).resolves.toEqual({ status: 'submitted', submittedDraftIds: ['draft-1', 'draft-2'] }); + expect(request).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ method: 'POST', path: 'merge-request/draft_notes' }), + ); + expect(request).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ method: 'POST', path: 'merge-request/draft_notes' }), + ); + expect(request).toHaveBeenNthCalledWith(3, { + body: { body: `Outcome summary.\n\n${action}` }, + method: 'POST', + path: 'merge-request/notes', + }); + }, +); + +test('validates every GitLab draft before posting the first one', async () => { + await using repository = await createTemporaryGitRepository('codiff-gitlab-mutation-'); + const { mutations, request } = createHarness(); + + await expect( + mutations.submitMergeRequestReview(repository.path, { + comments: [comment, { ...comment, lineNumber: 99, localDraftId: 'draft-2' }], + event: 'REQUEST_CHANGES', + source, + }), + ).resolves.toMatchObject({ + reason: expect.stringContaining('Line 99'), + status: 'failed', + submittedDraftIds: [], + }); + expect(request).not.toHaveBeenCalled(); +}); diff --git a/electron/__tests__/glab-gitlab-transport.test.ts b/electron/__tests__/glab-gitlab-transport.test.ts new file mode 100644 index 00000000..cba5b9cf --- /dev/null +++ b/electron/__tests__/glab-gitlab-transport.test.ts @@ -0,0 +1,484 @@ +import { chmod, mkdtemp, readFile, writeFile } from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, expect, test } from 'vite-plus/test'; + +const require = createRequire(import.meta.url); +const { configureCommandLog, disableCommandLog, flushCommandLog, startCommandAction } = + require('../command-log.cjs') as { + configureCommandLog: (logsDirectory: string) => string; + disableCommandLog: () => void; + flushCommandLog: () => Promise; + startCommandAction: (input: { command: string }) => { + cancel: () => void; + run: (callback: () => Value) => Value; + signal: AbortSignal; + }; + }; +const { createGlabGitLabTransport } = require('../git-state/glab-gitlab-transport.cjs') as { + createGlabGitLabTransport: (options: { hostname: string; repoRoot: string }) => { + request: (request: { + maxBytes?: number; + method?: string; + path: string; + query?: Record; + body?: unknown; + }) => Promise; + requestPages: (request: { + maxBytes?: number; + path: string; + query?: Record; + }) => Promise>; + requestBuffer: (request: { + maxBytes?: number; + path: string; + query?: Record; + signal?: AbortSignal; + }) => Promise; + requestText: (request: { + maxBytes?: number; + path: string; + query?: Record; + }) => Promise; + }; +}; + +const previousGlabPath = process.env.CODIFF_GLAB_PATH; +const previousCallsPath = process.env.CODIFF_GLAB_TEST_CALLS; + +afterEach(async () => { + await flushCommandLog(); + disableCommandLog(); + if (previousGlabPath == null) { + delete process.env.CODIFF_GLAB_PATH; + } else { + process.env.CODIFF_GLAB_PATH = previousGlabPath; + } + if (previousCallsPath == null) { + delete process.env.CODIFF_GLAB_TEST_CALLS; + } else { + process.env.CODIFF_GLAB_TEST_CALLS = previousCallsPath; + } +}); + +test('createGlabGitLabTransport performs glab api requests through the injected executable', async () => { + const directory = await mkdtemp(join(tmpdir(), 'codiff-glab-transport-')); + const fakeGlabPath = join(directory, 'glab'); + const callsPath = join(directory, 'calls.jsonl'); + await writeFile( + fakeGlabPath, + `#!/usr/bin/env node +const fs = require('node:fs'); +const callsPath = process.env.CODIFF_GLAB_TEST_CALLS; +const args = process.argv.slice(2); +let input = ''; +process.stdin.setEncoding('utf8'); +process.stdin.on('data', (chunk) => { input += chunk; }); +process.stdin.on('end', () => { + fs.appendFileSync(callsPath, JSON.stringify({ args, input }) + '\\n'); + if (args.includes('/projects/group%2Fproject/merge_requests/7/versions')) { + process.stdout.write(JSON.stringify([ + { + id: 1, + head_commit_sha: 'b'.repeat(40), + base_commit_sha: 'a'.repeat(40), + start_commit_sha: 'a'.repeat(40), + created_at: '2026-01-01T00:00:00.000Z', + }, + ])); + } else { + process.stdout.write('{}'); + } + process.exit(0); +}); +`, + 'utf8', + ); + await chmod(fakeGlabPath, 0o755); + process.env.CODIFF_GLAB_PATH = fakeGlabPath; + process.env.CODIFF_GLAB_TEST_CALLS = callsPath; + const commandLogPath = configureCommandLog(directory); + + const transport = createGlabGitLabTransport({ + hostname: 'gitlab.example.com', + repoRoot: directory, + }); + const versions = await transport.request>({ + path: '/api/v4/projects/group%2Fproject/merge_requests/7/versions', + }); + expect(versions).toEqual([expect.objectContaining({ id: 1 })]); + const calls = JSON.parse((await readFile(callsPath, 'utf8')).trim()) as { args: Array }; + expect(calls.args).toContain('/projects/group%2Fproject/merge_requests/7/versions'); + expect(calls.args).not.toContain('/api/v4/projects/group%2Fproject/merge_requests/7/versions'); + await flushCommandLog(); + const commandLog = (await readFile(commandLogPath, 'utf8')) + .trim() + .split('\n') + .map((line) => JSON.parse(line) as Record); + const start = commandLog.find( + (record) => record.event === 'start' && record.command === fakeGlabPath, + ); + expect(start).toEqual( + expect.objectContaining({ + args: calls.args, + kind: 'command', + }), + ); + expect(commandLog).toContainEqual( + expect.objectContaining({ + command: fakeGlabPath, + durationMs: expect.any(Number), + event: 'finish', + exitCode: 0, + id: start?.id, + status: 'ok', + }), + ); +}); + +test('createGlabGitLabTransport drains oversized JSON, text, binary, and paginated responses', async () => { + const directory = await mkdtemp(join(tmpdir(), 'codiff-glab-transport-')); + const fakeGlabPath = join(directory, 'glab'); + const callsPath = join(directory, 'calls.txt'); + await writeFile( + fakeGlabPath, + `#!/usr/bin/env node +const fs = require('node:fs'); +const resource = process.argv.at(-1); +const chunk = Buffer.alloc(16 * 1024, 120); +let remaining = 16; +const write = () => { + while (remaining > 0) { + remaining -= 1; + if (!process.stdout.write(chunk)) { + process.stdout.once('drain', write); + return; + } + } + fs.appendFileSync(process.env.CODIFF_GLAB_TEST_CALLS, resource + '\\n'); +}; +write(); +`, + 'utf8', + ); + await chmod(fakeGlabPath, 0o755); + process.env.CODIFF_GLAB_PATH = fakeGlabPath; + process.env.CODIFF_GLAB_TEST_CALLS = callsPath; + + const transport = createGlabGitLabTransport({ + hostname: 'gitlab.example.com', + repoRoot: directory, + }); + const results = await Promise.allSettled([ + transport.request({ maxBytes: 8, path: '/projects/group%2Fproject/merge_requests/1' }), + transport.requestText({ + maxBytes: 8, + path: '/projects/group%2Fproject/repository/files/readme/raw', + }), + transport.requestBuffer({ + maxBytes: 8, + path: '/projects/group%2Fproject/repository/blobs/deadbeef/raw', + }), + transport.requestPages({ + maxBytes: 8, + path: '/projects/group%2Fproject/merge_requests/1/discussions', + }), + ]); + + expect(results).toHaveLength(4); + for (const result of results) { + expect(result).toMatchObject({ + reason: expect.objectContaining({ + message: 'glab api response exceeded the 8-byte safety limit.', + name: 'ProviderOutputLimitError', + }), + status: 'rejected', + }); + } + expect((await readFile(callsPath, 'utf8')).trim().split('\n')).toHaveLength(4); +}); + +test('createGlabGitLabTransport bounds and drains responses without an explicit limit', async () => { + const directory = await mkdtemp(join(tmpdir(), 'codiff-glab-transport-')); + const fakeGlabPath = join(directory, 'glab'); + const callsPath = join(directory, 'calls.txt'); + await writeFile( + fakeGlabPath, + `#!/usr/bin/env node +const fs = require('node:fs'); +const chunk = Buffer.alloc(1024 * 1024, 120); +let remaining = 9; +const write = () => { + while (remaining > 0) { + remaining -= 1; + if (!process.stdout.write(chunk)) { + process.stdout.once('drain', write); + return; + } + } + fs.appendFileSync(process.env.CODIFF_GLAB_TEST_CALLS, 'drained\\n'); +}; +write(); +`, + 'utf8', + ); + await chmod(fakeGlabPath, 0o755); + process.env.CODIFF_GLAB_PATH = fakeGlabPath; + process.env.CODIFF_GLAB_TEST_CALLS = callsPath; + + const transport = createGlabGitLabTransport({ + hostname: 'gitlab.example.com', + repoRoot: directory, + }); + + await expect( + transport.request({ path: '/projects/group%2Fproject/merge_requests/1' }), + ).rejects.toMatchObject({ + message: 'glab api response exceeded the 8388608-byte safety limit.', + name: 'ProviderOutputLimitError', + }); + expect((await readFile(callsPath, 'utf8')).trim()).toBe('drained'); +}); + +test('createGlabGitLabTransport enforces each response bound on one shared GET', async () => { + const directory = await mkdtemp(join(tmpdir(), 'codiff-glab-transport-')); + const fakeGlabPath = join(directory, 'glab'); + const callsPath = join(directory, 'calls.txt'); + await writeFile( + fakeGlabPath, + `#!/usr/bin/env node +const fs = require('node:fs'); +fs.appendFileSync(process.env.CODIFF_GLAB_TEST_CALLS, 'call\\n'); +setTimeout(() => process.stdout.write('[{"id":1}]'), 25); +`, + 'utf8', + ); + await chmod(fakeGlabPath, 0o755); + process.env.CODIFF_GLAB_PATH = fakeGlabPath; + process.env.CODIFF_GLAB_TEST_CALLS = callsPath; + + const first = createGlabGitLabTransport({ + hostname: 'gitlab.example.com', + repoRoot: directory, + }); + const second = createGlabGitLabTransport({ + hostname: 'gitlab.example.com', + repoRoot: directory, + }); + const path = '/projects/group%2Fproject/merge_requests/1/discussions'; + const [smallBound, largeBound] = await Promise.allSettled([ + first.requestPages({ maxBytes: 2, path }), + second.requestPages({ maxBytes: 1024, path }), + ]); + + expect(smallBound).toMatchObject({ + reason: expect.objectContaining({ name: 'ProviderOutputLimitError' }), + status: 'rejected', + }); + expect(largeBound).toEqual({ status: 'fulfilled', value: [{ id: 1 }] }); + expect((await readFile(callsPath, 'utf8')).trim()).toBe('call'); +}); + +test('createGlabGitLabTransport keeps mutation resources last', async () => { + const directory = await mkdtemp(join(tmpdir(), 'codiff-glab-transport-')); + const fakeGlabPath = join(directory, 'glab'); + const callsPath = join(directory, 'calls.jsonl'); + await writeFile( + fakeGlabPath, + `#!/usr/bin/env node +const fs = require('node:fs'); +const args = process.argv.slice(2); +let input = ''; +process.stdin.setEncoding('utf8'); +process.stdin.on('data', (chunk) => { input += chunk; }); +process.stdin.on('end', () => { + fs.appendFileSync(process.env.CODIFF_GLAB_TEST_CALLS, JSON.stringify({ args, input }) + '\\n'); + process.stdout.write('{}'); +}); +`, + 'utf8', + ); + await chmod(fakeGlabPath, 0o755); + process.env.CODIFF_GLAB_PATH = fakeGlabPath; + process.env.CODIFF_GLAB_TEST_CALLS = callsPath; + + const transport = createGlabGitLabTransport({ + hostname: 'gitlab.example.com', + repoRoot: directory, + }); + await transport.request({ + body: { body: 'Looks good.' }, + method: 'POST', + path: '/api/v4/projects/group%2Fproject/merge_requests/7/notes', + }); + + const call = JSON.parse((await readFile(callsPath, 'utf8')).trim()) as { + args: Array; + input: string; + }; + expect(call.args.at(-1)).toBe('/projects/group%2Fproject/merge_requests/7/notes'); + expect(call.args).toContain('Content-Type: application/json'); + expect(JSON.parse(call.input)).toEqual({ body: 'Looks good.' }); +}); + +test('createGlabGitLabTransport preserves exact binary response bytes', async () => { + const directory = await mkdtemp(join(tmpdir(), 'codiff-glab-transport-')); + const fakeGlabPath = join(directory, 'glab'); + const callsPath = join(directory, 'calls.jsonl'); + await writeFile( + fakeGlabPath, + `#!/usr/bin/env node +const fs = require('node:fs'); +const args = process.argv.slice(2); +fs.appendFileSync(process.env.CODIFF_GLAB_TEST_CALLS, JSON.stringify({ args }) + '\\n'); +process.stdout.write(Buffer.from([0, 255, 10, 128])); +`, + 'utf8', + ); + await chmod(fakeGlabPath, 0o755); + process.env.CODIFF_GLAB_PATH = fakeGlabPath; + process.env.CODIFF_GLAB_TEST_CALLS = callsPath; + + const transport = createGlabGitLabTransport({ + hostname: 'gitlab.example.com', + repoRoot: directory, + }); + const bytes = await transport.requestBuffer({ + path: '/api/v4/projects/group%2Fproject/repository/blobs/deadbeef/raw', + }); + + expect([...bytes]).toEqual([0, 255, 10, 128]); + const calls = JSON.parse((await readFile(callsPath, 'utf8')).trim()) as { + args: Array; + }; + expect(calls.args).toContain('/projects/group%2Fproject/repository/blobs/deadbeef/raw'); +}); + +test('createGlabGitLabTransport inherits cancellation from its enclosing action', async () => { + const directory = await mkdtemp(join(tmpdir(), 'codiff-glab-transport-')); + const fakeGlabPath = join(directory, 'glab'); + await writeFile( + fakeGlabPath, + `#!/usr/bin/env node +setInterval(() => {}, 1_000); +`, + 'utf8', + ); + await chmod(fakeGlabPath, 0o755); + process.env.CODIFF_GLAB_PATH = fakeGlabPath; + const commandLogPath = configureCommandLog(directory); + + const transport = createGlabGitLabTransport({ + hostname: 'gitlab.example.com', + repoRoot: directory, + }); + const action = startCommandAction({ command: 'initial-load' }); + const pending = action.run(() => + transport.requestBuffer({ + path: '/api/v4/projects/group%2Fproject/repository/blobs/deadbeef/raw', + }), + ); + setTimeout(() => action.cancel(), 25); + + await expect(pending).rejects.toMatchObject({ name: 'AbortError' }); + expect(action.signal.aborted).toBe(true); + await flushCommandLog(); + const commandLog = (await readFile(commandLogPath, 'utf8')) + .trim() + .split('\n') + .map((line) => JSON.parse(line) as Record); + const actionStart = commandLog.find( + (record) => record.event === 'start' && record.command === 'initial-load', + ); + const finishes = commandLog.filter( + (record) => record.event === 'finish' && record.command === fakeGlabPath, + ); + expect(finishes).toEqual([ + expect.objectContaining({ + actionId: actionStart?.id, + canceled: true, + errorName: 'AbortError', + status: 'canceled', + }), + ]); +}); + +test('createGlabGitLabTransport records cancellation before spawning a provider process', async () => { + const directory = await mkdtemp(join(tmpdir(), 'codiff-glab-transport-')); + const fakeGlabPath = join(directory, 'glab'); + await writeFile(fakeGlabPath, '#!/usr/bin/env node\n', 'utf8'); + await chmod(fakeGlabPath, 0o755); + process.env.CODIFF_GLAB_PATH = fakeGlabPath; + const commandLogPath = configureCommandLog(directory); + const controller = new AbortController(); + controller.abort(); + + const transport = createGlabGitLabTransport({ + hostname: 'gitlab.example.com', + repoRoot: directory, + }); + await expect( + transport.requestBuffer({ + path: '/api/v4/projects/group%2Fproject/repository/blobs/deadbeef/raw', + signal: controller.signal, + }), + ).rejects.toMatchObject({ name: 'AbortError' }); + await flushCommandLog(); + + const commandLog = (await readFile(commandLogPath, 'utf8')) + .trim() + .split('\n') + .map((line) => JSON.parse(line) as Record); + expect( + commandLog.filter((record) => record.event === 'start' && record.command === fakeGlabPath), + ).toHaveLength(1); + expect(commandLog).toContainEqual( + expect.objectContaining({ + canceled: true, + command: fakeGlabPath, + errorName: 'AbortError', + event: 'finish', + status: 'canceled', + }), + ); +}); + +test('createGlabGitLabTransport flattens all glab paginated response pages', async () => { + const directory = await mkdtemp(join(tmpdir(), 'codiff-glab-transport-')); + const fakeGlabPath = join(directory, 'glab'); + const callsPath = join(directory, 'calls.jsonl'); + await writeFile( + fakeGlabPath, + `#!/usr/bin/env node +const fs = require('node:fs'); +const args = process.argv.slice(2); +fs.appendFileSync(process.env.CODIFF_GLAB_TEST_CALLS, JSON.stringify({ args }) + '\\n'); +process.stdout.write('[\\n {"id": 1, "label": "nested } and \\\\"quoted\\\\" text"}\\n][{"id": 2}]'); +`, + 'utf8', + ); + await chmod(fakeGlabPath, 0o755); + process.env.CODIFF_GLAB_PATH = fakeGlabPath; + process.env.CODIFF_GLAB_TEST_CALLS = callsPath; + + const transport = createGlabGitLabTransport({ + hostname: 'gitlab.example.com', + repoRoot: directory, + }); + const secondTransport = createGlabGitLabTransport({ + hostname: 'gitlab.example.com', + repoRoot: directory, + }); + const pages = await Promise.all([ + transport.requestPages({ path: '/api/v4/projects/group%2Fproject/issues' }), + secondTransport.requestPages({ path: '/api/v4/projects/group%2Fproject/issues' }), + ]); + expect(pages).toEqual([ + [{ id: 1, label: 'nested } and "quoted" text' }, { id: 2 }], + [{ id: 1, label: 'nested } and "quoted" text' }, { id: 2 }], + ]); + const calls = (await readFile(callsPath, 'utf8')).trim().split('\n'); + expect(calls).toHaveLength(1); + expect((JSON.parse(calls[0]) as { args: Array }).args).toContain('--paginate'); +}); diff --git a/electron/__tests__/helpers/git-repository.ts b/electron/__tests__/helpers/git-repository.ts new file mode 100644 index 00000000..b62340de --- /dev/null +++ b/electron/__tests__/helpers/git-repository.ts @@ -0,0 +1,19 @@ +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import { getGitTestEnvironmentForSubprocess } from '../../../core/__tests__/helpers/git.ts'; +import { createTemporaryDirectory } from '../../../core/__tests__/helpers/resources.ts'; + +const execFileAsync = promisify(execFile); + +export const createTemporaryGitRepository = async (prefix: string) => { + const directory = await createTemporaryDirectory(prefix); + try { + await execFileAsync('git', ['-C', directory.path, 'init', '--quiet'], { + env: getGitTestEnvironmentForSubprocess(), + }); + return directory; + } catch (error) { + await directory[Symbol.asyncDispose](); + throw error; + } +}; diff --git a/electron/__tests__/narrative-walkthrough.test.ts b/electron/__tests__/narrative-walkthrough.test.ts index 76a6154f..9c9699d4 100644 --- a/electron/__tests__/narrative-walkthrough.test.ts +++ b/electron/__tests__/narrative-walkthrough.test.ts @@ -1446,7 +1446,7 @@ test('strips the commit composer when the source is not a working tree', () => { input.commit = { title: 'Fix hunk nav' }; const result = normalizeNarrativeWalkthrough(input, files, { - source: { ref: 'abc1234', type: 'commit' }, + source: { sha: 'abc1234', type: 'commit' }, }); expect(result.commit).toBeUndefined(); diff --git a/electron/__tests__/provider-review-state.test.ts b/electron/__tests__/provider-review-state.test.ts new file mode 100644 index 00000000..c5cc246a --- /dev/null +++ b/electron/__tests__/provider-review-state.test.ts @@ -0,0 +1,474 @@ +import { execFileSync } from 'node:child_process'; +import { chmod, mkdir, mkdtemp, readFile, writeFile } from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import { tmpdir } from 'node:os'; +import { delimiter, join } from 'node:path'; +import { expect, test } from 'vite-plus/test'; +import type { + RepositoryState, + RevisionContentBatchRequest, + RevisionContentBatchResult, +} from '../../core/types.ts'; + +const { readPullRequestState } = require('../git-state/pull-request.cjs') as { + readPullRequestState: ( + repoRoot: string, + source: { type: 'pull-request'; url: string }, + ) => Promise; +}; +const { readRevisionContent } = require('../review-content.cjs') as { + readRevisionContent: ( + repoRoot: string, + request: RevisionContentBatchRequest, + ) => Promise; +}; +const { rangeArtifactToPullRequestFiles } = require('../git-state/review-range-sections.cjs') as { + rangeArtifactToPullRequestFiles: ( + artifact: import('../../core/index.ts').RangeArtifact, + number: number, + options?: { deferContents?: boolean }, + ) => ReadonlyArray; +}; + +test('renders returned files and a warning when overall Range Artifact coverage is truncated', () => { + const baseSha = 'a'.repeat(40); + const headSha = 'b'.repeat(40); + const files = rangeArtifactToPullRequestFiles( + { + baseSha, + coverage: 'truncated', + files: [ + { + coverage: 'complete', + lineCount: { additions: 1, deletions: 1 }, + patch: '@@ -1 +1 @@\n-old\n+new', + path: 'src/app.ts', + status: 'modified', + }, + ], + headSha, + incompleteReason: 'GitLab returned only the first changed file.', + provenance: { + kind: 'gitlab-api', + project: { host: 'gitlab.example.com', project: 'group/project', provider: 'gitlab' }, + }, + }, + 7, + ); + + expect(files[0]?.sections[0]).toMatchObject({ + lineCount: { additions: 1, deletions: 1 }, + loadState: 'ready', + patch: expect.stringContaining('+new'), + summary: { + canLoad: true, + reason: 'Showing the provider patch for this file.', + }, + }); + expect(files[0]?.sections[0]?.summary?.reason).not.toContain('Artifact coverage'); + expect(files).toHaveLength(2); + expect(files[1]).toMatchObject({ path: 'Review diff incomplete', status: 'modified' }); + expect(files[1]?.sections[0]).toMatchObject({ + id: 'review-range-incomplete:7', + loadState: 'error', + summary: { + canLoad: false, + reason: 'GitLab returned only the first changed file.', + }, + }); + expect(files[1]?.sections[0]).not.toHaveProperty('range'); +}); + +test('keeps coverage warning identity distinct from real repository paths', () => { + const files = rangeArtifactToPullRequestFiles( + { + baseSha: 'a'.repeat(40), + coverage: 'truncated', + files: [ + { + coverage: 'complete', + patch: '@@ -1 +1 @@\n-old\n+new', + path: 'Review diff incomplete', + status: 'modified', + }, + { + coverage: 'complete', + patch: '@@ -1 +1 @@\n-old\n+new', + path: 'Review diff incomplete (2)', + status: 'modified', + }, + ], + headSha: 'b'.repeat(40), + incompleteReason: 'The provider response was truncated.', + provenance: { + kind: 'gitlab-api', + project: { host: 'gitlab.example.com', project: 'group/project', provider: 'gitlab' }, + }, + }, + 7, + ); + + expect(files.map((file) => file.path)).toEqual([ + 'Review diff incomplete', + 'Review diff incomplete (2)', + 'Review diff incomplete (3)', + ]); +}); + +test('keeps a complete empty Range Artifact empty', () => { + const files = rangeArtifactToPullRequestFiles( + { + baseSha: 'a'.repeat(40), + coverage: 'complete', + files: [], + headSha: 'b'.repeat(40), + provenance: { + kind: 'gitlab-api', + project: { host: 'gitlab.example.com', project: 'group/project', provider: 'gitlab' }, + }, + }, + 7, + ); + + expect(files).toEqual([]); +}); + +test('carries immutable blob identity when a provider omits patch material', () => { + const artifact = { + baseSha: 'a'.repeat(40) as import('../../core/types.ts').GitSha, + coverage: 'complete' as const, + files: [ + { + coverage: 'opaque' as const, + newObjectId: '2'.repeat(40), + oldObjectId: '1'.repeat(40), + path: 'src/deferred.ts', + status: 'modified' as const, + }, + ], + headSha: 'b'.repeat(40) as import('../../core/types.ts').GitSha, + provenance: { + kind: 'github-api' as const, + project: { + host: 'github.com', + project: 'example/repo', + provider: 'github' as const, + }, + }, + }; + const first = rangeArtifactToPullRequestFiles(artifact, 7, { deferContents: true }); + const rebased = rangeArtifactToPullRequestFiles( + { ...artifact, baseSha: 'c'.repeat(40) as import('../../core/types.ts').GitSha }, + 7, + { deferContents: true }, + ); + + expect(first[0].sections[0].summary?.fingerprint).toBeTruthy(); + expect(rebased[0].sections[0].summary?.fingerprint).toBe( + first[0].sections[0].summary?.fingerprint, + ); +}); + +test('renders a visible unavailable item for a wholly truncated Range Artifact', () => { + const files = rangeArtifactToPullRequestFiles( + { + baseSha: 'a'.repeat(40), + coverage: 'truncated', + files: [], + headSha: 'b'.repeat(40), + incompleteReason: 'GitLab merge request diffs exceeded the 8.0 MiB limit.', + provenance: { + kind: 'gitlab-api', + project: { host: 'gitlab.example.com', project: 'group/project', provider: 'gitlab' }, + }, + }, + 7, + ); + + expect(files).toEqual([ + expect.objectContaining({ path: 'Review diff unavailable', status: 'modified' }), + ]); + expect(files[0]?.sections[0]).toMatchObject({ + loadState: 'error', + summary: { + canLoad: false, + reason: 'GitLab merge request diffs exceeded the 8.0 MiB limit.', + }, + }); + expect(files[0]?.sections[0]).not.toHaveProperty('range'); +}); + +const { createGitLabPosition, readMergeRequestState } = + require('../git-state/merge-request.cjs') as { + createGitLabPosition: (comment: unknown, metadata: unknown, diff?: unknown) => unknown; + readMergeRequestState: ( + repoRoot: string, + source: { provider: 'gitlab'; type: 'pull-request'; url: string }, + ) => Promise; + }; + +const git = (directory: string, args: ReadonlyArray) => + execFileSync('git', ['-C', directory, ...args], { encoding: 'utf8' }).trim(); + +const createRepository = async (remote: string) => { + const directory = await mkdtemp(join(tmpdir(), 'codiff-provider-state-')); + execFileSync('git', ['init', '--quiet', directory]); + git(directory, ['config', 'user.email', 'codiff@example.com']); + git(directory, ['config', 'user.name', 'Codiff Test']); + git(directory, ['remote', 'add', 'origin', remote]); + return directory; +}; + +const createReviewRange = async ( + directory: string, + refs: { base: string; head: string }, + paths: ReadonlyArray = ['src/app.ts'], +) => { + await mkdir(join(directory, 'src'), { recursive: true }); + await Promise.all(paths.map((path) => writeFile(join(directory, path), `old ${path}\n`, 'utf8'))); + git(directory, ['add', '.']); + git(directory, ['commit', '--quiet', '-m', 'Create review base']); + const baseSha = git(directory, ['rev-parse', 'HEAD']); + await Promise.all(paths.map((path) => writeFile(join(directory, path), `new ${path}\n`, 'utf8'))); + git(directory, ['add', '.']); + git(directory, ['commit', '--quiet', '-m', 'Create review head']); + const headSha = git(directory, ['rev-parse', 'HEAD']); + git(directory, ['update-ref', refs.base, baseSha]); + git(directory, ['update-ref', refs.head, headSha]); + return { baseSha, headSha }; +}; + +const readStateRevisionContents = (directory: string, state: RepositoryState) => { + const requests = state.files.flatMap((file) => + file.sections.flatMap((section) => [ + ...(section.range?.base + ? [ + { + key: `${file.path}:old`, + maxBytes: 2 * 1024 * 1024, + path: file.oldPath ?? file.path, + revision: section.range.base, + }, + ] + : []), + ...(section.range?.head + ? [ + { + key: `${file.path}:new`, + maxBytes: 2 * 1024 * 1024, + path: file.path, + revision: section.range.head, + }, + ] + : []), + ]), + ); + return readRevisionContent(directory, { + generation: 'provider-state-test', + requests, + source: state.source, + }); +}; + +test('returns a GitHub Range Artifact before exact file hydration', async () => { + const directory = await createRepository('https://github.com/nkzw-tech/codiff.git'); + const fakeGh = join(directory, 'gh'); + const callLog = join(directory, 'gh-calls.jsonl'); + const { baseSha, headSha } = await createReviewRange( + directory, + { + base: 'refs/codiff/pull-requests/7/base', + head: 'refs/codiff/pull-requests/7/head', + }, + ['src/app.ts', 'src/other.ts'], + ); + await writeFile( + fakeGh, + `#!/usr/bin/env node +const { appendFileSync } = require('node:fs'); +const args = process.argv.slice(2); +appendFileSync(${JSON.stringify(callLog)}, JSON.stringify(args) + '\\n'); +const resource = args.find((arg) => arg.includes('repos/')) || ''; +if (resource.includes('/compare/')) { + process.stdout.write(JSON.stringify({ + commits: [{ + commit: { author: { date: '2026-01-01T00:00:00.000Z', name: 'Ada' }, message: 'Update app' }, + parents: [{ sha: '${baseSha}' }], + sha: '${headSha}', + }], + merge_base_commit: { sha: '${baseSha}' }, + files: ['src/app.ts', 'src/other.ts'].map((path) => ({ + filename: path, + patch: '@@ -1 +1 @@\\n-old\\n+new\\n', + status: 'modified', + })), + total_commits: 1, + })); +} else { + process.stdout.write(JSON.stringify({ + base: { ref: 'main', sha: '${baseSha}' }, + head: { sha: '${headSha}' }, + title: 'Review Range Artifacts', + user: { login: 'ada' }, + })); +} +`, + 'utf8', + ); + await chmod(fakeGh, 0o755); + const previousPath = process.env.PATH; + process.env.PATH = `${directory}${delimiter}${previousPath ?? ''}`; + try { + const source = { + type: 'pull-request', + url: 'https://github.com/nkzw-tech/codiff/pull/7', + } as const; + const state = await readPullRequestState(directory, source); + + expect(state.files).toHaveLength(2); + expect(state.files[0]).toMatchObject({ path: 'src/app.ts', status: 'modified' }); + expect(state.files[0]?.sections[0]).toMatchObject({ + id: 'src/app.ts:pull-request:7', + kind: 'pull-request', + loadState: 'ready', + range: { base: { sha: baseSha }, head: { sha: headSha } }, + summary: { canLoad: true }, + }); + expect(state.files[0]?.sections[0]?.patch).toMatch( + /^diff --git a\/src\/app\.ts b\/src\/app\.ts/, + ); + expect(state.files[0]?.sections[0]).not.toHaveProperty('newFile'); + expect(state.files[0]?.sections[0]).not.toHaveProperty('oldFile'); + expect(state.reviewComments).toBeUndefined(); + const contents = await readStateRevisionContents(directory, state); + expect(contents.results).toHaveLength(4); + expect(contents.results.every((result) => result.status === 'ready')).toBe(true); + const readyContents = new Map( + contents.results.flatMap((result) => + result.status === 'ready' + ? [[result.key, new TextDecoder().decode(result.value.bytes)] as const] + : [], + ), + ); + expect(readyContents.get('src/app.ts:old')).toBe('old src/app.ts\n'); + expect(readyContents.get('src/app.ts:new')).toBe('new src/app.ts\n'); + const calls = (await readFile(callLog, 'utf8')) + .trim() + .split('\n') + .map((line) => JSON.parse(line) as Array); + expect(calls).toHaveLength(2); + expect(calls.filter((args) => args.some((arg) => arg.includes('/compare/')))).toHaveLength(1); + expect(calls.flat()).not.toContainEqual(expect.stringMatching(/\/pulls\/7\/files/)); + expect(calls.flat()).not.toContainEqual( + expect.stringMatching(/comments|contents|graphql|application\/vnd\.github\.v3\.diff/), + ); + } finally { + if (previousPath == null) delete process.env.PATH; + else process.env.PATH = previousPath; + } +}, 30_000); + +test('returns a GitLab Range Artifact before exact file hydration', async () => { + const directory = await createRepository('https://gitlab.example.com/group/project.git'); + const fakeGlab = join(directory, 'glab'); + const callLog = join(directory, 'glab-calls.jsonl'); + const { baseSha, headSha } = await createReviewRange( + directory, + { + base: 'refs/codiff/merge-requests/7/base', + head: 'refs/codiff/merge-requests/7/head', + }, + ['src/app.ts', 'src/other.ts'], + ); + const startSha = 'd'.repeat(40); + await writeFile( + fakeGlab, + `#!/usr/bin/env node +const { appendFileSync } = require('node:fs'); +const args = process.argv.slice(2); +appendFileSync(${JSON.stringify(callLog)}, JSON.stringify(args) + '\\n'); +const resource = args.find((arg) => arg.includes('projects/')) || ''; +if (resource.includes('/repository/compare?')) { + process.stdout.write(JSON.stringify({ + commits: [{ + authored_date: '2026-01-01T00:00:00.000Z', + author_name: 'Ada', + id: '${headSha}', + parent_ids: ['${baseSha}'], + title: 'Update app', + }], + diffs: ['src/app.ts', 'src/other.ts'].map((path) => ({ + a_mode: '100644', + b_mode: '100644', + diff: '@@ -1 +1 @@\\n-old\\n+new', + new_path: path, + old_path: path, + })), + })); +} else { + process.stdout.write(JSON.stringify({ + author: { username: 'ada' }, + diff_refs: { base_sha: '${baseSha}', head_sha: '${headSha}', start_sha: '${startSha}' }, + sha: '${headSha}', + title: 'Review Range Artifacts', + web_url: 'https://gitlab.example.com/group/project/-/merge_requests/7', + })); +} +`, + 'utf8', + ); + await chmod(fakeGlab, 0o755); + const previous = process.env.CODIFF_GLAB_PATH; + process.env.CODIFF_GLAB_PATH = fakeGlab; + try { + const source = { + provider: 'gitlab', + type: 'pull-request', + url: 'https://gitlab.example.com/group/project/-/merge_requests/7', + } as const; + const state = await readMergeRequestState(directory, source); + + expect(state.files).toHaveLength(2); + expect(state.files[0]).toMatchObject({ path: 'src/app.ts', status: 'modified' }); + expect(state.files[0]?.sections[0]).toMatchObject({ + id: 'src/app.ts:pull-request:7', + kind: 'pull-request', + loadState: 'ready', + range: { base: { sha: baseSha }, head: { sha: headSha } }, + summary: { canLoad: true }, + }); + expect(state.files[0]?.sections[0]?.patch).toMatch( + /^diff --git a\/src\/app\.ts b\/src\/app\.ts/, + ); + expect(state.files[0]?.sections[0]).not.toHaveProperty('newFile'); + expect(state.files[0]?.sections[0]).not.toHaveProperty('oldFile'); + expect(state.reviewComments).toBeUndefined(); + expect(state.files.every((file) => !('oldFile' in (file.sections[0] ?? {})))).toBe(true); + expect( + createGitLabPosition( + { anchor: 'file', filePath: 'src/app.ts' }, + { diff_refs: { base_sha: baseSha, head_sha: headSha, start_sha: startSha } }, + { new_path: 'src/app.ts', old_path: 'src/app.ts' }, + ), + ).toMatchObject({ base_sha: baseSha, head_sha: headSha, start_sha: startSha }); + const contents = await readStateRevisionContents(directory, state); + expect(contents.results).toHaveLength(4); + expect(contents.results.every((result) => result.status === 'ready')).toBe(true); + const calls = (await readFile(callLog, 'utf8')) + .trim() + .split('\n') + .map((line) => JSON.parse(line) as Array); + expect(calls).toHaveLength(2); + const compare = calls.find((args) => args.some((arg) => arg.includes('/repository/compare'))); + expect(compare).toBeDefined(); + expect(compare?.join(' ')).toContain(`from=${baseSha}`); + expect(compare?.join(' ')).not.toContain(startSha); + expect(calls.flat()).not.toContainEqual(expect.stringMatching(/merge_requests\/7\/diffs/)); + expect(calls.flat()).not.toContainEqual( + expect.stringMatching(/discussions|repository\/files|versions/), + ); + } finally { + if (previous == null) delete process.env.CODIFF_GLAB_PATH; + else process.env.CODIFF_GLAB_PATH = previous; + } +}, 30_000); diff --git a/electron/__tests__/pull-request-command.test.ts b/electron/__tests__/pull-request-command.test.ts index e573b0e8..2310b62a 100644 --- a/electron/__tests__/pull-request-command.test.ts +++ b/electron/__tests__/pull-request-command.test.ts @@ -10,24 +10,29 @@ import { } from '../../core/__tests__/helpers/resources.ts'; const require = createRequire(import.meta.url); -const { GH_NOT_FOUND_CODE, getGhCommand, submitPullRequestReview } = - require('../git-state/pull-request.cjs') as { +const { GH_NOT_FOUND_CODE, getGhCommand } = + require('../git-state/github-history/gh-github-transport.cjs') as { GH_NOT_FOUND_CODE: string; getGhCommand: () => string; - submitPullRequestReview: ( - launchPath: string, - request: { - body?: string; - comments: ReadonlyArray>; - event: 'APPROVE' | 'COMMENT' | 'REQUEST_CHANGES'; - source: { - provider: 'github'; - type: 'pull-request'; - url: string; - }; - }, - ) => Promise; }; +const { submitPullRequestReview } = require('../git-state/pull-request.cjs') as { + submitPullRequestReview: ( + launchPath: string, + request: { + body?: string; + comments: ReadonlyArray>; + event: 'APPROVE' | 'COMMENT' | 'REQUEST_CHANGES'; + source: { + provider: 'github'; + type: 'pull-request'; + url: string; + }; + }, + ) => Promise< + | { status: 'submitted'; submittedDraftIds: ReadonlyArray } + | { reason: string; status: 'failed'; submittedDraftIds: ReadonlyArray } + >; +}; const execFileAsync = promisify(execFile); @@ -111,7 +116,11 @@ test('reports a missing GitHub CLI when the resolved executable cannot be spawne url: 'https://github.com/nkzw-tech/codiff/pull/12', }, }), - ).rejects.toThrow('GitHub support requires gh'); + ).resolves.toMatchObject({ + reason: expect.stringContaining('GitHub support requires gh'), + status: 'failed', + submittedDraftIds: [], + }); }); test('reaches the GitHub CLI when it is not on PATH', async () => { @@ -146,11 +155,23 @@ test('reaches the GitHub CLI when it is not on PATH', async () => { fakeGh, `#!/bin/sh printf '%s | %s\\n' "$*" "$(cat)" >> "$CODIFF_GITHUB_COMMAND_TEST_CALLS" +head_sha='0123456789abcdef0123456789abcdef01234567' +base_sha='fedcba9876543210fedcba9876543210fedcba98' for arg in "$@"; do - if [ "$arg" = 'repos/nkzw-tech/codiff/pulls/12' ]; then - printf '%s' '{"head":{"sha":"0123456789abcdef0123456789abcdef01234567"}}' + if [ "$arg" = '/repos/nkzw-tech/codiff/pulls/12' ]; then + printf '%s' '{"base":{"sha":"'"$base_sha"'"},"head":{"sha":"'"$head_sha"'"}}' exit 0 fi + case "$arg" in + /repos/nkzw-tech/codiff/compare/*) + printf '%s' '{"merge_base_commit":{"sha":"'"$base_sha"'"}}' + exit 0 + ;; + /repos/nkzw-tech/codiff/pulls/12/files*) + printf '%s' '[]' + exit 0 + ;; + esac done printf '%s' '{}' `, @@ -164,23 +185,31 @@ printf '%s' '{}' SHELL: undefined, }); - await submitPullRequestReview(repo, { - body: 'General feedback.', - comments: [], - event: 'COMMENT', - source: { - provider: 'github', - type: 'pull-request', - url: 'https://github.com/nkzw-tech/codiff/pull/12', - }, - }); + await expect( + submitPullRequestReview(repo, { + body: 'General feedback.', + comments: [], + event: 'COMMENT', + source: { + provider: 'github', + type: 'pull-request', + url: 'https://github.com/nkzw-tech/codiff/pull/12', + }, + }), + ).resolves.toEqual({ status: 'submitted', submittedDraftIds: [] }); const calls = (await readFile(callsPath, 'utf8')).trim().split('\n'); - expect(calls).toEqual([ - 'api repos/nkzw-tech/codiff/pulls/12 | ', - 'api -X POST repos/nkzw-tech/codiff/pulls/12/reviews --input - | ' + - '{"body":"General feedback.","comments":[],"event":"COMMENT"}', - ]); + expect(calls[0]).toBe('api /repos/nkzw-tech/codiff/pulls/12 | '); + expect(calls.slice(1, 3)).toEqual( + expect.arrayContaining([ + `api /repos/nkzw-tech/codiff/compare/${'fedcba9876543210fedcba9876543210fedcba98'}...${'0123456789abcdef0123456789abcdef01234567'} | `, + 'api --paginate /repos/nkzw-tech/codiff/pulls/12/files?per_page=100 | ', + ]), + ); + expect(calls[3]).toBe( + 'api --method POST /repos/nkzw-tech/codiff/pulls/12/reviews --input - | ' + + '{"body":"General feedback.","commit_id":"0123456789abcdef0123456789abcdef01234567","comments":[],"event":"COMMENT"}', + ); }); test('authenticates gh from the login shell environment when the app inherited none', async () => { diff --git a/electron/__tests__/pull-request-remote.test.ts b/electron/__tests__/pull-request-remote.test.ts index 191fa4b7..40edf4b3 100644 --- a/electron/__tests__/pull-request-remote.test.ts +++ b/electron/__tests__/pull-request-remote.test.ts @@ -149,7 +149,7 @@ test('skips a mismatching origin alias and selects the remote with the exact PR } finally { await removeGitTestDirectory(directory); } -}); +}, 15_000); test('rejects opaque remotes that do not match both the repository path and PR head', async () => { const directory = await mkdtemp(join(tmpdir(), 'codiff-pull-request-remote-')); @@ -179,4 +179,4 @@ test('rejects opaque remotes that do not match both the repository path and PR h } finally { await removeGitTestDirectory(directory); } -}); +}, 15_000); diff --git a/electron/__tests__/pull-request-review.test.ts b/electron/__tests__/pull-request-review.test.ts index bdf45241..29be2e20 100644 --- a/electron/__tests__/pull-request-review.test.ts +++ b/electron/__tests__/pull-request-review.test.ts @@ -8,6 +8,7 @@ import { createTemporaryDirectory, createTemporaryEnvironment, } from '../../core/__tests__/helpers/resources.ts'; +import type { GitSha, SubmitPullRequestReviewResult } from '../../core/types.ts'; const require = createRequire(import.meta.url); const { submitPullRequestReview } = require('../git-state/pull-request.cjs') as { @@ -23,7 +24,7 @@ const { submitPullRequestReview } = require('../git-state/pull-request.cjs') as url: string; }; }, - ) => Promise; + ) => Promise; }; const execFileAsync = promisify(execFile); @@ -34,6 +35,9 @@ test('submits normalized GitHub review payloads through the GitHub CLI', async ( const fakeBin = join(directory.path, 'bin'); const fakeGh = join(fakeBin, 'gh'); const callsPath = join(directory.path, 'calls.jsonl'); + const baseSha = 'a'.repeat(40); + const advancedBaseTipSha = 'c'.repeat(40); + const headSha = 'b'.repeat(40); await Promise.all([mkdir(repo), mkdir(fakeBin)]); await execFileAsync('git', ['-C', repo, 'init']); @@ -58,11 +62,24 @@ process.stdin.on('end', () => { process.env.CODIFF_GITHUB_REVIEW_TEST_CALLS, JSON.stringify({ args, input }) + '\\n', ); - process.stdout.write( - args.includes('repos/nkzw-tech/codiff/pulls/12') - ? '{"head":{"sha":"0123456789abcdef0123456789abcdef01234567"}}' - : '{}', - ); + const endpoint = args.find((argument) => argument.startsWith('/repos/')) || ''; + if (endpoint.includes('/compare/')) { + process.stdout.write(JSON.stringify({ merge_base_commit: { sha: '${baseSha}' } })); + return; + } + if (endpoint.endsWith('/files?per_page=100')) { + process.stdout.write(JSON.stringify([{ + filename: 'src/app.ts', + patch: '@@ -7 +7 @@\\n-old\\n+new\\n', + }])); + return; + } + process.stdout.write(endpoint.endsWith('/pulls/12') + ? JSON.stringify({ + base: { sha: '${advancedBaseTipSha}' }, + head: { sha: '${headSha}' }, + }) + : '{}'); }); `, ); @@ -75,6 +92,7 @@ process.stdin.on('end', () => { }); const source = { + headSha: headSha as GitSha, provider: 'github' as const, type: 'pull-request' as const, url: 'https://github.com/nkzw-tech/codiff/pull/12', @@ -86,6 +104,19 @@ process.stdin.on('end', () => { body: 'Please keep this explicit.', filePath: 'src/app.ts', lineNumber: 7, + localDraftId: 'draft-1', + position: { + range: { + base: { + label: { kind: 'commit' as const, text: 'aaaaaaa' }, + sha: baseSha as GitSha, + }, + head: { + label: { kind: 'commit' as const, text: 'bbbbbbb' }, + sha: headSha as GitSha, + }, + }, + }, side: 'additions', }, ], @@ -105,7 +136,11 @@ process.stdin.on('end', () => { event: 'COMMENT', source, }), - ).rejects.toThrow('A comment review requires an inline comment or a review comment.'); + ).resolves.toEqual({ + reason: 'A comment review requires an inline comment or a review comment.', + status: 'failed', + submittedDraftIds: [], + }); await submitPullRequestReview(repo, { comments: [], event: 'REQUEST_CHANGES', @@ -123,11 +158,19 @@ process.stdin.on('end', () => { }, ); const reviewCalls = calls.filter((call) => - call.args.includes('repos/nkzw-tech/codiff/pulls/12/reviews'), + call.args.includes('/repos/nkzw-tech/codiff/pulls/12/reviews'), + ); + const endpoints = calls.flatMap((call) => + call.args.filter((argument) => argument.startsWith('/repos/')), ); + const metadataPath = '/repos/nkzw-tech/codiff/pulls/12'; + const comparePath = `/repos/nkzw-tech/codiff/compare/${advancedBaseTipSha}...${headSha}`; + expect(endpoints).toContain(comparePath); + expect(endpoints.indexOf(metadataPath)).toBeLessThan(endpoints.indexOf(comparePath)); expect(reviewCalls).toHaveLength(3); expect(JSON.parse(reviewCalls[0].input)).toEqual({ - body: '', + body: 'Review comments.', + commit_id: headSha, comments: [ { body: 'Please keep this explicit.', @@ -140,12 +183,146 @@ process.stdin.on('end', () => { }); expect(JSON.parse(reviewCalls[1].input)).toEqual({ body: 'General feedback.', + commit_id: headSha, comments: [], event: 'COMMENT', }); expect(JSON.parse(reviewCalls[2].input)).toEqual({ body: 'Requesting changes.', + commit_id: headSha, comments: [], event: 'REQUEST_CHANGES', }); }); + +test.each([ + { + expectedReason: 'head changed', + mergeBaseSha: 'a'.repeat(40), + metadataHeadSha: 'd'.repeat(40), + name: 'changed head', + }, + { + expectedReason: 'draft range no longer matches', + mergeBaseSha: 'e'.repeat(40), + metadataHeadSha: 'b'.repeat(40), + name: 'changed merge base', + }, + { + expectedReason: 'did not return the current pull request merge base', + mergeBaseSha: null, + metadataHeadSha: 'b'.repeat(40), + name: 'missing merge base', + }, +])( + 'rejects a $name before mutating GitHub', + async ({ expectedReason, mergeBaseSha, metadataHeadSha }) => { + await using directory = await createTemporaryDirectory('codiff-pull-request-review-'); + const repo = join(directory.path, 'repo'); + const fakeBin = join(directory.path, 'bin'); + const fakeGh = join(fakeBin, 'gh'); + const callsPath = join(directory.path, 'calls.jsonl'); + const draftBaseSha = 'a'.repeat(40); + const baseTipSha = 'c'.repeat(40); + const draftHeadSha = 'b'.repeat(40); + + await Promise.all([mkdir(repo), mkdir(fakeBin)]); + await execFileAsync('git', ['-C', repo, 'init']); + await execFileAsync('git', [ + '-C', + repo, + 'remote', + 'add', + 'origin', + 'git@github.com:nkzw-tech/codiff.git', + ]); + await writeFile( + fakeGh, + `#!/usr/bin/env node +const { appendFileSync } = require('node:fs'); +const args = process.argv.slice(2); +let input = ''; +process.stdin.setEncoding('utf8'); +process.stdin.on('data', (chunk) => { input += chunk; }); +process.stdin.on('end', () => { + appendFileSync( + process.env.CODIFF_GITHUB_REVIEW_TEST_CALLS, + JSON.stringify({ args, input }) + '\\n', + ); + const endpoint = args.find((argument) => argument.startsWith('/repos/')) || ''; + if (endpoint.includes('/compare/')) { + process.stdout.write(${JSON.stringify( + JSON.stringify(mergeBaseSha ? { merge_base_commit: { sha: mergeBaseSha } } : {}), + )}); + return; + } + if (endpoint.endsWith('/files?per_page=100')) { + process.stdout.write(JSON.stringify([{ + filename: 'src/app.ts', + patch: '@@ -7 +7 @@\\n-old\\n+new\\n', + }])); + return; + } + process.stdout.write(endpoint.endsWith('/pulls/12') + ? ${JSON.stringify( + JSON.stringify({ + base: { sha: baseTipSha }, + head: { sha: metadataHeadSha }, + }), + )} + : '{}'); +}); +`, + ); + await chmod(fakeGh, 0o755); + + await using _environment = createTemporaryEnvironment({ + CODIFF_GITHUB_REVIEW_TEST_CALLS: callsPath, + PATH: `${fakeBin}:${process.env.PATH ?? ''}`, + }); + + const result = await submitPullRequestReview(repo, { + comments: [ + { + body: 'Please keep this explicit.', + filePath: 'src/app.ts', + lineNumber: 7, + localDraftId: 'draft-1', + position: { + range: { + base: { + label: { kind: 'commit' as const, text: 'aaaaaaa' }, + sha: draftBaseSha as GitSha, + }, + head: { + label: { kind: 'commit' as const, text: 'bbbbbbb' }, + sha: draftHeadSha as GitSha, + }, + }, + }, + side: 'additions', + }, + ], + event: 'COMMENT', + source: { + headSha: draftHeadSha as GitSha, + provider: 'github', + type: 'pull-request', + url: 'https://github.com/nkzw-tech/codiff/pull/12', + }, + }); + + expect(result).toMatchObject({ + reason: expect.stringContaining(expectedReason), + status: 'failed', + submittedDraftIds: [], + }); + const calls = (await readFile(callsPath, 'utf8')) + .trim() + .split('\n') + .map((line) => JSON.parse(line) as { args: ReadonlyArray }); + expect( + calls.filter((call) => call.args.includes('/repos/nkzw-tech/codiff/pulls/12/reviews')), + ).toHaveLength(0); + }, +); diff --git a/electron/__tests__/repository-watcher.test.ts b/electron/__tests__/repository-watcher.test.ts index 9e94e864..eacc6cbe 100644 --- a/electron/__tests__/repository-watcher.test.ts +++ b/electron/__tests__/repository-watcher.test.ts @@ -27,6 +27,7 @@ type Coordinator = { attach: (subscriber: { getState: () => SubscriberState; id: number; + initialSnapshot?: Snapshot | Promise; notify: (root: string) => void; root: string; }) => Promise; @@ -50,6 +51,7 @@ type Coordinator = { const require = createRequire(import.meta.url); const { createRepositoryWatcherCoordinator, + getRepositoryWatcherInitialSnapshot, getRepositoryWatcherPollInterval, normalizeRepositoryWatcherPath, parseRepositoryWatcherStatus, @@ -65,6 +67,7 @@ const { ) => Promise; setTimeoutImpl?: (callback: () => void, delay: number) => unknown; }) => Coordinator; + getRepositoryWatcherInitialSnapshot: (state: object) => Promise | undefined; getRepositoryWatcherPollInterval: (states: ReadonlyArray) => number; normalizeRepositoryWatcherPath: (path: string, pathSeparator?: string) => string; parseRepositoryWatcherStatus: (raw: string) => { head: string; paths: Array }; @@ -79,6 +82,9 @@ const { expectedPathVersions: ReadonlyMap, ) => boolean; }; +const { readRepositoryState } = require('../git-state.cjs') as { + readRepositoryState: (launchPath: string) => Promise; +}; const execFileAsync = promisify(execFile); const largeTestFileSize = 8 * 1024 * 1024; @@ -127,6 +133,44 @@ test('parses porcelain-v2 branch information and dirty paths', () => { }); }); +test('parses porcelain-v2 unmerged records as conflicted', () => { + const hash = '1'.repeat(40); + const { parsePorcelainV2Status } = require('../git-state/working-tree.cjs') as { + parsePorcelainV2Status: (raw: string) => Array<{ + conflictStage?: 1 | 2 | 3; + path: string; + staged: boolean; + status: string; + unstaged: boolean; + untracked: boolean; + }>; + }; + const unmerged = [ + ['UU', 'both-modified.ts', 2], + ['AA', 'both-added.ts', 2], + ['DD', 'both-deleted.ts', 1], + ['AU', 'added-by-us.ts', 2], + ['UA', 'added-by-them.ts'], + ['DU', 'deleted-by-us.ts'], + ['UD', 'deleted-by-them.ts', 2], + ] as const; + const raw = unmerged + .map(([xy, path]) => `u ${xy} N... 100644 100644 100644 100644 ${hash} ${hash} ${hash} ${path}`) + .concat('') + .join('\0'); + + expect(parsePorcelainV2Status(raw)).toEqual( + unmerged.map(([, path, conflictStage]) => ({ + ...(conflictStage ? { conflictStage } : {}), + path, + staged: false, + status: 'conflicted', + unstaged: true, + untracked: false, + })), + ); +}); + test('ignores only expected app-written paths', () => { expect( repositoryWatcherSnapshotsMatchExpectedWrites( @@ -202,6 +246,56 @@ test('never ignores repository HEAD changes', () => { ).toBe(false); }); +test('compares an adopted startup snapshot immediately after watcher attachment', async () => { + await using directory = await createTemporaryDirectory('codiff-initial-watcher-'); + const repository = await realpath(directory.path); + const timers: Array<{ callback: () => void; cleared: boolean; delay: number }> = []; + let reads = 0; + const coordinator = createRepositoryWatcherCoordinator({ + clearTimeoutImpl: (timer) => { + (timer as (typeof timers)[number]).cleared = true; + }, + readSnapshot: async (root, exactPaths, knownDirtyPaths) => { + reads += 1; + return readRepositoryWatcherSnapshot(root, exactPaths, knownDirtyPaths); + }, + setTimeoutImpl: (callback, delay) => { + const timer = { callback, cleared: false, delay }; + timers.push(timer); + return timer; + }, + }); + + try { + await git(repository, ['init']); + await writeFile(join(repository, 'file.txt'), 'before\n'); + await git(repository, ['add', 'file.txt']); + await git(repository, ['commit', '-m', 'initial']); + + const state = await readRepositoryState(repository); + const initialSnapshot = getRepositoryWatcherInitialSnapshot(state); + if (!initialSnapshot) { + throw new Error('Expected the initial repository state to retain a watcher snapshot.'); + } + + await writeFile(join(repository, 'file.txt'), 'after\n'); + const notifications: Array = []; + await coordinator.attach({ + getState: () => ({ focused: true, visible: true }), + id: 1, + initialSnapshot, + notify: (root) => notifications.push(root), + root: repository, + }); + + await initialSnapshot; + expect(reads).toBe(1); + expect(notifications).toEqual([repository]); + } finally { + coordinator.detach(1); + } +}, 30_000); + test('shares one watcher per repository and adapts polling to window state', async () => { const timers: Array<{ callback: () => void; cleared: boolean; delay: number }> = []; const states = new Map([ diff --git a/electron/__tests__/review-content.test.ts b/electron/__tests__/review-content.test.ts new file mode 100644 index 00000000..8407b8ca --- /dev/null +++ b/electron/__tests__/review-content.test.ts @@ -0,0 +1,218 @@ +import { execFileSync } from 'node:child_process'; +import { mkdir, writeFile } from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import { join } from 'node:path'; +import { expect, test, vi } from 'vite-plus/test'; +import { createTemporaryDirectory } from '../../core/__tests__/helpers/resources.ts'; +import type { + GitSha, + Revision, + RevisionContentBatchRequest, + RevisionContentBatchResult, +} from '../../core/types.ts'; + +const require = createRequire(import.meta.url); +const { readRevisionContent } = require('../review-content.cjs') as { + readRevisionContent( + launchPath: string, + batch: RevisionContentBatchRequest, + ): Promise; +}; +const { readGitHubFileBlobArtifacts, readGitLabFileBlobArtifacts } = + require('../git-state/provider-artifact-sources.cjs') as { + readGitHubFileBlobArtifacts( + repoRoot: string, + pull: { headSha?: string; host?: string; number?: number; owner: string; repo: string }, + requests: ReadonlyArray<{ maxBytes: number; path: string; ref: string }>, + transport?: unknown, + ): Promise>; + readGitLabFileBlobArtifacts( + repoRoot: string, + mergeRequest: { headSha?: string; host: string; number?: number; projectPath: string }, + requests: ReadonlyArray<{ maxBytes: number; path: string; ref: string }>, + transport?: unknown, + ): Promise>; + }; + +const git = (repo: string, ...args: Array) => + execFileSync('git', ['-C', repo, ...args], { encoding: 'utf8' }).trim(); +const gitSha = (value: string) => value as GitSha; +const commitRevision = (sha: string): Revision => ({ + label: { kind: 'commit', text: sha.slice(0, 7) }, + sha: gitSha(sha), +}); + +const createRepository = async () => { + const directory = await createTemporaryDirectory('codiff-review-content-'); + git(directory.path, 'init', '--quiet'); + git(directory.path, 'config', 'user.email', 'codiff@example.com'); + git(directory.path, 'config', 'user.name', 'Codiff Test'); + await mkdir(join(directory.path, 'src'), { recursive: true }); + return directory; +}; + +test('reads commit, index, and working-copy bytes in one revision batch', async () => { + await using directory = await createRepository(); + const path = 'src/file.txt'; + await writeFile(join(directory.path, path), 'committed\n'); + git(directory.path, 'add', path); + git(directory.path, 'commit', '--quiet', '-m', 'Add file'); + const sha = git(directory.path, 'rev-parse', 'HEAD'); + await writeFile(join(directory.path, path), 'indexed\n'); + git(directory.path, 'add', path); + await writeFile(join(directory.path, path), 'working\n'); + const requests = [ + { key: 'commit', maxBytes: 1024, path, revision: commitRevision(sha) }, + { + key: 'index', + maxBytes: 1024, + path, + revision: { + kind: 'index' as const, + label: { kind: 'review-marker' as const, text: 'Index' }, + }, + }, + { + key: 'working', + maxBytes: 1024, + path, + revision: { + kind: 'working-copy' as const, + label: { kind: 'review-marker' as const, text: 'Working copy' }, + }, + }, + ]; + + const result = await readRevisionContent(directory.path, { + generation: 'generation-1', + requests, + source: { type: 'working-tree' }, + }); + const contents = new Map( + result.results.flatMap((item) => + item.status === 'ready' + ? [[item.key, new TextDecoder().decode(item.value.bytes)] as const] + : [], + ), + ); + + expect(contents).toEqual( + new Map([ + ['commit', 'committed\n'], + ['index', 'indexed\n'], + ['working', 'working\n'], + ]), + ); + expect(result.results.map((item) => item.status)).toEqual(['ready', 'ready', 'ready']); +}); + +test('returns missing coordinates and bounded-read failures per item', async () => { + await using directory = await createRepository(); + await writeFile(join(directory.path, 'src/large.txt'), 'larger than four bytes'); + + const result = await readRevisionContent(directory.path, { + generation: 'generation-1', + requests: [ + { + key: 'missing', + maxBytes: 1024, + path: 'src/missing.txt', + revision: { + kind: 'working-copy', + label: { kind: 'review-marker', text: 'Working copy' }, + }, + }, + { + key: 'large', + maxBytes: 4, + path: 'src/large.txt', + revision: { + kind: 'working-copy', + label: { kind: 'review-marker', text: 'Working copy' }, + }, + }, + ], + source: { type: 'working-tree' }, + }); + + expect(result.results).toEqual([ + { key: 'missing', status: 'missing' }, + expect.objectContaining({ key: 'large', status: 'unavailable' }), + ]); +}); + +test('uses native Git before GitHub transport and falls back with normalized bytes', async () => { + await using directory = await createRepository(); + await writeFile(join(directory.path, 'src/native.txt'), 'native\n'); + git(directory.path, 'add', '.'); + git(directory.path, 'commit', '--quiet', '-m', 'Add native file'); + const sha = git(directory.path, 'rev-parse', 'HEAD'); + const transport = { + request: vi.fn(async () => ({ + content: Buffer.from('provider\n').toString('base64'), + encoding: 'base64', + sha: 'b'.repeat(40), + })), + }; + const pull = { number: 1, owner: 'example', repo: 'repo' }; + + const native = await readGitHubFileBlobArtifacts( + directory.path, + pull, + [{ maxBytes: 1024, path: 'src/native.txt', ref: sha }], + transport, + ); + const fallback = await readGitHubFileBlobArtifacts( + directory.path, + pull, + [{ maxBytes: 1024, path: 'src/provider.txt', ref: 'a'.repeat(40) }], + transport, + ); + + expect(transport.request).toHaveBeenCalledOnce(); + expect(native.get(`${sha}:src/native.txt`)?.provenance.kind).toBe('native-git'); + expect(new TextDecoder().decode(fallback.get(`${'a'.repeat(40)}:src/provider.txt`)?.bytes)).toBe( + 'provider\n', + ); + expect(fallback.get(`${'a'.repeat(40)}:src/provider.txt`)?.provenance.kind).toBe('github-api'); +}); + +test('normalizes GitLab fallback through the same commit-content adapter shape', async () => { + await using directory = await createRepository(); + const transport = { + request: vi.fn(async () => ({ + blob_id: 'c'.repeat(40), + content: Buffer.from('gitlab\n').toString('base64'), + encoding: 'base64', + })), + }; + const ref = 'd'.repeat(40); + const result = await readGitLabFileBlobArtifacts( + directory.path, + { host: 'gitlab.example.com', projectPath: 'group/project' }, + [{ maxBytes: 1024, path: 'src/provider.txt', ref }], + transport, + ); + const blob = result.get(`${ref}:src/provider.txt`); + + expect(new TextDecoder().decode(blob?.bytes)).toBe('gitlab\n'); + expect(blob?.provenance.kind).toBe('gitlab-api'); +}); + +test('rejects provider fallback after the logical review head changes', async () => { + await using directory = await createRepository(); + const expectedHead = 'a'.repeat(40); + const transport = { + request: vi.fn(async () => ({ head: { sha: 'b'.repeat(40) } })), + }; + + await expect( + readGitHubFileBlobArtifacts( + directory.path, + { headSha: expectedHead, number: 1, owner: 'example', repo: 'repo' }, + [{ maxBytes: 1024, path: 'src/provider.txt', ref: expectedHead }], + transport, + ), + ).rejects.toThrow('head changed'); + expect(transport.request).toHaveBeenCalledOnce(); +}); diff --git a/electron/__tests__/reviewed-diff-signature.test.ts b/electron/__tests__/reviewed-diff-signature.test.ts new file mode 100644 index 00000000..b523458d --- /dev/null +++ b/electron/__tests__/reviewed-diff-signature.test.ts @@ -0,0 +1,40 @@ +import { createRequire } from 'node:module'; +import { expect, test } from 'vite-plus/test'; + +const require = createRequire(import.meta.url); +const { getReviewedDiffSignature } = require('../reviewed-diff-signature.cjs') as { + getReviewedDiffSignature: ( + files: ReadonlyArray<{ + fingerprint: string; + oldPath?: string; + path: string; + sections: ReadonlyArray<{ range?: unknown }>; + status: string; + }>, + ) => string; +}; + +const file = (base: string, path = 'src/app.ts') => ({ + fingerprint: 'same-content', + path, + sections: [ + { + range: { + base: { kind: 'commit', sha: base }, + head: { kind: 'commit', sha: 'h'.repeat(40) }, + }, + }, + ], + status: 'modified', +}); + +test('reviewed diff signatures change for base-only changes and ignore file order', () => { + const first = getReviewedDiffSignature([file('a'.repeat(40)), file('a'.repeat(40), 'b.ts')]); + + expect(getReviewedDiffSignature([file('b'.repeat(40)), file('a'.repeat(40), 'b.ts')])).not.toBe( + first, + ); + expect(getReviewedDiffSignature([file('a'.repeat(40), 'b.ts'), file('a'.repeat(40))])).toBe( + first, + ); +}); diff --git a/electron/__tests__/shared-walkthrough-upload.test.ts b/electron/__tests__/shared-walkthrough-upload.test.ts index 7a30f46a..5baf5edb 100644 --- a/electron/__tests__/shared-walkthrough-upload.test.ts +++ b/electron/__tests__/shared-walkthrough-upload.test.ts @@ -2,18 +2,20 @@ import { createRequire } from 'node:module'; import { expect, test, vi } from 'vite-plus/test'; const require = createRequire(import.meta.url); -const { uploadSharedWalkthrough } = require('../shared-walkthrough-upload.cjs') as { - uploadSharedWalkthrough: (options: { - authenticate: () => Promise; - fetchImpl: typeof fetch; - openExternal: (url: string) => Promise; - openClaimPage?: boolean; - serviceUrl: string; - snapshot: unknown; - trustCertificates?: () => { reason?: string; status: string }; - uploader?: { email: string; name: string }; - }) => Promise; -}; +const { sanitizeSharedWalkthroughSnapshot, uploadSharedWalkthrough } = + require('../shared-walkthrough-upload.cjs') as { + sanitizeSharedWalkthroughSnapshot: (snapshot: any) => any; + uploadSharedWalkthrough: (options: { + authenticate: () => Promise; + fetchImpl: typeof fetch; + openExternal: (url: string) => Promise; + openClaimPage?: boolean; + serviceUrl: string; + snapshot: unknown; + trustCertificates?: () => { reason?: string; status: string }; + uploader?: { email: string; name: string }; + }) => Promise; + }; test('uploads git identity separately from the walkthrough snapshot', async () => { const authenticate = vi.fn(async () => {}); @@ -210,3 +212,31 @@ test('explains certificate trust status for certificate-chain failures', async ( 'Codiff share upload intent request failed: fetch failed: SELF_SIGNED_CERT_IN_CHAIN - self signed certificate in certificate chain System certificate trust was not applied (this Node/Electron runtime does not expose system certificate APIs).', ); }); + +test('strips hidden session context and local repository paths from walkthrough uploads', () => { + const snapshot = { + kind: 'codiff-walkthrough-share', + repository: { root: '/Users/ada/private-repo', source: { type: 'working-tree' } }, + version: 1, + walkthrough: { + context: { + messages: [{ role: 'user', text: 'Private implementation conversation.' }], + source: { threadId: 'private-session', type: 'codex-session-excerpt' }, + }, + repo: { branch: 'main', root: '/Users/ada/private-repo' }, + title: 'Review', + }, + }; + + expect(sanitizeSharedWalkthroughSnapshot(snapshot)).toEqual({ + kind: 'codiff-walkthrough-share', + repository: { root: '[redacted]', source: { type: 'working-tree' } }, + version: 1, + walkthrough: { + repo: { branch: 'main', root: '[redacted]' }, + title: 'Review', + }, + }); + expect(snapshot.walkthrough.context.messages[0].text).toContain('Private'); + expect(snapshot.repository.root).toContain('/Users/ada'); +}); diff --git a/electron/__tests__/walkthrough-commit.test.ts b/electron/__tests__/walkthrough-commit.test.ts index 046c3201..6705512b 100644 --- a/electron/__tests__/walkthrough-commit.test.ts +++ b/electron/__tests__/walkthrough-commit.test.ts @@ -19,7 +19,7 @@ const { createWalkthroughCommit } = require('../walkthrough-commit.cjs') as { repoPath: string, request: { body?: string; paths?: ReadonlyArray; subject?: string }, onOutput?: (chunk: string) => void, - ) => Promise<{ hash: string; status: 'committed' } | { reason: string; status: 'failed' }>; + ) => Promise<{ sha: string; status: 'committed' } | { reason: string; status: 'failed' }>; }; const execFileAsync = promisify(execFile); diff --git a/electron/__tests__/walkthrough-generation-cache-key.test.ts b/electron/__tests__/walkthrough-generation-cache-key.test.ts new file mode 100644 index 00000000..46fd54a7 --- /dev/null +++ b/electron/__tests__/walkthrough-generation-cache-key.test.ts @@ -0,0 +1,92 @@ +import { createRequire } from 'node:module'; +import { expect, test } from 'vite-plus/test'; +import type { GenerationProfile, GitSha, RepositoryState } from '../../core/types.ts'; + +type CacheInput = { + profile: GenerationProfile; + request: { customInstructions: string; scope: string }; + state: RepositoryState; +}; + +const require = createRequire(import.meta.url); +const { buildWalkthroughGenerationCacheIdentity, getWalkthroughGenerationCacheKey } = + require('../walkthrough-generation-cache-key.cjs') as { + buildWalkthroughGenerationCacheIdentity: (input: CacheInput) => { + source: { description: string | null; title: string | null }; + }; + getWalkthroughGenerationCacheKey: (input: CacheInput) => string; + }; + +const input = (): CacheInput => ({ + profile: { + agent: 'codex', + authoringVersion: 'format-neutral-test', + modelCandidates: ['gpt-5.6-terra'], + }, + request: { customInstructions: 'Review the change.', scope: 'complete-diff' }, + state: { + branch: 'feature/cache-key', + files: [ + { + fingerprint: 'src/app.ts:1', + path: 'src/app.ts', + sections: [ + { + binary: false, + id: 'src/app.ts:commit', + kind: 'commit', + patch: '@@ -1 +1 @@\n-old\n+new\n', + }, + ], + status: 'modified', + }, + ], + generatedAt: 1, + root: '/repo', + source: { + description: ' Explain the new behavior. ', + headSha: 'a'.repeat(40) as GitSha, + number: 42, + provider: 'github', + title: ' Improve cache behavior ', + type: 'pull-request', + url: 'https://github.com/nkzw-tech/codiff/pull/42', + }, + }, +}); + +test('cache identities normalize review prose and exclude checkout roots', () => { + const base = input(); + const movedCheckout = input(); + movedCheckout.state.root = '/another/checkout'; + + expect(getWalkthroughGenerationCacheKey(movedCheckout)).toBe( + getWalkthroughGenerationCacheKey(base), + ); + expect(buildWalkthroughGenerationCacheIdentity(base).source).toMatchObject({ + description: 'Explain the new behavior.', + title: 'Improve cache behavior', + }); +}); + +test('cache keys include semantic requests, profiles, and review identity', () => { + const base = input(); + const changedRequest = input(); + changedRequest.request.customInstructions = 'Focus on cancellation.'; + const changedProfile = input(); + changedProfile.profile = { ...changedProfile.profile, modelCandidates: ['gpt-5.6-sol'] }; + const changedReview = input(); + if (changedReview.state.source.type === 'pull-request') { + changedReview.state.source.title = 'Different review'; + } + + expect(getWalkthroughGenerationCacheKey(changedRequest)).not.toBe( + getWalkthroughGenerationCacheKey(base), + ); + expect(getWalkthroughGenerationCacheKey(changedProfile)).not.toBe( + getWalkthroughGenerationCacheKey(base), + ); + expect(getWalkthroughGenerationCacheKey(changedReview)).not.toBe( + getWalkthroughGenerationCacheKey(base), + ); +}); diff --git a/electron/__tests__/walkthrough-generation-coordinator.test.ts b/electron/__tests__/walkthrough-generation-coordinator.test.ts new file mode 100644 index 00000000..abae61f5 --- /dev/null +++ b/electron/__tests__/walkthrough-generation-coordinator.test.ts @@ -0,0 +1,67 @@ +import { createRequire } from 'node:module'; +import { expect, test } from 'vite-plus/test'; + +const require = createRequire(import.meta.url); +const { createWalkthroughGenerationCoordinator, SUPERSEDED_GENERATION_REASON } = + require('../walkthrough-generation-coordinator.cjs') as { + createWalkthroughGenerationCoordinator: () => { + begin: (key: number) => AbortController; + cancel: (key: number, reason?: unknown) => void; + clear: (key: number, reason?: unknown) => void; + finish: (key: number, controller: AbortController) => void; + getReusable: ( + key: number, + cacheKey: string, + force?: boolean, + ) => ReadonlyArray | undefined; + retain: ( + key: number, + controller: AbortController, + cacheKey: string, + components: ReadonlyArray, + ) => boolean; + }; + SUPERSEDED_GENERATION_REASON: string; + }; + +test('supersedes an active generation without letting it overwrite current retry state', () => { + const coordinator = createWalkthroughGenerationCoordinator(); + const first = coordinator.begin(7); + const second = coordinator.begin(7); + + expect(first.signal.aborted).toBe(true); + expect(first.signal.reason).toEqual(new Error(SUPERSEDED_GENERATION_REASON)); + expect(coordinator.retain(7, first, 'review-a', ['stale'])).toBe(false); + coordinator.finish(7, first); + expect(coordinator.retain(7, second, 'review-a', ['current'])).toBe(true); + expect(coordinator.getReusable(7, 'review-a')).toEqual(['current']); +}); + +test('cancel aborts active work without discarding reusable components', () => { + const coordinator = createWalkthroughGenerationCoordinator(); + const controller = coordinator.begin(9); + const components = [{ identity: 'ready-unit' }]; + const reason = new Error('The review source changed.'); + + expect(coordinator.retain(9, controller, 'review-a', components)).toBe(true); + coordinator.cancel(9, reason); + + expect(controller.signal.aborted).toBe(true); + expect(controller.signal.reason).toBe(reason); + expect(coordinator.getReusable(9, 'review-a')).toBe(components); +}); + +test('retains partial components only for matching non-forced retries', () => { + const coordinator = createWalkthroughGenerationCoordinator(); + const current = coordinator.begin(11); + const components = [{ identity: 'ready-unit' }]; + + expect(coordinator.retain(11, current, 'review-a', components)).toBe(true); + expect(coordinator.getReusable(11, 'review-a')).toBe(components); + expect(coordinator.getReusable(11, 'review-b')).toBeUndefined(); + expect(coordinator.getReusable(11, 'review-a', true)).toBeUndefined(); + + coordinator.clear(11); + expect(current.signal.aborted).toBe(true); + expect(coordinator.getReusable(11, 'review-a')).toBeUndefined(); +}); diff --git a/electron/__tests__/walkthrough-model-invocation.test.ts b/electron/__tests__/walkthrough-model-invocation.test.ts new file mode 100644 index 00000000..651e8bb6 --- /dev/null +++ b/electron/__tests__/walkthrough-model-invocation.test.ts @@ -0,0 +1,130 @@ +import { spawn } from 'node:child_process'; +import { createRequire } from 'node:module'; +import { expect, test, vi } from 'vite-plus/test'; +import type { GenerationProfile } from '../../core/types.ts'; + +const require = createRequire(import.meta.url); +const { getAgent } = require('../agent.cjs') as { getAgent: (id: string) => any }; +const { invokeWalkthroughModel, parseStructuredModelResponse } = + require('../walkthrough-model-invocation.cjs') as { + invokeWalkthroughModel: (input: any) => Promise<{ + generationMetadata: { model: string; profile: GenerationProfile }; + response: string; + }>; + parseStructuredModelResponse: (response: unknown) => unknown; + }; + +const profile: GenerationProfile = { + agent: 'codex', + authoringVersion: 'format-neutral-test', + modelCandidates: ['primary', 'intermediate', 'fallback'], +}; + +const agent = (run: (...args: Array) => Promise) => ({ + defaultTimeoutMs: 1_000, + id: 'codex', + normalizeModel: (value: unknown) => String(value), + run, +}); + +test('records an actual intermediate fallback while preserving the complete candidate chain', async () => { + const onModelFallback = vi.fn(); + const run = vi.fn(async (...args: Array) => { + expect(args[5]).toMatchObject({ fallbackModel: 'fallback', model: 'primary' }); + await args[5].onModelFallback('intermediate', 'primary'); + return '```json\n{"value":42}\n```'; + }); + + const result = await invokeWalkthroughModel({ + agent: agent(run), + agentOptions: { onModelFallback }, + generatedAt: () => '2026-08-04T00:00:00.000Z', + profile, + prompt: 'Explain the review.', + repoRoot: '/repo', + schema: { type: 'object' }, + }); + + expect(result.generationMetadata).toMatchObject({ model: 'intermediate', profile }); + expect(onModelFallback).toHaveBeenCalledWith('intermediate', 'primary'); + expect(parseStructuredModelResponse(result.response)).toEqual({ value: 42 }); +}); + +test('cancels publication while a model invocation is still pending', async () => { + const controller = new AbortController(); + const run = vi.fn(() => new Promise(() => {})); + const invocation = invokeWalkthroughModel({ + agent: agent(run), + profile, + prompt: 'Explain the review.', + repoRoot: '/repo', + schema: { type: 'object' }, + signal: controller.signal, + }); + await vi.waitFor(() => expect(run).toHaveBeenCalledTimes(1)); + + controller.abort(new Error('The review changed.')); + + await expect(invocation).rejects.toThrow('The review changed.'); +}); + +test('aborting model invocation terminates the actual agent child process', async () => { + let child: ReturnType | null = null; + let resolveStarted!: (pid: number) => void; + const started = new Promise((resolve) => { + resolveStarted = resolve; + }); + let resolveExit!: (value: { code: number | null; signal: NodeJS.Signals | null }) => void; + const exited = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve) => { + resolveExit = resolve; + }); + const fixtureSource = ` + process.stdout.write(JSON.stringify({ pid: process.pid }) + '\\n'); + process.on('SIGTERM', () => process.exit(0)); + process.stdin.resume(); + setTimeout(() => process.exit(96), 4000); + `; + const commandTransport = { + command: 'bounded-claude-fixture', + spawn: (_command: string, _args: ReadonlyArray, options: { signal?: AbortSignal }) => { + child = spawn(process.execPath, ['-e', fixtureSource], { + signal: options.signal, + stdio: ['pipe', 'pipe', 'pipe'], + }); + let output = ''; + child.stdout?.setEncoding('utf8'); + child.stdout?.on('data', (chunk: string) => { + output += chunk; + const line = output.split('\n').find(Boolean); + if (line) { + resolveStarted((JSON.parse(line) as { pid: number }).pid); + } + }); + child.once('exit', (code, signal) => resolveExit({ code, signal })); + return child; + }, + }; + const controller = new AbortController(); + const claudeAgent = getAgent('claude'); + const invocation = invokeWalkthroughModel({ + agent: claudeAgent, + agentOptions: { commandTransport }, + profile: { + agent: 'claude', + authoringVersion: 'child-cancellation-test', + modelCandidates: [claudeAgent.defaultModel], + }, + prompt: 'Wait for cancellation.', + repoRoot: '/repo', + schema: { type: 'object' }, + signal: controller.signal, + timeoutMs: 3500, + }); + const pid = await started; + controller.abort(new Error('The review changed.')); + + await expect(invocation).rejects.toThrow('The review changed.'); + await expect(exited).resolves.toMatchObject({ code: 0 }); + expect(child?.pid).toBe(pid); + expect(child?.exitCode).not.toBeNull(); +}); diff --git a/electron/__tests__/walkthrough-progress.test.ts b/electron/__tests__/walkthrough-progress.test.ts index 5049ed48..14e873c8 100644 --- a/electron/__tests__/walkthrough-progress.test.ts +++ b/electron/__tests__/walkthrough-progress.test.ts @@ -5,8 +5,8 @@ const require = createRequire(import.meta.url); const { createWalkthroughProgressReporter } = require('../walkthrough-progress.cjs') as { createWalkthroughProgressReporter: (webContents: { isDestroyed: () => boolean; - send: (channel: string, progress: { phase: string }) => void; - }) => (phase: string) => void; + send: (channel: string, progress: unknown) => void; + }) => (update: { phase: string; summary: string } | string) => void; }; test('forwards repeated real progress events while the request is current', () => { @@ -38,3 +38,19 @@ test('forwards repeated real progress events while the request is current', () = reportProgress('agent-generation'); expect(send).toHaveBeenCalledTimes(2); }); + +test('forwards structured generation progress without provider fields', () => { + const send = vi.fn(); + const reportProgress = createWalkthroughProgressReporter({ + isDestroyed: () => false, + send, + }); + const generation = { + phase: 'generating-units', + summary: 'Generating the second task.', + }; + + reportProgress(generation); + + expect(send).toHaveBeenCalledWith('codiff:walkthroughProgress', { generation }); +}); diff --git a/electron/__tests__/window-identity.test.ts b/electron/__tests__/window-identity.test.ts index b617c927..8ed911bf 100644 --- a/electron/__tests__/window-identity.test.ts +++ b/electron/__tests__/window-identity.test.ts @@ -11,41 +11,57 @@ import { } from '../../core/__tests__/helpers/resources.ts'; const require = createRequire(import.meta.url); -const { findMatchingWindowIdentity, getWindowIdentity, getWindowIdentityForRepositoryState } = - require('../window-identity.cjs') as { - findMatchingWindowIdentity: ( - identity: { key: string } | null, - existingIdentities: ReadonlyMap, - ) => number | null; - getWindowIdentity: ( - repositoryPath: string, - launchOptions?: { - source?: - | { type: 'working-tree' } - | { ref: string; type: 'branch' } - | { baseRef: string; headRef: string; ref: string; type: 'branch-diff' } - | { ref: string; type: 'commit' } - | { - number?: number; - owner?: string; - repo?: string; - type: 'pull-request'; - url: string; - }; - walkthrough?: boolean; - walkthroughFile?: string; - planFile?: string; - planResultFile?: string; - }, - ) => { key: string; repositoryRoot: string; sourceKey: string } | null; - getWindowIdentityForRepositoryState: (state: { - root: string; - source: +const { + findMatchingWindowIdentity, + getWindowIdentity, + getWindowIdentityForRepositoryState, + storeResolvedWindowState, +} = require('../window-identity.cjs') as { + findMatchingWindowIdentity: ( + identity: { key: string } | null, + existingIdentities: ReadonlyMap, + ) => number | null; + getWindowIdentity: ( + repositoryPath: string, + launchOptions?: { + source?: | { type: 'working-tree' } + | { ref: string; type: 'branch' } + | { baseSha: string; headSha: string; ref: string; type: 'branch-diff' } | { ref: string; type: 'commit' } - | { baseRef: string; headRef: string; ref: string; type: 'branch-diff' }; - }) => { key: string; repositoryRoot: string; sourceKey: string } | null; - }; + | { + number?: number; + owner?: string; + repo?: string; + type: 'pull-request'; + url: string; + }; + walkthrough?: boolean; + walkthroughFile?: string; + planFile?: string; + planResultFile?: string; + }, + ) => { key: string; repositoryRoot: string; sourceKey: string } | null; + getWindowIdentityForRepositoryState: (state: { + root: string; + source: + | { type: 'working-tree' } + | { sha: string; type: 'commit' } + | { baseSha: string; headSha: string; ref: string; type: 'branch-diff' }; + }) => { key: string; repositoryRoot: string; sourceKey: string } | null; + storeResolvedWindowState: ( + webContentsId: number, + state: { root: string; source: { type: 'working-tree' } }, + stores: { + identities: Map; + launchOptions: Map< + number, + { repositoryPathProvided: boolean; source?: { type: 'working-tree' }; walkthrough: boolean } + >; + repositories: Map; + }, + ) => { key: string; repositoryRoot: string; sourceKey: string } | null; +}; const execFileAsync = promisify(execFile); @@ -141,7 +157,7 @@ test.sequential('resolved repository states build identities without invoking Gi expect( getWindowIdentityForRepositoryState({ root: repository.path, - source: { ref: head, type: 'commit' }, + source: { sha: head, type: 'commit' }, }), ).toMatchObject({ repositoryRoot: await realpath(repository.path), @@ -189,7 +205,7 @@ test('window identities distinguish branch history launches', async () => { expect( getWindowIdentity(directory.path, { - source: { baseRef: head, headRef: nextHead, ref: 'feature', type: 'branch-diff' }, + source: { baseSha: head, headSha: nextHead, ref: 'feature', type: 'branch-diff' }, })?.sourceKey, ).toBe(`branch-diff:feature:${head}:${nextHead}`); }); @@ -205,7 +221,7 @@ test('window identities normalize GitHub pull request sources', async () => { url: 'https://github.com/NKZW-Tech/Codiff/pull/8', }, })?.sourceKey, - ).toBe('pull-request:nkzw-tech/codiff#8'); + ).toBe('pull-request:github:github.com:nkzw-tech/codiff#8'); expect( getWindowIdentity(directory.path, { source: { @@ -237,7 +253,7 @@ test('window identities normalize pull request URLs copied from a review tab', a url: 'https://github.com/NKZW-Tech/Codiff/pull/8/changes#r4821', }, })?.sourceKey, - ).toBe('pull-request:nkzw-tech/codiff#8'); + ).toBe('pull-request:github:github.com:nkzw-tech/codiff#8'); expect( getWindowIdentity(directory.path, { source: { @@ -245,7 +261,7 @@ test('window identities normalize pull request URLs copied from a review tab', a url: 'https://gitlab.example.com/group/subgroup/project/-/merge_requests/8/diffs', }, })?.sourceKey, - ).toBe('pull-request:gitlab:gitlab.example.com/group/subgroup/project#8'); + ).toBe('pull-request:gitlab:gitlab.example.com:group/subgroup/project#8'); }); test('window identities normalize GitLab merge request sources', async () => { @@ -259,7 +275,7 @@ test('window identities normalize GitLab merge request sources', async () => { url: 'https://gitlab.example.com/group/subgroup/project/-/merge_requests/8', }, })?.sourceKey, - ).toBe('pull-request:gitlab:gitlab.example.com/group/subgroup/project#8'); + ).toBe('pull-request:gitlab:gitlab.example.com:group/subgroup/project#8'); }); test('window identity matching requires exact identity matches', () => { @@ -282,3 +298,49 @@ test('window identity matching requires exact identity matches', () => { null, ); }); + +test('retargeting one viewport allows duplicate working-tree identities', () => { + const existingWorkingTreeIdentity = { + key: '/repo\0working-tree', + repositoryRoot: '/repo', + sourceKey: 'working-tree', + }; + const identities = new Map([ + [ + 1, + { + key: '/repo\0pull-request:example/repo#12', + repositoryRoot: '/repo', + sourceKey: 'pull-request:example/repo#12', + }, + ], + [2, existingWorkingTreeIdentity], + ]); + const launchOptions = new Map([ + [1, { repositoryPathProvided: true, walkthrough: false }], + [ + 2, + { + repositoryPathProvided: true, + source: { type: 'working-tree' as const }, + walkthrough: false, + }, + ], + ]); + const repositories = new Map([ + [1, '/repo'], + [2, '/repo'], + ]); + + const identity = storeResolvedWindowState( + 1, + { root: '/repo', source: { type: 'working-tree' } }, + { identities, launchOptions, repositories }, + ); + + expect(identity?.sourceKey).toBe('working-tree'); + expect(identities.get(1)?.sourceKey).toBe('working-tree'); + expect(identities.get(2)).toBe(existingWorkingTreeIdentity); + expect(launchOptions.get(1)?.source).toEqual({ type: 'working-tree' }); + expect(findMatchingWindowIdentity(identity, identities)).toBe(1); +}); diff --git a/electron/agent.cjs b/electron/agent.cjs index 29ff8efb..88d91894 100644 --- a/electron/agent.cjs +++ b/electron/agent.cjs @@ -28,6 +28,7 @@ const { readPiSessionContext } = require('./pi-session-context.cjs'); * onPartialText?: (delta: string) => void; * onProgress?: (phase: import('../core/types.ts').WalkthroughProgressPhase) => void; * reasoningEffort?: 'low' | 'medium' | 'high'; + * signal?: AbortSignal; * timeoutMs?: number; * }} AgentOptions * @typedef {{ @@ -39,6 +40,7 @@ const { readPiSessionContext } = require('./pi-session-context.cjs'); * models: ReadonlyArray<{id: string; label: string}>; * defaultModel: string; * fallbackModel: string; + * getModelCandidates: (model: unknown) => ReadonlyArray; * modelSettingKey: 'openAIModel' | 'claudeModel' | 'opencodeModel' | 'piModel'; * normalizeModel: (value: unknown) => string; * notFoundCode: string; @@ -88,6 +90,10 @@ const createCodexAgent = () => ({ models: codex.OPENAI_MODELS, defaultModel: codex.DEFAULT_OPENAI_MODEL, fallbackModel: codex.FALLBACK_OPENAI_MODEL, + getModelCandidates: (model) => { + const normalized = codex.normalizeOpenAIModel(model); + return [normalized, ...codex.getOpenAIModelFallbacks(normalized)]; + }, modelSettingKey: 'openAIModel', normalizeModel: codex.normalizeOpenAIModel, notFoundCode: codex.CODEX_NOT_FOUND_CODE, @@ -108,6 +114,12 @@ const createClaudeAgent = () => ({ models: claude.CLAUDE_MODELS, defaultModel: claude.DEFAULT_CLAUDE_MODEL, fallbackModel: claude.FALLBACK_CLAUDE_MODEL, + getModelCandidates: (model) => [ + ...new Set([ + claude.normalizeClaudeModel(model), + claude.normalizeClaudeModel(claude.FALLBACK_CLAUDE_MODEL), + ]), + ], modelSettingKey: 'claudeModel', normalizeModel: claude.normalizeClaudeModel, notFoundCode: claude.CLAUDE_NOT_FOUND_CODE, @@ -128,6 +140,12 @@ const createOpenCodeAgent = () => ({ models: opencode.OPENCODE_MODELS, defaultModel: opencode.DEFAULT_OPENCODE_MODEL, fallbackModel: opencode.FALLBACK_OPENCODE_MODEL, + getModelCandidates: (model) => [ + ...new Set([ + opencode.normalizeOpenCodeModel(model), + opencode.normalizeOpenCodeModel(opencode.FALLBACK_OPENCODE_MODEL), + ]), + ], modelSettingKey: 'opencodeModel', normalizeModel: opencode.normalizeOpenCodeModel, notFoundCode: opencode.OPENCODE_NOT_FOUND_CODE, @@ -149,6 +167,7 @@ const createPiAgent = () => ({ models: pi.PI_MODELS, defaultModel: pi.DEFAULT_PI_MODEL, fallbackModel: pi.FALLBACK_PI_MODEL, + getModelCandidates: (model) => [pi.normalizePiModel(model)], modelSettingKey: 'piModel', normalizeModel: pi.normalizePiModel, notFoundCode: pi.PI_NOT_FOUND_CODE, diff --git a/electron/claude.cjs b/electron/claude.cjs index 78ea976e..4e0058cb 100644 --- a/electron/claude.cjs +++ b/electron/claude.cjs @@ -28,6 +28,7 @@ const CLAUDE_NOT_LOGGED_IN_MESSAGE = * model?: string; * onModelFallback?: (fallbackModel: string, originalModel: string) => Promise | void; * onProgress?: (phase: import('../core/types.ts').WalkthroughProgressPhase) => void; + * signal?: AbortSignal; * timeoutMs?: number; * }} ClaudeOptions */ @@ -270,6 +271,7 @@ const runClaude = async ( const child = commandTransport.spawn(commandTransport.command, claudeArgs, { cwd: repoRoot, env: environment, + signal: options.signal, stdio: ['pipe', 'pipe', 'pipe'], }); const streamParser = streamProgress ? createClaudeStreamParser(options.onProgress) : null; diff --git a/electron/codex.cjs b/electron/codex.cjs index 8ae1144b..d1eabc62 100644 --- a/electron/codex.cjs +++ b/electron/codex.cjs @@ -44,6 +44,7 @@ const CODEX_NOT_FOUND_MESSAGE = * onModelFallback?: (fallbackModel: string, originalModel: string) => Promise | void; * onProgress?: (phase: import('../core/types.ts').WalkthroughProgressPhase) => void; * reasoningEffort?: 'low' | 'medium' | 'high'; + * signal?: AbortSignal; * timeoutMs?: number; * }} CodexOptions */ @@ -424,6 +425,7 @@ const runCodex = async ( ]; const child = commandTransport.spawn(commandTransport.command, codexArgs, { env: environment, + signal: options.signal, stdio: ['pipe', 'pipe', 'pipe'], }); const eventParser = createCodexEventParser(options.onProgress); @@ -514,6 +516,7 @@ const runCodex = async ( { cwd: repoRoot, env: environment, + signal: options.signal, stdio: ['pipe', 'pipe', 'pipe'], }, ); @@ -821,6 +824,7 @@ module.exports = { DEFAULT_OPENAI_MODEL, FALLBACK_OPENAI_MODEL, getCodexCommand, + getOpenAIModelFallbacks, isCodexNotFoundError, normalizeOpenAIModel, OPENAI_MODELS, diff --git a/electron/command-log.cjs b/electron/command-log.cjs new file mode 100644 index 00000000..5c2431b0 --- /dev/null +++ b/electron/command-log.cjs @@ -0,0 +1,353 @@ +// @ts-check + +const { appendFile, chmod, mkdir, writeFile } = require('node:fs/promises'); +const { AsyncLocalStorage } = require('node:async_hooks'); +const { dirname, join } = require('node:path'); +const { performance } = require('node:perf_hooks'); + +/** @type {string | null} */ +let commandLogPath = null; +let nextTimingId = 0; +let pendingWrites = Promise.resolve(); +/** @type {AsyncLocalStorage<{actionId: number, signal: AbortSignal}>} */ +const actionContext = new AsyncLocalStorage(); + +const secretFlag = /(?:authorization|password|private[-_]?token|secret|token)/i; + +/** @param {unknown} error */ +const processErrorOutcome = (error) => { + if (!error || typeof error !== 'object') { + return { exitCode: null, signal: null }; + } + const result = /** @type {{code?: unknown, exitCode?: unknown, signal?: unknown}} */ (error); + return { + exitCode: + typeof result.exitCode === 'number' + ? result.exitCode + : typeof result.code === 'number' + ? result.code + : null, + signal: typeof result.signal === 'string' ? result.signal : null, + }; +}; + +/** @param {string} value */ +const sanitizeValue = (value) => { + if (/^https?:/i.test(value) || (value.startsWith('/') && value.includes('?'))) { + try { + const absolute = /^https?:/i.test(value); + const url = new URL(value, 'https://codiff.invalid'); + if (url.username) url.username = '[REDACTED]'; + if (url.password) url.password = '[REDACTED]'; + for (const key of url.searchParams.keys()) { + if (secretFlag.test(key)) { + url.searchParams.set(key, '[REDACTED]'); + } + } + return absolute ? url.toString() : `${url.pathname}${url.search}`; + } catch { + return value; + } + } + const header = /^([^:]+):\s*(.*)$/.exec(value); + if (header && secretFlag.test(header[1])) { + return `${header[1]}: [REDACTED]`; + } + const equals = value.indexOf('='); + return equals > 0 && secretFlag.test(value.slice(0, equals)) + ? `${value.slice(0, equals + 1)}[REDACTED]` + : value; +}; + +/** @param {ReadonlyArray} args */ +const sanitizeArgs = (args) => { + let redactNext = false; + return args.map((argument) => { + if (redactNext) { + redactNext = false; + return '[REDACTED]'; + } + const lower = argument.toLowerCase(); + if (lower === '--header' || lower === '-h') { + redactNext = true; + return argument; + } + if (lower.startsWith('--header=')) { + return `${argument.slice(0, argument.indexOf('=') + 1)}${sanitizeValue( + argument.slice(argument.indexOf('=') + 1), + )}`; + } + if (lower.startsWith('-h') && argument.length > 2) { + return `${argument.slice(0, 2)}${sanitizeValue(argument.slice(2))}`; + } + if (secretFlag.test(argument.split('=')[0])) { + return argument.includes('=') ? sanitizeValue(argument) : argument; + } + return sanitizeValue(argument); + }); +}; + +/** @param {unknown} value @param {string} [key] */ +const sanitizeDetailValue = (value, key = '') => { + if (secretFlag.test(key)) return '[REDACTED]'; + if (typeof value === 'string') return sanitizeValue(value); + if (Array.isArray(value)) return value.map((item) => sanitizeDetailValue(item)); + if (value instanceof Error) { + return { + message: sanitizeValue(value.message), + name: value.name, + ...(value.cause == null ? {} : { cause: sanitizeDetailValue(value.cause) }), + }; + } + if (value && typeof value === 'object') { + return Object.fromEntries( + Object.entries(value).map(([entryKey, item]) => [ + entryKey, + sanitizeDetailValue(item, entryKey), + ]), + ); + } + return value; +}; + +/** @param {Record} details */ +const sanitizeDetails = (details) => + /** @type {Record} */ (sanitizeDetailValue(details)); + +/** @param {() => Promise} write */ +const enqueueWrite = (write) => { + pendingWrites = pendingWrites.then(write, write).catch(() => { + // Diagnostics must never interrupt the action being measured. + }); +}; + +/** @param {string} path @param {Record} record */ +const appendRecord = (path, record) => { + enqueueWrite(async () => { + await appendFile(path, `${JSON.stringify(record)}\n`, 'utf8'); + }); +}; + +/** + * Start a fresh command log for this Electron session. + * @param {string} logsDirectory + * @param {{processStartedAt?: string}} [options] + */ +const configureCommandLog = (logsDirectory, options = {}) => { + const path = process.env.CODIFF_COMMAND_LOG_PATH || join(logsDirectory, 'commands.jsonl'); + if (commandLogPath === path) { + return path; + } + commandLogPath = path; + nextTimingId = 0; + enqueueWrite(async () => { + const directory = dirname(path); + await mkdir(directory, { mode: 0o700, recursive: true }); + await chmod(directory, 0o700); + await writeFile( + path, + `${JSON.stringify({ + event: 'session-start', + monotonicMs: 0, + pid: process.pid, + timestamp: options.processStartedAt || new Date().toISOString(), + })}\n`, + { encoding: 'utf8', mode: 0o600 }, + ); + await chmod(path, 0o600); + }); + return path; +}; + +/** + * @param {{ + * args?: ReadonlyArray; + * command: string; + * cwd?: string; + * details?: Record; + * kind?: 'action' | 'command'; + * }} input + */ +const startCommandTiming = (input) => { + const path = commandLogPath; + const contextActionId = actionContext.getStore()?.actionId; + const implicitActionId = + path && input.kind !== 'action' && !contextActionId ? (nextTimingId += 1) : null; + const id = (nextTimingId += 1); + const startedAt = performance.now(); + const startedTimestamp = new Date().toISOString(); + const parentActionId = contextActionId || implicitActionId; + let finished = false; + if (path) { + if (implicitActionId) { + appendRecord(path, { + command: 'standalone-command', + details: sanitizeDetails({ executable: input.command }), + event: 'start', + id: implicitActionId, + kind: 'action', + timestamp: startedTimestamp, + }); + } + appendRecord(path, { + ...(input.args ? { args: sanitizeArgs(input.args) } : {}), + ...(parentActionId ? { actionId: parentActionId } : {}), + command: input.command, + ...(input.cwd ? { cwd: input.cwd } : {}), + ...(input.details ? { details: sanitizeDetails(input.details) } : {}), + event: 'start', + id, + kind: input.kind || 'command', + timestamp: startedTimestamp, + }); + } + + return { + id, + /** @param {{canceled?: boolean, error?: unknown, exitCode?: number | null, signal?: string | null, timedOut?: boolean}} [result] */ + finish: (result = {}) => { + if (finished) { + return; + } + finished = true; + if (!path) { + return; + } + const failed = result.error != null || (result.exitCode != null && result.exitCode !== 0); + const errorOutcome = processErrorOutcome(result.error); + const exitCode = + result.exitCode ?? + errorOutcome.exitCode ?? + (input.kind !== 'action' && !failed && !result.canceled && !result.timedOut ? 0 : null); + const signal = result.signal || errorOutcome.signal; + const durationMs = Math.round((performance.now() - startedAt) * 100) / 100; + const status = result.timedOut + ? 'timeout' + : result.canceled + ? 'canceled' + : failed + ? 'error' + : 'ok'; + appendRecord(path, { + ...(parentActionId ? { actionId: parentActionId } : {}), + ...(result.canceled ? { canceled: true } : {}), + command: input.command, + durationMs, + ...(result.error != null + ? { errorName: result.error instanceof Error ? result.error.name : 'Error' } + : {}), + event: 'finish', + ...(exitCode != null ? { exitCode } : {}), + id, + kind: input.kind || 'command', + ...(signal ? { signal } : {}), + status, + timestamp: new Date().toISOString(), + ...(result.timedOut ? { timedOut: true } : {}), + }); + if (implicitActionId) { + appendRecord(path, { + command: 'standalone-command', + durationMs, + event: 'finish', + id: implicitActionId, + kind: 'action', + status, + timestamp: new Date().toISOString(), + }); + } + }, + }; +}; + +/** @param {string} name @param {{actionId?: number, details?: Record, monotonicMs?: number, timestamp?: string}} [options] */ +const recordCommandMilestone = (name, options = {}) => { + if (!commandLogPath) { + return; + } + appendRecord(commandLogPath, { + actionId: options.actionId || actionContext.getStore()?.actionId, + ...(options.details ? { details: sanitizeDetails(options.details) } : {}), + event: 'milestone', + monotonicMs: options.monotonicMs ?? performance.now(), + name, + timestamp: options.timestamp || new Date().toISOString(), + }); +}; + +/** + * Return the cancellation boundary for the enclosing command action, if one + * exists. Canonical process runners use this as their default signal so a + * caller cannot accidentally leave a startup command outside its action. + * + * @returns {AbortSignal | undefined} + */ +const getCommandActionSignal = () => actionContext.getStore()?.signal; + +/** @param {{command: string, cwd?: string, details?: Record}} input */ +const startCommandAction = (input) => { + const timing = startCommandTiming({ ...input, kind: 'action' }); + const controller = new AbortController(); + let finished = false; + + /** @param {{canceled?: boolean, error?: unknown, exitCode?: number | null, signal?: string | null, timedOut?: boolean}} [result] */ + const finish = (result = {}) => { + if (finished) { + return; + } + finished = true; + if (result.canceled && !controller.signal.aborted) { + controller.abort(); + } + timing.finish(result); + }; + + return { + ...timing, + cancel: () => finish({ canceled: true }), + finish, + signal: controller.signal, + /** @template Value @param {() => Value} callback */ + run: (callback) => + actionContext.run({ actionId: timing.id, signal: controller.signal }, callback), + }; +}; + +/** + * @template Value + * @param {{command: string, cwd?: string, details?: Record}} input + * @param {() => Promise} callback + */ +const runWithCommandAction = async (input, callback) => { + const timing = startCommandAction(input); + return timing.run(async () => { + try { + const value = await callback(); + timing.finish(); + return value; + } catch (error) { + timing.finish({ + canceled: error instanceof Error && error.name === 'AbortError', + error, + }); + throw error; + } + }); +}; + +const flushCommandLog = () => pendingWrites; +const disableCommandLog = () => { + commandLogPath = null; +}; + +module.exports = { + configureCommandLog, + disableCommandLog, + flushCommandLog, + getCommandActionSignal, + recordCommandMilestone, + runWithCommandAction, + sanitizeArgs, + startCommandAction, + startCommandTiming, +}; diff --git a/electron/definition-search.cjs b/electron/definition-search.cjs index 648fbb23..a2ce282a 100644 --- a/electron/definition-search.cjs +++ b/electron/definition-search.cjs @@ -301,21 +301,18 @@ const resolveSearchRevision = async (request, repoPath) => { let base = null; let mergeBase = false; if (source.type === 'commit') { - head = source.ref; - base = `${source.ref}^`; + head = source.sha; + base = `${source.sha}^`; } else if (source.type === 'range') { head = source.head; base = source.base; mergeBase = source.symmetric; } else if (source.type === 'branch-diff') { - head = source.headRef; - base = source.baseRef; - } else if (source.type === 'branch-working-tree' && source.headRef) { - head = source.headRef; - base = source.baseRef || `${source.headRef}^`; - } else if (source.type === 'branch') { - head = source.ref; - base = `${source.ref}^`; + head = source.headSha; + base = source.baseSha; + } else if (source.type === 'branch-working-tree' && source.headSha) { + head = source.headSha; + base = source.baseSha || `${source.headSha}^`; } else if (source.type === 'pull-request' && source.number != null) { const namespace = source.provider === 'gitlab' ? 'merge-requests' : 'pull-requests'; head = `refs/codiff/${namespace}/${source.number}/head`; diff --git a/electron/generated-files.cjs b/electron/generated-files.cjs index 3dca624c..b28dc52c 100644 --- a/electron/generated-files.cjs +++ b/electron/generated-files.cjs @@ -15,21 +15,6 @@ const isGeneratedAttributeValue = (value) => /** @param {string} value */ const isNotGeneratedAttributeValue = (value) => value === 'unset' || value === 'false'; -/** @param {import('../core/types.ts').ReviewSource} source */ -const getGeneratedAttributeSource = (source) => - source.type === 'commit' - ? source.ref - : source.type === 'range' - ? source.head - : source.type === 'branch-diff' - ? source.headRef - : source.type === 'pull-request' - ? source.headSha - : // `branch-working-tree` includes live uncommitted files, so the generated - // attribute state must be computed live (like `working-tree`) rather than - // pinned to a fixed ref. - undefined; - /** @param {Buffer} output */ const parseGeneratedAttributeStates = (output) => { const fields = output.toString('utf8').split('\0'); @@ -89,15 +74,18 @@ const readGeneratedAttributeStatesFromTree = async (repoRoot, paths, source) => /** * @param {string} repoRoot * @param {ReadonlyArray} paths - * @param {string | undefined} source + * @param {import('../core/types.ts').Revision} revision */ -const readGeneratedAttributeStates = async (repoRoot, paths, source) => { +const readRevisionGeneratedAttributeStates = async (repoRoot, paths, revision) => { if (paths.length === 0) { return new Map(); } + const kind = revision.kind || 'commit'; + const source = kind === 'commit' && 'sha' in revision ? revision.sha : undefined; + const options = kind === 'index' ? ['--cached'] : source ? ['--source', source] : []; try { - return await checkGeneratedAttributeStates(repoRoot, paths, source ? ['--source', source] : []); + return await checkGeneratedAttributeStates(repoRoot, paths, options); } catch { if (source) { try { @@ -129,19 +117,22 @@ const applyGeneratedAttributeStates = (state, generatedAttributeStates) => ({ }), }); -/** @param {import('../core/types.ts').RepositoryState} state */ -const annotateGeneratedFiles = async (state) => +/** + * @param {import('../core/types.ts').RepositoryState} state + * @param {import('../core/types.ts').Revision} revision + */ +const annotateGeneratedFiles = async (state, revision) => applyGeneratedAttributeStates( state, - await readGeneratedAttributeStates( + await readRevisionGeneratedAttributeStates( state.root, state.files.map((file) => file.path), - getGeneratedAttributeSource(state.source), + revision, ), ); module.exports = { annotateGeneratedFiles, applyGeneratedAttributeStates, - readGeneratedAttributeStates, + readRevisionGeneratedAttributeStates, }; diff --git a/electron/git-state.cjs b/electron/git-state.cjs index 243b6d67..cf92f08f 100644 --- a/electron/git-state.cjs +++ b/electron/git-state.cjs @@ -1,73 +1,66 @@ // @ts-check -const { git, gitOrEmpty, parseStatus, validateRepositoryPath } = require('./git-state/common.cjs'); +const { + git, + gitOrEmpty, + parseStatus, + runWithCommandSignal, + validateRepositoryPath, +} = require('./git-state/common.cjs'); const { listRepositoryHistory, - readBranchImageContent, - readBranchSectionContent, readBranchState, - readBranchWorkingTreeImageContent, - readBranchWorkingTreeSectionContent, readBranchWorkingTreeState, - readCommitImageContent, - readCommitSectionContent, readCommitState, readResolvedCommitState, - readRangeImageContent, - readRangeSectionContent, readRangeState, } = require('./git-state/commit.cjs'); -const { parseRepositoryWatcherStatus } = require('./repository-watcher.cjs'); +const { + createRepositoryWatcherSnapshot, + parseRepositoryWatcherStatus, + setRepositoryWatcherInitialSnapshot, + transferRepositoryWatcherInitialSnapshot, +} = require('./repository-watcher.cjs'); const { PENDING_REVIEW_COMMENT_ERROR, collectResolvedReviewCommentIds, createPullRequestHistoryFetchRefspecs, - createPullRequestSection, createPullRequestSource, - getPullRequestHeadImageSource, listPullRequestHistory, normalizeGitHubPullRequestCommit, normalizeGitHubReviewComment, normalizePullRequestComment, parseGitHubPullRequestUrl, - readPullRequestImageContent, + readPullRequestReviewComments, readPullRequestState, - resolvePullRequestContentRefs, selectUnresolvedReviewComments, submitPullRequestComment, submitPullRequestReview, } = require('./git-state/pull-request.cjs'); +const { createPullRequestSection } = require('./git-state/review-range-sections.cjs'); const { createGitLabPosition, createMergeRequestFetchRefspecs, listMergeRequestHistory, normalizeGitLabReviewComment, parseGitLabMergeRequestUrl, - readMergeRequestImageContent, + readMergeRequestReviewComments, readMergeRequestState, submitMergeRequestComment, submitMergeRequestReview, } = require('./git-state/merge-request.cjs'); const { parseReviewUrl } = require('./review-source.cjs'); -const { - readDiffSectionContent: readWorkingTreeDiffSectionContent, - readDiffImageContent: readWorkingTreeDiffImageContent, - readGitIdentity, - readRepositoryChangeSignature, - readWorkingTreeState, -} = require('./git-state/working-tree.cjs'); +const { readGitIdentity, readWorkingTreeState } = require('./git-state/working-tree.cjs'); const { annotateGeneratedFiles } = require('./generated-files.cjs'); +const { readRevisionContent } = require('./review-content.cjs'); /** - * @typedef {import('../core/types.ts').DiffSectionContentRequest} DiffSectionContentRequest - * @typedef {import('../core/types.ts').DiffImageContentRequest} DiffImageContentRequest - * @typedef {import('../core/types.ts').DiffImageContentResult} DiffImageContentResult * @typedef {import('../core/types.ts').RepositoryHistory} RepositoryHistory * @typedef {import('../core/types.ts').RepositoryState} RepositoryState * @typedef {import('../core/types.ts').ReviewSource} ReviewSource */ -/** @param {string} launchPath @param {ReviewSource} [source] @param {{showWhitespace?: boolean}} [options] @returns {Promise} */ +/** @param {string} launchPath @param {ReviewSource} [source] @param {{repositoryRoot?: string; showWhitespace?: boolean}} [options] @returns {Promise} */ const readRepositoryState = async (launchPath, source = { type: 'working-tree' }, options = {}) => { const state = source.type === 'pull-request' @@ -87,6 +80,7 @@ const readRepositoryState = async (launchPath, source = { type: 'working-tree' } }) : await readWorkingTreeState(launchPath, { eagerContents: false, + repositoryRoot: options.repositoryRoot, showWhitespace: options.showWhitespace, }); const comparisonState = @@ -94,11 +88,26 @@ const readRepositoryState = async (launchPath, source = { type: 'working-tree' } source.type === 'range' || source.type === 'branch' || source.type === 'branch-diff'; + const generatedRevision = + state.source.type === 'pull-request' && state.source.headSha + ? { + label: { kind: 'commit', text: state.source.headSha.slice(0, 7) }, + sha: state.source.headSha, + } + : { + kind: /** @type {const} */ ('working-copy'), + label: { kind: /** @type {const} */ ('review-marker'), text: 'Working Copy' }, + }; const [branch, annotatedState] = await Promise.all([ - gitOrEmpty(state.root, ['symbolic-ref', '--short', 'HEAD']), - comparisonState ? state : annotateGeneratedFiles(state), + source.type === 'pull-request' + ? Promise.resolve('') + : gitOrEmpty(state.root, ['symbolic-ref', '--short', 'HEAD']), + comparisonState ? state : annotateGeneratedFiles(state, generatedRevision), ]); - return { ...annotatedState, branch: branch.trim() || null }; + return transferRepositoryWatcherInitialSnapshot(state, { + ...annotatedState, + branch: branch.trim() || null, + }); }; /** @@ -137,16 +146,19 @@ const readWalkthroughRepositoryState = async (launchPath, source, options = {}) return { ...state, branch }; } - return { - branch, - files: [], - generatedAt: Date.now(), - launchPath, - root: repoRoot, - source: { - type: 'working-tree', + return setRepositoryWatcherInitialSnapshot( + { + branch, + files: [], + generatedAt: Date.now(), + launchPath, + root: repoRoot, + source: { + type: 'working-tree', + }, }, - }; + createRepositoryWatcherSnapshot(repoRoot, status), + ); }; /** @param {Extract} source */ @@ -155,8 +167,8 @@ const isGitLabReviewSource = (source) => /** @param {Extract} source */ const getBranchHistoryRef = (source) => - source.type !== 'branch' && source.baseRef && source.headRef - ? `${source.baseRef}..${source.headRef}` + source.type !== 'branch' && source.baseSha && source.headSha + ? `${source.baseSha}..${source.headSha}` : `${source.ref}..HEAD`; /** @param {string} launchPath @param {number} [limit] @param {ReviewSource} [source] @returns {Promise} */ @@ -177,50 +189,12 @@ const readRepositoryHistory = (launchPath, limit, source) => : undefined, ); -/** @param {string} launchPath @param {DiffSectionContentRequest} request */ -const readDiffSectionContent = async (launchPath, request) => - request.source?.type === 'range' - ? readRangeSectionContent( - launchPath, - request.source.base, - request.source.head, - request.source.symmetric, - request.path, - { force: request.force }, - ) - : request.source?.type === 'branch' || request.source?.type === 'branch-diff' - ? readBranchSectionContent(launchPath, request.source, request.path, { - force: request.force, - }) - : request.source?.type === 'branch-working-tree' - ? readBranchWorkingTreeSectionContent(launchPath, request) - : request.kind === 'commit' || request.source?.type === 'commit' - ? readCommitSectionContent(launchPath, request.source?.ref || 'HEAD', request.path, { - force: request.force, - }) - : readWorkingTreeDiffSectionContent(launchPath, request); - -/** @param {string} launchPath @param {DiffImageContentRequest} request @returns {Promise} */ -const readDiffImageContent = (launchPath, request) => - request.source?.type === 'pull-request' - ? (isGitLabReviewSource(request.source) - ? readMergeRequestImageContent - : readPullRequestImageContent)(launchPath, request.source, request.path) - : request.source?.type === 'range' - ? readRangeImageContent( - launchPath, - request.source.base, - request.source.head, - request.source.symmetric, - request.path, - ) - : request.source?.type === 'branch' || request.source?.type === 'branch-diff' - ? readBranchImageContent(launchPath, request.source, request.path) - : request.source?.type === 'branch-working-tree' - ? readBranchWorkingTreeImageContent(launchPath, request) - : request.kind === 'commit' || request.source?.type === 'commit' - ? readCommitImageContent(launchPath, request.source?.ref || 'HEAD', request.path) - : readWorkingTreeDiffImageContent(launchPath, request); +/** @param {string} launchPath @param {Extract} source */ +const readReviewComments = (launchPath, source) => + (isGitLabReviewSource(source) ? readMergeRequestReviewComments : readPullRequestReviewComments)( + launchPath, + source, + ); module.exports = { PENDING_REVIEW_COMMENT_ERROR, @@ -230,7 +204,6 @@ module.exports = { createMergeRequestFetchRefspecs, createPullRequestSection, createPullRequestSource, - getPullRequestHeadImageSource, listRepositoryHistory: readRepositoryHistory, normalizeGitHubPullRequestCommit, normalizeGitHubReviewComment, @@ -241,16 +214,15 @@ module.exports = { parseGitLabMergeRequestUrl, selectUnresolvedReviewComments, readBranchState, - readDiffSectionContent, - readDiffImageContent, readGitIdentity, - readRepositoryChangeSignature, + readReviewComments, readCommitState, readPullRequestState, + readRevisionContent, readRepositoryState, readWalkthroughRepositoryState, readWorkingTreeState, - resolvePullRequestContentRefs, + runWithCommandSignal, submitPullRequestComment: (launchPath, request) => (isGitLabReviewSource(request.source) ? submitMergeRequestComment : submitPullRequestComment)( launchPath, diff --git a/electron/git-state/commit-metadata.cjs b/electron/git-state/commit-metadata.cjs index 6c26e8c9..4b5e3515 100644 --- a/electron/git-state/commit-metadata.cjs +++ b/electron/git-state/commit-metadata.cjs @@ -5,6 +5,7 @@ const { fileSort, getGravatarHash, git, gitBufferWithInput, gitOrEmpty } = requi /** * @typedef {import('../../core/types.ts').CommitMetadata} CommitMetadata * @typedef {import('../../core/types.ts').CommitMetadataFile} CommitMetadataFile + * @typedef {import('../../core/types.ts').GitSha} GitSha * @typedef {import('./common.cjs').StatusItem} StatusItem * @typedef {{additions?: number; binary: boolean; deletions?: number; path: string}} NumstatItem */ @@ -124,9 +125,9 @@ const readCommitMessageParts = async (repoRoot, subject, body) => { const parseCommitMetadataHeader = (raw) => { const parts = raw.split('\0'); const [ - ref, - shortRef, - parents, + sha, + shortSha, + parentShas, authorName, authorEmail, authorDate, @@ -144,9 +145,11 @@ const parseCommitMetadataHeader = (raw) => { author: createCommitMetadataPerson(authorName, authorEmail, authorDate), body: body || '', committer: createCommitMetadataPerson(committerName, committerEmail, committerDate), - parents: parents ? parents.split(' ').filter(Boolean) : [], - ref: ref || '', - shortRef: shortRef || '', + parentShas: parentShas + ? /** @type {Array} */ (parentShas.split(' ').filter(Boolean)) + : [], + sha: /** @type {GitSha} */ (sha || ''), + shortSha: shortSha || '', signature: { ...(signatureKey ? { key: signatureKey.trim() } : {}), ...(signatureSigner ? { signer: signatureSigner } : {}), @@ -236,7 +239,7 @@ const readCommitMetadataForCommit = async (repoRoot, commit, firstParent, status ...header, body: messageParts.body, files, - ref: commit, + sha: /** @type {GitSha} */ (commit), refs: refs .split('\n') .map((value) => value.trim()) diff --git a/electron/git-state/commit.cjs b/electron/git-state/commit.cjs index 86f32419..4b4a44fa 100644 --- a/electron/git-state/commit.cjs +++ b/electron/git-state/commit.cjs @@ -1,28 +1,20 @@ // @ts-check const { fileSort, getFingerprint, getGravatarHash, git, normalizeStatus } = require('./common.cjs'); -const { - readComparisonImageContent, - readComparisonSectionContent, - readComparisonState, -} = require('./comparison.cjs'); +const { readComparisonState } = require('./comparison.cjs'); const { readCommitMetadataForCommit } = require('./commit-metadata.cjs'); const { applyGeneratedAttributeStates, - readGeneratedAttributeStates, + readRevisionGeneratedAttributeStates, } = require('../generated-files.cjs'); -const { - readDiffImageContent: readWorkingTreeDiffImageContent, - readDiffSectionContent: readWorkingTreeDiffSectionContent, - readWorkingTreeState, -} = require('./working-tree.cjs'); +const { readWorkingTreeState } = require('./working-tree.cjs'); +const { transferRepositoryWatcherInitialSnapshot } = require('../repository-watcher.cjs'); /** * @typedef {import('../../core/types.ts').ChangedFile} ChangedFile - * @typedef {import('../../core/types.ts').DiffImageContentRequest} DiffImageContentRequest - * @typedef {import('../../core/types.ts').DiffImageContentResult} DiffImageContentResult - * @typedef {import('../../core/types.ts').DiffSectionContentRequest} DiffSectionContentRequest + * @typedef {import('../../core/types.ts').GitSha} GitSha * @typedef {import('../../core/types.ts').RepositoryState} RepositoryState + * @typedef {import('../../core/types.ts').ResolvedReviewSource} ResolvedReviewSource * @typedef {import('../../core/types.ts').ReviewSource} ReviewSource * @typedef {import('./common.cjs').StatusItem} StatusItem * @typedef {Extract} BranchSource @@ -31,10 +23,10 @@ const { * @typedef {Extract} CommitSource * @typedef {Extract} RangeSource * @typedef {BranchSource | BranchDiffSource | CommitSource | RangeSource} ComparisonSource - * @typedef {CommitSource | BranchDiffSource | RangeSource} ResolvedComparisonSource + * @typedef {Extract} ResolvedComparisonSource * @typedef {{ - * newRef: string; - * oldRef?: string; + * newSha: GitSha; + * oldSha?: GitSha; * repoRoot: string; * source: ResolvedComparisonSource; * sourceLabel: string; @@ -76,10 +68,10 @@ const parseCommitNameStatus = (raw, options = {}) => { return options.sort === false ? files : files.sort(fileSort); }; -/** @param {string} repoRoot @param {string} commit @returns {Promise>} */ +/** @param {string} repoRoot @param {string} commit @returns {Promise>} */ const readCommitParents = async (repoRoot, commit) => { const raw = (await git(repoRoot, ['rev-list', '--parents', '-n', '1', commit])).trim(); - return raw ? raw.split(' ').slice(1) : []; + return raw ? /** @type {Array} */ (raw.split(' ').slice(1)) : []; }; /** @@ -102,33 +94,38 @@ const readCommitNameStatus = async (repoRoot, commit, firstParent, options = {}) /** * @param {string} repoRoot * @param {string} ref + * @returns {Promise} */ const resolveRangeEndpoint = async (repoRoot, ref) => { if (ref !== 'HEAD') { try { - return (await git(repoRoot, ['rev-parse', '--verify', `refs/heads/${ref}^{commit}`])).trim(); + return /** @type {GitSha} */ ( + (await git(repoRoot, ['rev-parse', '--verify', `refs/heads/${ref}^{commit}`])).trim() + ); } catch { // Fall back to Git's normal ref parser for tags, hashes, and fully-qualified refs. } } - return (await git(repoRoot, ['rev-parse', '--verify', `${ref}^{commit}`])).trim(); + return /** @type {GitSha} */ ( + (await git(repoRoot, ['rev-parse', '--verify', `${ref}^{commit}`])).trim() + ); }; /** * Resolve a `base...head` (symmetric -> merge-base) or `base..head` range to the - * concrete (oldRef, newRef) pair the commit helpers diff against. + * concrete (oldSha, newSha) pair the commit helpers diff against. * @param {string} repoRoot @param {string} base @param {string} head @param {boolean} symmetric - * @returns {Promise<{ newRef: string; oldRef: string }>} + * @returns {Promise<{ newSha: GitSha; oldSha: GitSha }>} */ const resolveRangeRefs = async (repoRoot, base, head, symmetric) => { - const newRef = await resolveRangeEndpoint(repoRoot, head); - const oldRef = symmetric + const newSha = await resolveRangeEndpoint(repoRoot, head); + const oldSha = symmetric ? ( - await git(repoRoot, ['merge-base', await resolveRangeEndpoint(repoRoot, base), newRef]) + await git(repoRoot, ['merge-base', await resolveRangeEndpoint(repoRoot, base), newSha]) ).trim() : await resolveRangeEndpoint(repoRoot, base); - return { newRef, oldRef }; + return { newSha, oldSha: /** @type {GitSha} */ (oldSha) }; }; /** @param {string} left @param {string} right */ @@ -211,19 +208,19 @@ const normalizeBranchSourceInput = (input) => /** * @param {string} repoRoot * @param {BranchSource | BranchDiffSource} source - * @returns {Promise<{newRef: string; oldRef: string; source: BranchDiffSource; sourceLabel: string}>} + * @returns {Promise<{newSha: GitSha; oldSha: GitSha; source: BranchDiffSource; sourceLabel: string}>} */ const resolveBranchComparison = async (repoRoot, source) => { if (source.type === 'branch-diff') { return { - newRef: source.headRef, - oldRef: source.baseRef, + newSha: source.headSha, + oldSha: source.baseSha, source, sourceLabel: 'branch', }; } - const newRef = await resolveRangeEndpoint(repoRoot, 'HEAD'); + const newSha = await resolveRangeEndpoint(repoRoot, 'HEAD'); let branchRef; try { branchRef = await resolveRangeEndpoint(repoRoot, source.ref); @@ -235,13 +232,15 @@ const resolveBranchComparison = async (repoRoot, source) => { }`, ); } - const oldRef = (await git(repoRoot, ['merge-base', branchRef, newRef])).trim(); + const oldSha = /** @type {GitSha} */ ( + (await git(repoRoot, ['merge-base', branchRef, newSha])).trim() + ); return { - newRef, - oldRef, + newSha, + oldSha, source: { - baseRef: oldRef, - headRef: newRef, + baseSha: oldSha, + headSha: newSha, ref: source.ref, type: 'branch-diff', }, @@ -256,15 +255,15 @@ const resolveBranchComparison = async (repoRoot, source) => { */ const resolveComparisonSource = async (repoRoot, source) => { if (source.type === 'commit') { - const commit = ( - await git(repoRoot, ['rev-parse', '--verify', `${source.ref}^{commit}`]) - ).trim(); + const commit = /** @type {GitSha} */ ( + (await git(repoRoot, ['rev-parse', '--verify', `${source.ref}^{commit}`])).trim() + ); const [firstParent] = await readCommitParents(repoRoot, commit); return { - newRef: commit, - oldRef: firstParent, + newSha: commit, + oldSha: firstParent, source: { - ref: commit, + sha: commit, type: 'commit', }, sourceLabel: 'commit', @@ -272,15 +271,15 @@ const resolveComparisonSource = async (repoRoot, source) => { } if (source.type === 'range') { - const { newRef, oldRef } = await resolveRangeRefs( + const { newSha, oldSha } = await resolveRangeRefs( repoRoot, source.base, source.head, source.symmetric, ); return { - newRef, - oldRef, + newSha, + oldSha, source, sourceLabel: 'range', }; @@ -297,7 +296,7 @@ const resolveComparisonSource = async (repoRoot, source) => { const readResolvedComparison = async (launchPath, source) => { const repoRoot = (await git(launchPath, ['rev-parse', '--show-toplevel'])).trim(); const comparison = await resolveComparisonSource(repoRoot, source); - const status = await readCommitNameStatus(repoRoot, comparison.newRef, comparison.oldRef, { + const status = await readCommitNameStatus(repoRoot, comparison.newSha, comparison.oldSha, { sort: false, }); @@ -315,11 +314,11 @@ const readResolvedCommitComparison = async (repoRoot, commit) => { sort: false, }); return { - newRef: commit, - oldRef: firstParent, + newSha: /** @type {GitSha} */ (commit), + oldSha: firstParent, repoRoot, source: { - ref: commit, + sha: /** @type {GitSha} */ (commit), type: 'commit', }, sourceLabel: 'commit', @@ -331,8 +330,8 @@ const readResolvedCommitComparison = async (repoRoot, commit) => { const readResolvedComparisonState = (launchPath, comparison) => readComparisonState({ launchPath, - newRef: comparison.newRef, - oldRef: comparison.oldRef, + newSha: comparison.newSha, + oldSha: comparison.oldSha, repoRoot: comparison.repoRoot, source: comparison.source, status: comparison.status, @@ -340,10 +339,13 @@ const readResolvedComparisonState = (launchPath, comparison) => /** @param {ResolvedComparison} comparison */ const readComparisonGeneratedAttributeStates = (comparison) => - readGeneratedAttributeStates( + readRevisionGeneratedAttributeStates( comparison.repoRoot, comparison.status.map((file) => file.path), - comparison.newRef, + { + label: { kind: 'commit', text: comparison.newSha.slice(0, 7) }, + sha: comparison.newSha, + }, ); /** @param {string} launchPath @param {ComparisonSource} source @returns {Promise} */ @@ -356,62 +358,13 @@ const readComparisonSourceState = async (launchPath, source) => { return applyGeneratedAttributeStates(state, generatedAttributeStates); }; -/** - * @param {string} launchPath - * @param {ComparisonSource} source - * @param {string} requestedPath - * @param {{force?: boolean}} [options] - */ -const readComparisonSourceSectionContent = async ( - launchPath, - source, - requestedPath, - options = {}, -) => { - const comparison = await readResolvedComparison(launchPath, source); - return readComparisonSectionContent( - comparison.repoRoot, - comparison.newRef, - comparison.oldRef, - comparison.status, - requestedPath, - comparison.sourceLabel, - options, - ); -}; - -/** - * @param {string} launchPath - * @param {ComparisonSource} source - * @param {string} requestedPath - * @returns {Promise} - */ -const readComparisonSourceImageContent = async (launchPath, source, requestedPath) => { - try { - const comparison = await readResolvedComparison(launchPath, source); - return await readComparisonImageContent( - comparison.repoRoot, - comparison.newRef, - comparison.oldRef, - comparison.status, - requestedPath, - comparison.sourceLabel, - ); - } catch (error) { - return { - reason: error instanceof Error ? error.message : 'Codiff could not load this image.', - status: 'unavailable', - }; - } -}; - /** @param {string} launchPath @param {ResolvedComparison} comparison */ const readCommitStateFromComparison = async (launchPath, comparison) => { const [commitMetadata, state, generatedAttributeStates] = await Promise.all([ readCommitMetadataForCommit( comparison.repoRoot, - comparison.newRef, - comparison.oldRef, + comparison.newSha, + comparison.oldSha, comparison.status, ), readResolvedComparisonState(launchPath, comparison), @@ -440,24 +393,6 @@ const readCommitState = async (launchPath, ref) => const readResolvedCommitState = async (launchPath, repoRoot, commit) => readCommitStateFromComparison(launchPath, await readResolvedCommitComparison(repoRoot, commit)); -/** - * @param {string} launchPath - * @param {string} ref - * @param {string} requestedPath - * @param {{force?: boolean}} [options] - */ -const readCommitSectionContent = (launchPath, ref, requestedPath, options = {}) => - readComparisonSourceSectionContent(launchPath, { ref, type: 'commit' }, requestedPath, options); - -/** - * @param {string} launchPath - * @param {string} ref - * @param {string} requestedPath - * @returns {Promise} - */ -const readCommitImageContent = (launchPath, ref, requestedPath) => - readComparisonSourceImageContent(launchPath, { ref, type: 'commit' }, requestedPath); - /** * @param {string} launchPath @param {string} base @param {string} head @param {boolean} symmetric * @returns {Promise} @@ -470,68 +405,13 @@ const readRangeState = (launchPath, base, head, symmetric) => type: 'range', }); -/** - * @param {string} launchPath @param {string} base @param {string} head @param {boolean} symmetric @param {string} requestedPath @param {{encoding?: BufferEncoding, force?: boolean}} [options] - */ -const readRangeSectionContent = (launchPath, base, head, symmetric, requestedPath, options = {}) => - readComparisonSourceSectionContent( - launchPath, - { - base, - head, - symmetric, - type: 'range', - }, - requestedPath, - options, - ); - -/** - * @param {string} launchPath @param {string} base @param {string} head @param {boolean} symmetric @param {string} requestedPath - * @returns {Promise} - */ -const readRangeImageContent = (launchPath, base, head, symmetric, requestedPath) => - readComparisonSourceImageContent( - launchPath, - { - base, - head, - symmetric, - type: 'range', - }, - requestedPath, - ); - /** @param {string} launchPath @param {string | BranchSource | BranchDiffSource} input @returns {Promise} */ const readBranchState = (launchPath, input) => readComparisonSourceState(launchPath, normalizeBranchSourceInput(input)); -/** - * @param {string} launchPath - * @param {string | BranchSource | BranchDiffSource} input - * @param {string} requestedPath - * @param {{force?: boolean}} [options] - */ -const readBranchSectionContent = (launchPath, input, requestedPath, options = {}) => - readComparisonSourceSectionContent( - launchPath, - normalizeBranchSourceInput(input), - requestedPath, - options, - ); - -/** - * @param {string} launchPath - * @param {string | BranchSource | BranchDiffSource} input - * @param {string} requestedPath - * @returns {Promise} - */ -const readBranchImageContent = (launchPath, input, requestedPath) => - readComparisonSourceImageContent(launchPath, normalizeBranchSourceInput(input), requestedPath); - /** * Reduce a `branch-working-tree` input (which may or may not already carry a - * resolved baseRef/headRef) down to the plain branch/branch-diff shape that + * resolved baseSha/headSha) down to the plain branch/branch-diff shape that * {@link readBranchState} already understands. * @param {string | BranchSource | BranchDiffSource | BranchWorkingTreeSource} input * @returns {string | BranchSource | BranchDiffSource} @@ -541,8 +421,8 @@ const toBranchComparisonInput = (input) => { return input; } - return input.baseRef && input.headRef - ? { baseRef: input.baseRef, headRef: input.headRef, ref: input.ref, type: 'branch-diff' } + return input.baseSha && input.headSha + ? { baseSha: input.baseSha, headSha: input.headSha, ref: input.ref, type: 'branch-diff' } : { ref: input.ref, type: 'branch' }; }; @@ -609,17 +489,17 @@ const mergeBranchAndWorkingTreeState = (branchState, workingTreeState) => { .map((path) => mergeChangedFile(branchFilesByPath.get(path), workingTreeFilesByPath.get(path))) .sort(fileSort); - return { + return transferRepositoryWatcherInitialSnapshot(workingTreeState, { ...branchState, files, generatedAt: Date.now(), source: { - baseRef: branchSource.baseRef, - headRef: branchSource.headRef, + baseSha: branchSource.baseSha, + headSha: branchSource.headSha, ref: branchSource.ref, type: 'branch-working-tree', }, - }; + }); }; /** @@ -639,48 +519,6 @@ const readBranchWorkingTreeState = async (launchPath, input, options = {}) => { return mergeBranchAndWorkingTreeState(branchState, workingTreeState); }; -/** - * By the time a section/image content request comes in for a - * `branch-working-tree` source, that source is always the fully resolved - * copy round-tripped from `RepositoryState.source` (baseRef/headRef are only - * absent momentarily, at CLI-argument construction time, before the initial - * state has been read). - * @param {BranchWorkingTreeSource} source - * @returns {BranchDiffSource} - */ -const toResolvedBranchDiffSource = (source) => { - if (!source.baseRef || !source.headRef) { - throw new Error('Cannot load branch-working-tree content before the branch diff is resolved.'); - } - - return { baseRef: source.baseRef, headRef: source.headRef, ref: source.ref, type: 'branch-diff' }; -}; - -/** - * @param {string} launchPath - * @param {DiffSectionContentRequest} request - */ -const readBranchWorkingTreeSectionContent = (launchPath, request) => { - const source = /** @type {BranchWorkingTreeSource} */ (request.source); - return request.kind === 'staged' || request.kind === 'unstaged' - ? readWorkingTreeDiffSectionContent(launchPath, request) - : readBranchSectionContent(launchPath, toResolvedBranchDiffSource(source), request.path, { - force: request.force, - }); -}; - -/** - * @param {string} launchPath - * @param {DiffImageContentRequest} request - * @returns {Promise} - */ -const readBranchWorkingTreeImageContent = (launchPath, request) => { - const source = /** @type {BranchWorkingTreeSource} */ (request.source); - return request.kind === 'staged' || request.kind === 'unstaged' - ? readWorkingTreeDiffImageContent(launchPath, request) - : readBranchImageContent(launchPath, toResolvedBranchDiffSource(source), request.path); -}; - /** @param {string} launchPath @param {number} [limit] @param {string} [ref] */ const listRepositoryHistory = async (launchPath, limit = 200, ref = 'HEAD') => { const repoRoot = (await git(launchPath, ['rev-parse', '--show-toplevel'])).trim(); @@ -706,8 +544,8 @@ const listRepositoryHistory = async (launchPath, limit = 200, ref = 'HEAD') => { const entries = []; for (const record of raw.split('\x1e')) { - const [ref, parents, committedAt, subject, author, email] = record.trim().split('\x1f'); - if (!ref || !committedAt || subject == null) { + const [sha, parentShas, committedAt, subject, author, email] = record.trim().split('\x1f'); + if (!sha || !committedAt || subject == null) { continue; } @@ -719,8 +557,8 @@ const listRepositoryHistory = async (launchPath, limit = 200, ref = 'HEAD') => { author: author || '', committedAt: Number(committedAt) * 1000, gravatarUrl, - parents: parents ? parents.split(' ') : [], - ref, + parentShas: parentShas ? /** @type {Array} */ (parentShas.split(' ')) : [], + sha: /** @type {GitSha} */ (sha), subject, }); } @@ -733,17 +571,9 @@ const listRepositoryHistory = async (launchPath, limit = 200, ref = 'HEAD') => { module.exports = { listRepositoryHistory, - readBranchImageContent, - readBranchSectionContent, readBranchState, - readBranchWorkingTreeImageContent, - readBranchWorkingTreeSectionContent, readBranchWorkingTreeState, - readCommitImageContent, - readCommitSectionContent, readCommitState, readResolvedCommitState, - readRangeImageContent, - readRangeSectionContent, readRangeState, }; diff --git a/electron/git-state/common.cjs b/electron/git-state/common.cjs index a8007b1b..6a4e5f69 100644 --- a/electron/git-state/common.cjs +++ b/electron/git-state/common.cjs @@ -1,18 +1,22 @@ // @ts-check -const { execFile, spawn } = require('node:child_process'); +const { AsyncLocalStorage } = require('node:async_hooks'); +const { execFile, execFileSync, spawn } = require('node:child_process'); const { promises: fs } = require('node:fs'); const { createHash } = require('node:crypto'); const { isAbsolute, join, normalize, sep } = require('node:path'); const { promisify } = require('node:util'); +const { getCommandActionSignal, startCommandTiming } = require('../command-log.cjs'); const execFileAsync = promisify(execFile); +const commandSignalStorage = new AsyncLocalStorage(); +const getCurrentCommandSignal = () => commandSignalStorage.getStore(); +/** @template Value @param {AbortSignal} signal @param {() => Value} callback */ +const runWithCommandSignal = (signal, callback) => commandSignalStorage.run(signal, callback); /** * @typedef {import('../../core/types.ts').ChangedFile} ChangedFile - * @typedef {import('../../core/types.ts').DiffImageRevision} DiffImageRevision * @typedef {import('../../core/types.ts').DiffSection} DiffSection - * @typedef {import('../../core/types.ts').DiffSectionContentRequest} DiffSectionContentRequest * @typedef {import('../../core/types.ts').GitFileStatus} GitFileStatus * @typedef {import('../../core/types.ts').PullRequestReviewComment} PullRequestReviewComment * @typedef {import('../../core/types.ts').RepositoryState} RepositoryState @@ -34,7 +38,7 @@ const execFileAsync = promisify(execFile); * unstaged: boolean; * untracked: boolean; * }} StatusItem - * @typedef {{force?: boolean; patch?: {binary: boolean; patch: string}; patchOnly?: boolean; showWhitespace?: boolean}} ReadFileOptions + * @typedef {{force?: boolean; head?: string; patch?: {binary: boolean; patch: string}; patchOnly?: boolean; showWhitespace?: boolean}} ReadFileOptions * @typedef {{number: number; owner: string; repo: string; url: string}} PullRequestReference * @typedef {{owner: string; repo: string}} GitHubRemote * @typedef {{filename: string; patch?: string; previous_filename?: string; status: string}} GitHubPullRequestFile @@ -52,81 +56,165 @@ const getGravatarHash = (email) => /** * @param {string} repoPath * @param {ReadonlyArray} args - * @param {{encoding?: BufferEncoding}} [options] + * @param {{encoding?: BufferEncoding, signal?: AbortSignal}} [options] * @returns {Promise} */ const git = async (repoPath, args, options = {}) => { - const { stdout } = await execFileAsync('git', ['-C', repoPath, ...args], { - encoding: options.encoding || 'utf8', - maxBuffer: 1024 * 1024 * 64, - }); - return stdout; + const commandArgs = ['-C', repoPath, ...args]; + const signal = options.signal || getCurrentCommandSignal() || getCommandActionSignal(); + const timing = startCommandTiming({ args: commandArgs, command: 'git', cwd: repoPath }); + try { + const { stdout } = await execFileAsync('git', commandArgs, { + encoding: options.encoding || 'utf8', + maxBuffer: 1024 * 1024 * 64, + signal, + }); + timing.finish(); + return stdout; + } catch (error) { + timing.finish({ canceled: error instanceof Error && error.name === 'AbortError', error }); + throw error; + } }; -/** @param {string} repoPath @param {ReadonlyArray} args @returns {Promise} */ -const gitBuffer = async (repoPath, args) => { - const { stdout } = await execFileAsync('git', ['-C', repoPath, ...args], { - encoding: 'buffer', - maxBuffer: 1024 * 1024 * 64, - }); - return stdout; +/** @param {string} repoPath @param {ReadonlyArray} args @param {{signal?: AbortSignal}} [options] @returns {Promise} */ +const gitBuffer = async (repoPath, args, options = {}) => { + const commandArgs = ['-C', repoPath, ...args]; + const signal = options.signal || getCurrentCommandSignal() || getCommandActionSignal(); + const timing = startCommandTiming({ args: commandArgs, command: 'git', cwd: repoPath }); + try { + const { stdout } = await execFileAsync('git', commandArgs, { + encoding: 'buffer', + maxBuffer: 1024 * 1024 * 64, + signal, + }); + timing.finish(); + return stdout; + } catch (error) { + timing.finish({ canceled: error instanceof Error && error.name === 'AbortError', error }); + throw error; + } +}; + +/** + * Canonical synchronous Git boundary for startup paths that must resolve + * before a window exists. + * @param {string} repoPath + * @param {ReadonlyArray} args + * @param {{encoding?: BufferEncoding}} [options] + */ +const gitSync = (repoPath, args, options = {}) => { + (getCurrentCommandSignal() || getCommandActionSignal())?.throwIfAborted(); + const commandArgs = ['-C', repoPath, ...args]; + const timing = startCommandTiming({ args: commandArgs, command: 'git', cwd: repoPath }); + try { + const output = execFileSync('git', commandArgs, { + encoding: options.encoding || 'utf8', + maxBuffer: 1024 * 1024 * 64, + stdio: ['ignore', 'pipe', 'ignore'], + }); + timing.finish({ exitCode: 0 }); + return output; + } catch (error) { + timing.finish({ + error, + exitCode: + error && typeof error === 'object' && 'status' in error && typeof error.status === 'number' + ? error.status + : null, + }); + throw error; + } }; /** * @param {string} repoPath * @param {ReadonlyArray} args * @param {string | Buffer} input - * @param {{env?: NodeJS.ProcessEnv}} [options] + * @param {{env?: NodeJS.ProcessEnv, signal?: AbortSignal}} [options] * @returns {Promise} */ const gitBufferWithInput = (repoPath, args, input, options = {}) => new Promise((resolve, reject) => { - const child = spawn('git', ['-C', repoPath, ...args], { - env: options.env, - stdio: ['pipe', 'pipe', 'pipe'], - }); + const commandArgs = ['-C', repoPath, ...args]; + const signal = options.signal || getCurrentCommandSignal() || getCommandActionSignal(); + const timing = startCommandTiming({ args: commandArgs, command: 'git', cwd: repoPath }); /** @type {Array} */ const stdout = []; /** @type {Array} */ const stderr = []; + let settled = false; + + /** @param {unknown} reason @param {{canceled?: boolean, exitCode?: number | null, signal?: string | null}} [result] */ + const fail = (reason, result = {}) => { + if (settled) return; + settled = true; + const error = reason instanceof Error ? reason : new Error(String(reason)); + timing.finish({ ...result, error }); + reject(error); + }; + + const abortError = () => { + const reason = signal?.reason; + if (reason instanceof Error) return reason; + const error = new Error('Git command was aborted.'); + error.name = 'AbortError'; + return error; + }; + + let child; + try { + signal?.throwIfAborted(); + child = spawn('git', commandArgs, { + env: options.env, + signal, + stdio: ['pipe', 'pipe', 'pipe'], + }); + } catch (error) { + fail(error, { canceled: signal?.aborted }); + return; + } child.stdout.on('data', (chunk) => stdout.push(chunk)); child.stderr.on('data', (chunk) => stderr.push(chunk)); child.stdin.on('error', (error) => { - if (/** @type {NodeJS.ErrnoException} */ (error).code !== 'EPIPE') { - reject(error); + if (!settled && /** @type {NodeJS.ErrnoException} */ (error).code !== 'EPIPE') { + fail(error, { canceled: signal?.aborted }); + child.kill(); } }); - child.on('error', reject); - child.on('close', (code) => { + child.on('error', (error) => + fail(error, { canceled: error.name === 'AbortError' || signal?.aborted }), + ); + child.on('close', (code, childSignal) => { + if (settled) return; + if (signal?.aborted) { + fail(abortError(), { canceled: true, exitCode: code, signal: childSignal }); + return; + } if (code === 0) { + settled = true; + timing.finish({ exitCode: code }); resolve(Buffer.concat(stdout)); } else { const error = new Error( Buffer.concat(stderr).toString('utf8') || `git exited with status ${code}`, ); - reject(error); + fail(error, { exitCode: code, signal: childSignal }); } }); - child.stdin.end(input); + try { + child.stdin.end(input); + } catch (error) { + fail(error, { canceled: signal?.aborted }); + child.kill(); + } }); const EAGER_TEXT_FILE_LIMIT = 1024 * 1024; const MANUAL_TEXT_FILE_LIMIT = 2 * 1024 * 1024; -const IMAGE_FILE_LIMIT = 32 * 1024 * 1024; const MAX_UNTRACKED_INITIAL_ITEMS = 1000; -const imageMimeTypes = new Map([ - ['.apng', 'image/apng'], - ['.avif', 'image/avif'], - ['.bmp', 'image/bmp'], - ['.gif', 'image/gif'], - ['.ico', 'image/x-icon'], - ['.jpeg', 'image/jpeg'], - ['.jpg', 'image/jpeg'], - ['.png', 'image/png'], - ['.webp', 'image/webp'], -]); const GENERATED_DIRECTORY_NAMES = new Set([ '.cache', '.next', @@ -260,35 +348,6 @@ const formatBytes = (size) => { return `${size} B`; }; -/** @param {string} path */ -const getImageMimeType = (path) => { - const dotIndex = path.lastIndexOf('.'); - return dotIndex === -1 ? undefined : imageMimeTypes.get(path.slice(dotIndex).toLowerCase()); -}; - -/** - * @param {string} path - * @param {Buffer} buffer - * @returns {DiffImageRevision} - */ -const bufferToImageRevision = (path, buffer) => { - const mimeType = getImageMimeType(path); - if (!mimeType) { - throw new Error('Unsupported image file type.'); - } - - if (buffer.length > IMAGE_FILE_LIMIT) { - throw new Error(`Image is ${formatBytes(buffer.length)}, so Codiff skipped rendering it.`); - } - - return { - dataUrl: `data:${mimeType};base64,${buffer.toString('base64')}`, - mimeType, - name: path, - size: buffer.length, - }; -}; - /** @param {string} reason @param {Partial} [details] @returns {DiffSummary} */ const createSummary = (reason, details = {}) => ({ reason, @@ -358,60 +417,6 @@ const bufferToTextFile = (name, buffer, cacheKey) => { }; }; -/** - * @param {string} repoRoot - * @param {string} spec - * @param {string} path - * @returns {Promise} - */ -const readImageSpec = async (repoRoot, spec, path) => { - const mimeType = getImageMimeType(path); - if (!mimeType) { - throw new Error('Unsupported image file type.'); - } - - const size = await getBlobSize(repoRoot, spec); - if (size == null) { - return undefined; - } - - if (size > IMAGE_FILE_LIMIT) { - throw new Error(`Image is ${formatBytes(size)}, so Codiff skipped rendering it.`); - } - - try { - return bufferToImageRevision(path, await gitBuffer(repoRoot, ['show', spec])); - } catch { - return undefined; - } -}; - -/** @param {string} repoRoot @param {string} ref @param {string} path */ -const readGitImageFile = (repoRoot, ref, path) => readImageSpec(repoRoot, `${ref}:${path}`, path); - -/** @param {string} repoRoot @param {string} path @param {1 | 2 | 3} [stage] */ -const readIndexImageFile = (repoRoot, path, stage) => - readImageSpec(repoRoot, stage ? `:${stage}:${path}` : `:${path}`, path); - -/** @param {string} repoRoot @param {string} path */ -const readWorkingTreeImageFile = async (repoRoot, path) => { - const mimeType = getImageMimeType(path); - if (!mimeType) { - throw new Error('Unsupported image file type.'); - } - - const stat = await readFileStat(repoRoot, path); - if (!stat || !stat.isFile()) { - return undefined; - } - - if (stat.size > IMAGE_FILE_LIMIT) { - throw new Error(`Image is ${formatBytes(stat.size)}, so Codiff skipped rendering it.`); - } - - return bufferToImageRevision(path, await fs.readFile(join(repoRoot, path))); -}; - /** * @param {string} repoRoot * @param {string} ref @@ -623,7 +628,7 @@ const createPatchForNewFile = (path, contents) => { const getWhitespaceDiffArgs = (options = {}) => options.showWhitespace === false ? ['--ignore-all-space'] : []; -/** @param {string} repoRoot @param {StatusItem} item @param {WorkingTreeSectionKind} kind @param {{showWhitespace?: boolean}} [options] */ +/** @param {string} repoRoot @param {StatusItem} item @param {WorkingTreeSectionKind} kind @param {{head?: string; showWhitespace?: boolean}} [options] */ const getPatch = async (repoRoot, item, kind, options = {}) => { const whitespaceArgs = getWhitespaceDiffArgs(options); if (item.status === 'conflicted' && !item.conflictStage) { @@ -651,6 +656,7 @@ const getPatch = async (repoRoot, item, kind, options = {}) => { '--patch', '--no-ext-diff', ...whitespaceArgs, + ...(options.head ? [options.head] : []), '--', ...(item.oldPath ? [item.oldPath] : []), item.path, @@ -710,7 +716,12 @@ const summarizeContent = (...results) => { */ const getWorkingTreeContents = async (repoRoot, item, kind, options = {}) => { if (kind === 'staged') { - const oldFile = await readGitFile(repoRoot, 'HEAD', item.oldPath || item.path, options); + const oldFile = await readGitFile( + repoRoot, + options.head || 'HEAD', + item.oldPath || item.path, + options, + ); const newFile = await readIndexFile(repoRoot, item.path, options); const summary = summarizeContent(oldFile, newFile); @@ -772,6 +783,32 @@ const getWorkingTreeContents = async (repoRoot, item, kind, options = {}) => { const createSection = async (repoRoot, item, kind, options = {}) => { const id = `${item.path}:${kind}`; const needsWorkingTreeBaseline = item.status === 'conflicted' && !item.conflictStage; + const head = + options.head ?? + (kind === 'staged' + ? (await gitOrEmpty(repoRoot, ['rev-parse', '--verify', '--quiet', 'HEAD^{commit}'])).trim() + : ''); + const index = { + kind: 'index', + label: { kind: 'review-marker', text: 'Index' }, + ...(kind === 'unstaged' && item.conflictStage ? { stage: item.conflictStage } : {}), + }; + const workingCopy = { + kind: 'working-copy', + label: { kind: 'review-marker', text: 'Working copy' }, + }; + const range = + kind === 'staged' + ? { + base: head + ? { + label: { kind: 'commit', text: head.slice(0, 7) }, + sha: head, + } + : null, + head: index, + } + : { base: index, head: workingCopy }; if (options.patchOnly && !item.untracked && !needsWorkingTreeBaseline) { const patch = options.patch ?? (await getPatch(repoRoot, item, kind, options)); @@ -783,6 +820,7 @@ const createSection = async (repoRoot, item, kind, options = {}) => { kind, loadState: 'binary', patch: '', + range, summary: createSummary('Binary file changed.', { canLoad: false, }), @@ -795,6 +833,7 @@ const createSection = async (repoRoot, item, kind, options = {}) => { kind, loadState: 'ready', patch: patch.patch, + range, }; } @@ -807,6 +846,7 @@ const createSection = async (repoRoot, item, kind, options = {}) => { kind, loadState: contents.loadState, patch: '', + range, summary: contents.summary, }; } @@ -820,6 +860,7 @@ const createSection = async (repoRoot, item, kind, options = {}) => { newFile: contents.newFile, oldFile: contents.oldFile, patch: createPatchForNewFile(item.path, contents.newFile?.contents || ''), + range, }; } @@ -833,6 +874,7 @@ const createSection = async (repoRoot, item, kind, options = {}) => { newFile: contents.newFile, oldFile: contents.oldFile, patch: patch.patch, + range, }; }; @@ -846,42 +888,41 @@ const normalizeStatus = (statusCode) => ? 'renamed' : 'modified'; -/** @param {string} repoRoot @param {ReadonlyArray} args */ -const gitOrEmpty = async (repoRoot, args) => { +/** @param {string} repoRoot @param {ReadonlyArray} args @param {{signal?: AbortSignal}} [options] */ +const gitOrEmpty = async (repoRoot, args, options = {}) => { + const signal = options.signal || getCurrentCommandSignal() || getCommandActionSignal(); try { - return await git(repoRoot, args); + return await git(repoRoot, args, { ...options, signal }); } catch { + signal?.throwIfAborted(); return ''; } }; module.exports = { EAGER_TEXT_FILE_LIMIT, - IMAGE_FILE_LIMIT, MANUAL_TEXT_FILE_LIMIT, MAX_UNTRACKED_INITIAL_ITEMS, bufferToTextFile, - bufferToImageRevision, createSection, createSummary, fileSort, formatBytes, generatedDirectoryPathspecExcludes, generatedDirectoryPathspecs, + getCurrentCommandSignal, getFingerprint, getGravatarHash, - getImageMimeType, getWhitespaceDiffArgs, git, gitBufferWithInput, gitOrEmpty, + gitSync, normalizeStatus, parseStatus, readFileStat, readGitFile, - readGitImageFile, - readIndexImageFile, - readWorkingTreeImageFile, + runWithCommandSignal, summarizeContent, validateRepositoryPath, }; diff --git a/electron/git-state/comparison.cjs b/electron/git-state/comparison.cjs index a34ed280..bb8e045b 100644 --- a/electron/git-state/comparison.cjs +++ b/electron/git-state/comparison.cjs @@ -1,32 +1,26 @@ // @ts-check -const { - fileSort, - getFingerprint, - git, - readGitImageFile, - summarizeContent, - validateRepositoryPath, -} = require('./common.cjs'); +const { fileSort, getFingerprint, git, summarizeContent } = require('./common.cjs'); const { createEmptyFileContent, readGitFiles } = require('./git-files.cjs'); /** * @typedef {import('../../core/types.ts').ChangedFile} ChangedFile * @typedef {import('../../core/types.ts').DiffImageContentResult} DiffImageContentResult + * @typedef {import('../../core/types.ts').GitSha} GitSha * @typedef {import('../../core/types.ts').RepositoryState} RepositoryState * @typedef {import('../../core/types.ts').ReviewSource} ReviewSource * @typedef {import('./common.cjs').StatusItem} StatusItem */ -/** @param {string} newRef @param {string | undefined} oldRef @param {ReadonlyArray} paths */ -const createComparisonPatchArgs = (newRef, oldRef, paths) => - oldRef - ? ['diff', '--patch', '--no-ext-diff', '--find-renames', oldRef, newRef, '--', ...paths] - : ['show', '--format=', '--patch', '--no-ext-diff', '--find-renames', newRef, '--', ...paths]; +/** @param {GitSha} newSha @param {GitSha | undefined} oldSha @param {ReadonlyArray} paths */ +const createComparisonPatchArgs = (newSha, oldSha, paths) => + oldSha + ? ['diff', '--patch', '--no-ext-diff', '--find-renames', oldSha, newSha, '--', ...paths] + : ['show', '--format=', '--patch', '--no-ext-diff', '--find-renames', newSha, '--', ...paths]; -/** @param {string} repoRoot @param {string} newRef @param {string | undefined} oldRef @param {string} path */ -const readComparisonPatch = (repoRoot, newRef, oldRef, path) => - git(repoRoot, createComparisonPatchArgs(newRef, oldRef, [path])); +/** @param {string} repoRoot @param {GitSha} newSha @param {GitSha | undefined} oldSha @param {string} path */ +const readComparisonPatch = (repoRoot, newSha, oldSha, path) => + git(repoRoot, createComparisonPatchArgs(newSha, oldSha, [path])); /** @param {ReadonlyArray} values @param {number} size */ const chunk = (values, size) => { @@ -48,11 +42,11 @@ const splitCommitPatch = (patch) => /** * @param {string} repoRoot - * @param {string} newRef - * @param {string | undefined} oldRef + * @param {GitSha} newSha + * @param {GitSha | undefined} oldSha * @param {ReadonlyArray>} items */ -const readComparisonPatches = async (repoRoot, newRef, oldRef, items) => { +const readComparisonPatches = async (repoRoot, newSha, oldSha, items) => { /** @type {Map} */ const patches = new Map(); @@ -64,7 +58,7 @@ const readComparisonPatches = async (repoRoot, newRef, oldRef, items) => { continue; } - const patch = await git(repoRoot, createComparisonPatchArgs(newRef, oldRef, itemChunk)); + const patch = await git(repoRoot, createComparisonPatchArgs(newSha, oldSha, itemChunk)); const patchChunks = splitCommitPatch(patch); if (patchChunks.length === itemChunk.length) { @@ -74,7 +68,7 @@ const readComparisonPatches = async (repoRoot, newRef, oldRef, items) => { } else { await Promise.all( itemChunk.map(async (path) => { - patches.set(path, await readComparisonPatch(repoRoot, newRef, oldRef, path)); + patches.set(path, await readComparisonPatch(repoRoot, newSha, oldSha, path)); }), ); } @@ -84,18 +78,19 @@ const readComparisonPatches = async (repoRoot, newRef, oldRef, items) => { }; /** - * @param {string} ref + * @param {string} newRef + * @param {string | undefined} oldRef * @param {Pick} item * @param {ReturnType} oldFile * @param {ReturnType} newFile * @param {string} patch */ -const createComparisonFile = (ref, item, oldFile, newFile, patch) => { +const createComparisonFile = (newRef, oldRef, item, oldFile, newFile, patch) => { const summary = summarizeContent(oldFile, newFile); return { fingerprint: getFingerprint( - `${ref}\n${item.status}\n${item.oldPath || ''}\n${summary.loadState || 'ready'}\n${ + `${newRef}\n${oldRef || ''}\n${item.status}\n${item.oldPath || ''}\n${summary.loadState || 'ready'}\n${ summary.summary?.reason || '' }\n${summary.summary?.fingerprint || ''}\n${patch}\n${oldFile.file?.contents || ''}\n${ newFile.file?.contents || '' @@ -106,12 +101,22 @@ const createComparisonFile = (ref, item, oldFile, newFile, patch) => { sections: [ { binary: summary.binary || /Binary files .* differ/.test(patch), - id: `${item.path}:${ref}`, + id: `${item.path}:${newRef}`, kind: 'commit', loadState: summary.loadState, newFile: newFile.file, oldFile: oldFile.file, patch, + range: { + base: { + label: { kind: 'commit', text: (oldRef || newRef).slice(0, 7) }, + sha: oldRef || newRef, + }, + head: { + label: { kind: 'commit', text: newRef.slice(0, 7) }, + sha: newRef, + }, + }, summary: summary.summary, }, ], @@ -120,45 +125,46 @@ const createComparisonFile = (ref, item, oldFile, newFile, patch) => { }; /** - * @param {string} ref + * @param {string} newRef + * @param {string | undefined} oldRef * @param {Pick} item * @param {ReturnType} oldFile * @param {ReturnType} newFile * @param {string} patch */ -const createComparisonSection = (ref, item, oldFile, newFile, patch) => - createComparisonFile(ref, item, oldFile, newFile, patch).sections[0]; +const createComparisonSection = (newRef, oldRef, item, oldFile, newFile, patch) => + createComparisonFile(newRef, oldRef, item, oldFile, newFile, patch).sections[0]; /** * @param {Map | import('./common.cjs').FileContentResult>} oldFiles - * @param {string | undefined} oldRef + * @param {GitSha | undefined} oldSha * @param {Pick} item */ -const getOldComparisonFile = (oldFiles, oldRef, item) => - oldRef +const getOldComparisonFile = (oldFiles, oldSha, item) => + oldSha ? oldFiles.get(item.oldPath || item.path) || createEmptyFileContent(item.oldPath || item.path) : createEmptyFileContent(item.oldPath || item.path); /** * @param {string} repoRoot - * @param {string} newRef - * @param {string | undefined} oldRef + * @param {GitSha} newSha + * @param {GitSha | undefined} oldSha * @param {ReadonlyArray>} status * @param {{force?: boolean}} [options] */ -const readComparisonFiles = async (repoRoot, newRef, oldRef, status, options = {}) => { +const readComparisonFiles = async (repoRoot, newSha, oldSha, status, options = {}) => { const [oldFiles, newFiles] = await Promise.all([ - oldRef + oldSha ? readGitFiles( repoRoot, - oldRef, + oldSha, status.map((item) => item.oldPath || item.path), options, ) : Promise.resolve(new Map()), readGitFiles( repoRoot, - newRef, + newSha, status.map((item) => item.path), options, ), @@ -170,29 +176,30 @@ const readComparisonFiles = async (repoRoot, newRef, oldRef, status, options = { /** * @param {{ * launchPath: string; - * newRef: string; - * oldRef?: string; + * newSha: GitSha; + * oldSha?: GitSha; * repoRoot: string; - * source: ReviewSource; + * source: import('../../core/types.ts').ResolvedReviewSource; * status: ReadonlyArray>; * }} input - * @returns {Promise} + * @returns {Promise>} */ -const readComparisonState = async ({ launchPath, newRef, oldRef, repoRoot, source, status }) => { - const { oldFiles, newFiles } = await readComparisonFiles(repoRoot, newRef, oldRef, status); +const readComparisonState = async ({ launchPath, newSha, oldSha, repoRoot, source, status }) => { + const { oldFiles, newFiles } = await readComparisonFiles(repoRoot, newSha, oldSha, status); const readyItems = status.filter((item) => { - const oldFile = getOldComparisonFile(oldFiles, oldRef, item); + const oldFile = getOldComparisonFile(oldFiles, oldSha, item); const newFile = newFiles.get(item.path) || createEmptyFileContent(item.path); return summarizeContent(oldFile, newFile).loadState === 'ready'; }); - const patches = await readComparisonPatches(repoRoot, newRef, oldRef, readyItems); + const patches = await readComparisonPatches(repoRoot, newSha, oldSha, readyItems); /** @type {Array} */ const files = status .map((item) => createComparisonFile( - newRef, + newSha, + oldSha, item, - getOldComparisonFile(oldFiles, oldRef, item), + getOldComparisonFile(oldFiles, oldSha, item), newFiles.get(item.path) || createEmptyFileContent(item.path), patches.get(item.path) || '', ), @@ -208,99 +215,6 @@ const readComparisonState = async ({ launchPath, newRef, oldRef, repoRoot, sourc }; }; -/** - * @param {string} repoRoot - * @param {string} newRef - * @param {string | undefined} oldRef - * @param {ReadonlyArray>} status - * @param {string} requestedPath - * @param {string} sourceLabel - * @param {{force?: boolean}} [options] - */ -const readComparisonSectionContent = async ( - repoRoot, - newRef, - oldRef, - status, - requestedPath, - sourceLabel, - options = {}, -) => { - const path = validateRepositoryPath(requestedPath); - const item = status.find((candidate) => candidate.path === path); - if (!item) { - throw new Error(`File is not part of this ${sourceLabel}.`); - } - - const { oldFiles, newFiles } = await readComparisonFiles( - repoRoot, - newRef, - oldRef, - [item], - options, - ); - const oldFile = getOldComparisonFile(oldFiles, oldRef, item); - const newFile = newFiles.get(item.path) || createEmptyFileContent(item.path); - const summary = summarizeContent(oldFile, newFile); - const patch = - summary.loadState === 'ready' - ? await readComparisonPatch(repoRoot, newRef, oldRef, item.path) - : ''; - - return createComparisonSection(newRef, item, oldFile, newFile, patch); -}; - -/** - * @param {string} repoRoot - * @param {string} newRef - * @param {string | undefined} oldRef - * @param {ReadonlyArray>} status - * @param {string} requestedPath - * @param {string} sourceLabel - * @returns {Promise} - */ -const readComparisonImageContent = async ( - repoRoot, - newRef, - oldRef, - status, - requestedPath, - sourceLabel, -) => { - try { - const path = validateRepositoryPath(requestedPath); - const item = status.find((candidate) => candidate.path === path); - if (!item) { - throw new Error(`File is not part of this ${sourceLabel}.`); - } - - const [oldImage, newImage] = await Promise.all([ - oldRef ? readGitImageFile(repoRoot, oldRef, item.oldPath || item.path) : undefined, - readGitImageFile(repoRoot, newRef, item.path), - ]); - - if (!oldImage && !newImage) { - return { - reason: 'Codiff could not load either side of this image.', - status: 'unavailable', - }; - } - - return { - ...(newImage ? { newImage } : {}), - ...(oldImage ? { oldImage } : {}), - status: 'ready', - }; - } catch (error) { - return { - reason: error instanceof Error ? error.message : 'Codiff could not load this image.', - status: 'unavailable', - }; - } -}; - module.exports = { - readComparisonImageContent, - readComparisonSectionContent, readComparisonState, }; diff --git a/electron/git-state/github-history/gh-github-transport.cjs b/electron/git-state/github-history/gh-github-transport.cjs new file mode 100644 index 00000000..39422010 --- /dev/null +++ b/electron/git-state/github-history/gh-github-transport.cjs @@ -0,0 +1,582 @@ +// @ts-check + +/** + * gh-backed GitHubTransport for local Codiff. + * Keeps executable discovery, process spawning, and credentials in Electron. + */ + +const { spawn } = require('node:child_process'); +const { getCommandActionSignal, startCommandTiming } = require('../../command-log.cjs'); +const { realpathSync } = require('node:fs'); +const { homedir } = require('node:os'); +const { join } = require('node:path'); +const { findExecutableOnPath, isExecutableFile } = require('../../agent-shared.cjs'); +const { getCommandEnvironment } = require('../../login-shell-environment.cjs'); +const { getCurrentCommandSignal } = require('../common.cjs'); + +const GH_NOT_FOUND_CODE = 'GH_NOT_FOUND'; +const GH_NOT_FOUND_MESSAGE = + 'GitHub support requires gh. Install gh, authenticate it, and verify `gh --version` works in Terminal. Codiff searches PATH, ~/.local/bin/gh, /opt/homebrew/bin/gh, and /usr/local/bin/gh. If gh is installed somewhere else, quit Codiff, then launch it with `CODIFF_GH_PATH=/absolute/path/to/gh codiff`.'; +const DEFAULT_PROVIDER_OUTPUT_BYTES = 8 * 1024 * 1024; +class ProviderOutputLimitError extends Error { + /** @param {number} maxBytes */ + constructor(maxBytes) { + super(`gh api response exceeded the ${maxBytes}-byte safety limit.`); + this.name = 'ProviderOutputLimitError'; + } +} + +/** @typedef {{errorName?: string, maxBytes?: number, outputLimitExceeded: boolean, promise: Promise, status: 'pending' | 'fulfilled' | 'rejected'}} SharedGetRequest */ + +const sharedGetRequests = new Map(); +const SHARED_GET_RETENTION_MS = 30_000; +const MAX_STDERR_BYTES = 1024 * 1024; + +/** @param {number | undefined} maxBytes */ +const normalizeMaxBytes = (maxBytes) => { + if (maxBytes == null) { + return DEFAULT_PROVIDER_OUTPUT_BYTES; + } + if (!Number.isFinite(maxBytes) || maxBytes < 0) { + throw new RangeError('maxBytes must be a finite non-negative number.'); + } + return Math.floor(maxBytes); +}; + +/** @param {number | undefined} current @param {number | undefined} requested */ +const mergeMaxBytes = (current, requested) => + current == null || requested == null ? undefined : Math.max(current, requested); + +/** @param {Buffer} bytes @param {number | undefined} maxBytes */ +const enforceOutputLimit = (bytes, maxBytes) => { + if (maxBytes != null && bytes.length > maxBytes) { + throw new ProviderOutputLimitError(maxBytes); + } + return bytes; +}; + +/** + * Share GETs within one cancellation boundary, briefly retaining completed + * bytes so each consumer can enforce its own response bound. Concurrent + * consumers may raise the acquisition bound until output crosses it; a later + * larger consumer starts a new read only after an earlier bounded read + * discarded bytes. + * @param {string} key + * @param {number | undefined} maxBytes + * @param {(options: {getMaxBytes: () => number | undefined, onOutputLimit: () => void}) => Promise} read + * @param {AbortSignal | undefined} signal + * @returns {Promise} + */ +const readSharedGet = (key, maxBytes, read, signal) => { + let requestsBySignal = sharedGetRequests.get(key); + if (!requestsBySignal) { + requestsBySignal = new Map(); + sharedGetRequests.set(key, requestsBySignal); + } + const signalKey = signal || null; + /** @type {SharedGetRequest | undefined} */ + const existing = requestsBySignal.get(signalKey); + if (existing) { + if (existing.status === 'fulfilled') { + return existing.promise; + } + const canReuseLimitFailure = + existing.maxBytes == null || (maxBytes != null && maxBytes <= existing.maxBytes); + if ( + existing.status === 'rejected' && + (existing.errorName !== 'ProviderOutputLimitError' || canReuseLimitFailure) + ) { + return existing.promise; + } + if (existing.status === 'pending' && (!existing.outputLimitExceeded || canReuseLimitFailure)) { + if (!existing.outputLimitExceeded) { + existing.maxBytes = mergeMaxBytes(existing.maxBytes, maxBytes); + } + return existing.promise; + } + } + + /** @type {SharedGetRequest} */ + const entry = { + maxBytes, + outputLimitExceeded: false, + promise: /** @type {Promise} */ (Promise.resolve(Buffer.alloc(0))), + status: 'pending', + }; + const request = Promise.resolve().then(() => + read({ + getMaxBytes: () => entry.maxBytes, + onOutputLimit: () => { + entry.outputLimitExceeded = true; + }, + }), + ); + entry.promise = request; + requestsBySignal.set(signalKey, entry); + const expire = () => { + const timeout = setTimeout(() => { + if (requestsBySignal.get(signalKey) === entry) { + requestsBySignal.delete(signalKey); + if (requestsBySignal.size === 0 && sharedGetRequests.get(key) === requestsBySignal) { + sharedGetRequests.delete(key); + } + } + }, SHARED_GET_RETENTION_MS); + timeout.unref?.(); + }; + request.then( + () => { + entry.status = 'fulfilled'; + expire(); + }, + (error) => { + entry.errorName = error instanceof Error ? error.name : undefined; + entry.status = 'rejected'; + expire(); + }, + ); + return request; +}; + +/** @param {string} [detail] */ +const createGhNotFoundError = (detail) => { + const error = /** @type {Error & { code?: string }} */ ( + new Error(detail ? `${GH_NOT_FOUND_MESSAGE} ${detail}` : GH_NOT_FOUND_MESSAGE) + ); + error.code = GH_NOT_FOUND_CODE; + return error; +}; + +const getGhCommand = () => { + const ghPath = process.env.CODIFF_GH_PATH?.trim(); + if (ghPath) { + if (isExecutableFile(ghPath)) { + return ghPath; + } + throw createGhNotFoundError( + `CODIFF_GH_PATH is set to ${JSON.stringify(ghPath)}, but that file is not executable.`, + ); + } + + const pathCommand = findExecutableOnPath('gh'); + if (pathCommand) { + return pathCommand; + } + + for (const path of [ + join(homedir(), '.local/bin/gh'), + '/opt/homebrew/bin/gh', + '/usr/local/bin/gh', + ]) { + if (isExecutableFile(path)) { + return path; + } + } + + throw createGhNotFoundError(); +}; + +/** + * @param {string} repoRoot + * @param {ReadonlyArray} args + * @param {unknown} [input] + * @param {{getMaxBytes?: () => number | undefined, maxBytes?: number, onOutputLimit?: () => void, signal?: AbortSignal}} [options] + * @returns {Promise} + */ +const runGhApiBuffer = async (repoRoot, args, input, options = {}) => { + const environment = await getCommandEnvironment(); + return new Promise((resolve, reject) => { + const fixedMaxBytes = normalizeMaxBytes(options.maxBytes); + const signal = options.signal || getCurrentCommandSignal() || getCommandActionSignal(); + let command; + try { + command = getGhCommand(); + } catch (error) { + reject(error); + return; + } + + const timing = startCommandTiming({ args: ['api', ...args], command, cwd: repoRoot }); + let child; + try { + signal?.throwIfAborted(); + child = spawn(command, ['api', ...args], { + cwd: repoRoot, + env: environment, + signal, + stdio: ['pipe', 'pipe', 'pipe'], + }); + } catch (error) { + timing.finish({ canceled: signal?.aborted, error }); + reject(error); + return; + } + /** @type {Array} */ + const stdout = []; + /** @type {Array} */ + const stderr = []; + let stderrBytes = 0; + /** @type {ReturnType | undefined} */ + let forceKillTimeout; + let outputBytes = 0; + let outputLimit; + let settled = false; + + const terminate = () => { + child.kill('SIGTERM'); + forceKillTimeout ??= setTimeout(() => child.kill('SIGKILL'), 1_000); + forceKillTimeout.unref?.(); + }; + + /** + * @param {unknown} reason + * @param {{canceled?: boolean, exitCode?: number | null, signal?: string | null}} [result] + */ + const fail = (reason, result = {}) => { + if (settled) return; + settled = true; + const error = reason instanceof Error ? reason : new Error(String(reason)); + timing.finish({ + canceled: result.canceled, + error, + exitCode: result.exitCode, + signal: result.signal, + }); + reject(error); + }; + + const abortError = () => { + const reason = signal?.reason; + if (reason instanceof Error) return reason; + const error = new Error('gh api request was aborted.'); + error.name = 'AbortError'; + return error; + }; + + child.stdout.on('data', (value) => { + if (settled) return; + const chunk = Buffer.isBuffer(value) ? value : Buffer.from(value); + outputBytes += chunk.length; + if (outputLimit != null) { + return; + } + const maxBytes = options.getMaxBytes?.() ?? fixedMaxBytes; + if (maxBytes != null && outputBytes > maxBytes) { + outputLimit = maxBytes; + stdout.length = 0; + options.onOutputLimit?.(); + terminate(); + return; + } + stdout.push(chunk); + }); + child.stderr.on('data', (value) => { + if (settled || stderrBytes >= MAX_STDERR_BYTES) return; + const chunk = Buffer.isBuffer(value) ? value : Buffer.from(value); + const retained = chunk.subarray(0, MAX_STDERR_BYTES - stderrBytes); + stderr.push(Buffer.from(retained)); + stderrBytes += retained.length; + }); + child.stdin.on('error', (error) => { + if ( + !settled && + outputLimit == null && + /** @type {NodeJS.ErrnoException} */ (error).code !== 'EPIPE' + ) { + fail(error, { canceled: signal?.aborted }); + child.kill(); + } + }); + child.on('error', (error) => { + if (forceKillTimeout) { + clearTimeout(forceKillTimeout); + } + if (settled) return; + if (outputLimit != null) { + fail(new ProviderOutputLimitError(outputLimit), { canceled: signal?.aborted }); + return; + } + fail( + /** @type {NodeJS.ErrnoException} */ (error).code === 'ENOENT' + ? createGhNotFoundError() + : error, + { canceled: error.name === 'AbortError' || signal?.aborted }, + ); + }); + child.on('close', (code, childSignal) => { + if (forceKillTimeout) { + clearTimeout(forceKillTimeout); + } + if (settled) return; + if (outputLimit != null) { + fail(new ProviderOutputLimitError(outputLimit), { + exitCode: code, + signal: childSignal, + }); + return; + } + if (signal?.aborted) { + fail(abortError(), { canceled: true, exitCode: code, signal: childSignal }); + return; + } + if (code === 0) { + settled = true; + timing.finish({ exitCode: code }); + resolve(Buffer.concat(stdout, outputBytes)); + } else { + const error = new Error( + Buffer.concat(stderr).toString('utf8').trim() || `gh api exited with code ${code}.`, + ); + fail(error, { exitCode: code, signal: childSignal }); + } + }); + try { + if (input == null) { + child.stdin.end(); + } else { + child.stdin.end(typeof input === 'string' ? input : JSON.stringify(input)); + } + } catch (error) { + fail(error, { canceled: signal?.aborted }); + child.kill(); + } + }); +}; + +/** + * @param {string} repoRoot + * @param {ReadonlyArray} args + * @param {unknown} [input] + * @param {{buffer?: boolean, maxBytes?: number, signal?: AbortSignal}} [options] + * @returns {Promise} + */ +const runGhApi = async (repoRoot, args, input, options = {}) => { + const bytes = await runGhApiBuffer(repoRoot, args, input, options); + return options.buffer ? bytes : bytes.toString('utf8'); +}; + +/** + * gh writes paginated JSON documents consecutively. Parse complete arrays or + * objects without assuming a newline separator. + * @param {string} text + */ +const parseJsonDocuments = (text) => { + /** @type {Array} */ + const documents = []; + let depth = 0; + let escape = false; + let inString = false; + let start = -1; + for (let index = 0; index < text.length; index += 1) { + const character = text[index]; + if (start === -1) { + if (/\s/.test(character)) continue; + if (character !== '[' && character !== '{') { + throw new SyntaxError(`Unexpected character in gh JSON output at position ${index}.`); + } + start = index; + depth = 1; + continue; + } + if (inString) { + if (escape) escape = false; + else if (character === '\\') escape = true; + else if (character === '"') inString = false; + continue; + } + if (character === '"') inString = true; + else if (character === '[' || character === '{') depth += 1; + else if (character === ']' || character === '}') { + depth -= 1; + if (depth === 0) { + documents.push(JSON.parse(text.slice(start, index + 1))); + start = -1; + } + } + } + if (start !== -1 || inString) { + throw new SyntaxError('Incomplete JSON document in gh output.'); + } + return documents; +}; + +/** + * @param {string} value + * @param {Readonly> | undefined} query + */ +const appendQuery = (value, query) => { + let path = value.startsWith('/') ? value : `/${value}`; + if (!query) { + return path; + } + const params = new URLSearchParams(); + for (const [key, parameter] of Object.entries(query)) { + params.set(key, String(parameter)); + } + const suffix = params.toString(); + if (suffix) { + path = `${path}${path.includes('?') ? '&' : '?'}${suffix}`; + } + return path; +}; + +/** @param {Readonly> | undefined} query */ +const withoutPageSize = (query) => + query && Object.fromEntries(Object.entries(query).filter(([key]) => key !== 'per_page')); + +/** + * @param {{ repoRoot: string, signal?: AbortSignal }} options + */ +const createGhGitHubTransport = ({ repoRoot, signal: defaultSignal }) => { + const repositoryIdentity = realpathSync.native(repoRoot); + + /** + * @param {ReadonlyArray} args + * @param {unknown} input + * @param {{maxBytes?: number, sharedKey?: string, signal?: AbortSignal}} options + */ + const readApiBuffer = async (args, input, options) => { + const maxBytes = normalizeMaxBytes(options.maxBytes); + const bytes = options.sharedKey + ? await readSharedGet( + options.sharedKey, + maxBytes, + ({ getMaxBytes, onOutputLimit }) => + runGhApiBuffer(repoRoot, args, input, { + getMaxBytes, + onOutputLimit, + signal: options.signal, + }), + options.signal, + ) + : await runGhApiBuffer(repoRoot, args, input, { + maxBytes, + signal: options.signal, + }); + return enforceOutputLimit(bytes, maxBytes); + }; + + /** + * @param {{maxBytes?: number, query: string, signal?: AbortSignal, variables: Readonly>}} request + */ + const graphql = async (request) => { + const signal = + request.signal || defaultSignal || getCurrentCommandSignal() || getCommandActionSignal(); + /** @type {Array} */ + const args = ['graphql', '-f', `query=${request.query}`]; + for (const [key, value] of Object.entries(request.variables)) { + if (value != null) { + args.push('-F', `${key}=${String(value)}`); + } + } + return JSON.parse( + ( + await readApiBuffer(args, undefined, { + maxBytes: request.maxBytes, + signal, + }) + ).toString('utf8'), + ); + }; + + /** + * @param {{ + * maxBytes?: number, + * method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH', + * path: string, + * query?: Readonly>, + * accept?: string, + * body?: unknown, + * paginate?: boolean, + * signal?: AbortSignal, + * }} request + */ + const requestText = async (request) => { + const signal = + request.signal || defaultSignal || getCurrentCommandSignal() || getCommandActionSignal(); + /** @type {Array} */ + const args = []; + if (request.paginate) { + args.push('--paginate'); + } + if (request.method && request.method !== 'GET') { + args.push('--method', request.method); + } + if (request.accept) { + args.push('-H', `Accept: ${request.accept}`); + } + args.push(appendQuery(request.path, request.query)); + if (request.body != null) { + args.push('--input', '-'); + } + const sharedKey = + request.body == null && (!request.method || request.method === 'GET') + ? `${repositoryIdentity}\0text\0${request.paginate ? 'paginate' : 'single'}\0${request.accept || ''}\0${appendQuery(request.path, request.paginate ? withoutPageSize(request.query) : request.query)}` + : undefined; + return ( + await readApiBuffer(args, request.body, { + maxBytes: request.maxBytes, + sharedKey, + signal, + }) + ).toString('utf8'); + }; + + return { + graphql, + /** + * @template T + * @param {{ + * maxBytes?: number, + * method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH', + * path: string, + * query?: Readonly>, + * body?: unknown, + * paginate?: boolean, + * signal?: AbortSignal, + * }} request + * @returns {Promise} + */ + async request(request) { + const text = await requestText(request); + if (!text.trim()) { + return /** @type {T} */ (null); + } + if (request.paginate) { + const documents = parseJsonDocuments(text); + return /** @type {T} */ ( + documents.flatMap((document) => (Array.isArray(document) ? document : [document])) + ); + } + return /** @type {T} */ (JSON.parse(text)); + }, + async requestBuffer(request) { + const signal = + request.signal || defaultSignal || getCurrentCommandSignal() || getCommandActionSignal(); + /** @type {Array} */ + const args = []; + if (request.accept) { + args.push('-H', `Accept: ${request.accept}`); + } + args.push(appendQuery(request.path, request.query)); + return readApiBuffer(args, undefined, { + maxBytes: request.maxBytes, + sharedKey: `${repositoryIdentity}\0buffer\0single\0${request.accept || ''}\0${appendQuery(request.path, request.query)}`, + signal, + }); + }, + requestText, + }; +}; + +module.exports = { + GH_NOT_FOUND_CODE, + ProviderOutputLimitError, + appendQuery, + createGhGitHubTransport, + createGhNotFoundError, + getGhCommand, + parseJsonDocuments, + runGhApi, + runGhApiBuffer, +}; diff --git a/electron/git-state/github-review-mutations.cjs b/electron/git-state/github-review-mutations.cjs new file mode 100644 index 00000000..e0320dfb --- /dev/null +++ b/electron/git-state/github-review-mutations.cjs @@ -0,0 +1,192 @@ +// @ts-check + +const { git } = require('./common.cjs'); +const { validateCurrentReviewCommentTarget } = require('./review-comment-target.cjs'); + +/** + * @typedef {import('../../core/types.ts').PullRequestReviewComment} PullRequestReviewComment + * @typedef {import('../../core/types.ts').SubmitPullRequestCommentRequest} SubmitPullRequestCommentRequest + * @typedef {import('../../core/types.ts').SubmitPullRequestReviewRequest} SubmitPullRequestReviewRequest + * @typedef {{number: number, owner: string, repo: string, url: string}} PullRequestReference + * @typedef {{request: (request: {body?: unknown, method?: 'GET' | 'POST', paginate?: boolean, path: string, query?: Readonly>}) => Promise}} GitHubMutationTransport + * @typedef {{baseSha?: string, files: ReadonlyArray<{newPath: string, oldPath?: string, patch?: string}>, headSha?: string}} GitHubReviewTarget + */ + +const PENDING_REVIEW_COMMENT_ERROR = + 'You already have a pending GitHub review on this pull request. Submit or discard it on GitHub, then retry. Your comment draft is still here.'; + +/** @param {PullRequestReviewComment['side']} side */ +const toGitHubReviewSide = (side) => (side === 'deletions' ? 'LEFT' : 'RIGHT'); + +/** @param {PullRequestReviewComment} comment */ +const normalizePullRequestComment = (comment) => { + if (comment.anchor === 'file' || comment.lineNumber == null || comment.side == null) { + return { + body: comment.body, + path: comment.filePath, + subject_type: 'file', + }; + } + /** @type {{body: string, line: number, path: string, side: string, start_line?: number, start_side?: string}} */ + const payload = { + body: comment.body, + line: comment.lineNumber, + path: comment.filePath, + side: toGitHubReviewSide(comment.side), + }; + const startSide = comment.startSide ?? comment.side; + if ( + typeof comment.startLineNumber === 'number' && + comment.startLineNumber !== comment.lineNumber + ) { + payload.start_line = comment.startLineNumber; + payload.start_side = toGitHubReviewSide(startSide); + } + return payload; +}; + +/** @param {unknown} error */ +const isGitHubValidationError = (error) => + error instanceof Error && /(?:validation failed|http 422)/i.test(error.message); + +/** @param {SubmitPullRequestReviewRequest} request */ +const validatePullRequestReviewRequest = (request) => { + const body = request.body?.trim() || ''; + if (request.event === 'COMMENT' && request.comments.length === 0 && !body) { + throw new Error('A comment review requires an inline comment or a review comment.'); + } +}; + +/** @param {SubmitPullRequestReviewRequest} request @param {string} targetSha */ +const createPullRequestReviewPayload = (request, targetSha) => { + validatePullRequestReviewRequest(request); + const body = request.body?.trim() || ''; + return { + body: + body || + (request.event === 'COMMENT' + ? 'Review comments.' + : request.event === 'REQUEST_CHANGES' && request.comments.length === 0 + ? 'Requesting changes.' + : ''), + commit_id: targetSha, + comments: request.comments.map(normalizePullRequestComment), + event: request.event, + }; +}; + +/** + * @param {{ + * assertPullRequestMatchesRepository: (repoRoot: string, pullRequest: PullRequestReference) => Promise, + * createTransport: (repoRoot: string, pullRequest: PullRequestReference) => GitHubMutationTransport, + * normalizeGitHubReviewComment: (comment: any) => import('../../core/types.ts').PullRequestExistingReviewComment | null, + * parseGitHubPullRequestUrl: (value: string) => PullRequestReference, + * readCurrentTarget: (repoRoot: string, pullRequest: PullRequestReference, transport: GitHubMutationTransport) => Promise, + * }} dependencies + */ +const createGitHubReviewMutations = ({ + assertPullRequestMatchesRepository, + createTransport, + normalizeGitHubReviewComment, + parseGitHubPullRequestUrl, + readCurrentTarget, +}) => { + /** @param {PullRequestReference} pullRequest @param {GitHubMutationTransport} transport */ + const hasPendingPullRequestReview = async (pullRequest, transport) => { + const reviews = await transport.request({ + paginate: true, + path: `repos/${pullRequest.owner}/${pullRequest.repo}/pulls/${pullRequest.number}/reviews`, + query: { per_page: 100 }, + }); + return Array.isArray(reviews) && reviews.some((review) => review?.state === 'PENDING'); + }; + + /** @param {string} launchPath @param {SubmitPullRequestCommentRequest} request */ + const submitPullRequestComment = async (launchPath, request) => { + const repoRoot = (await git(launchPath, ['rev-parse', '--show-toplevel'])).trim(); + const pullRequest = parseGitHubPullRequestUrl(request.source.url); + await assertPullRequestMatchesRepository(repoRoot, pullRequest); + const transport = createTransport(repoRoot, pullRequest); + const target = await readCurrentTarget(repoRoot, pullRequest, transport); + validateCurrentReviewCommentTarget(request.comment, target); + const replyTo = request.comment.threadId ? Number(request.comment.threadId) : null; + if (request.comment.threadId && (!Number.isInteger(replyTo) || replyTo <= 0)) { + throw new Error('GitHub review replies require a numeric provider thread ID.'); + } + const rawComment = await transport + .request({ + body: replyTo + ? { body: request.comment.body, in_reply_to: replyTo } + : { + ...normalizePullRequestComment(request.comment), + commit_id: target.headSha, + }, + method: 'POST', + path: `repos/${pullRequest.owner}/${pullRequest.repo}/pulls/${pullRequest.number}/comments`, + }) + .catch(async (error) => { + if (isGitHubValidationError(error)) { + const hasPendingReview = await hasPendingPullRequestReview(pullRequest, transport).catch( + () => false, + ); + if (hasPendingReview) { + throw new Error(PENDING_REVIEW_COMMENT_ERROR); + } + } + throw error; + }); + const comment = normalizeGitHubReviewComment(rawComment); + if (!comment) { + throw new Error('GitHub accepted the comment but did not return line metadata.'); + } + return comment; + }; + + /** @param {string} launchPath @param {SubmitPullRequestReviewRequest} request */ + const submitPullRequestReview = async (launchPath, request) => { + /** @type {Array} */ + const submittedDraftIds = []; + try { + validatePullRequestReviewRequest(request); + const repoRoot = (await git(launchPath, ['rev-parse', '--show-toplevel'])).trim(); + const pullRequest = parseGitHubPullRequestUrl(request.source.url); + await assertPullRequestMatchesRepository(repoRoot, pullRequest); + const transport = createTransport(repoRoot, pullRequest); + const target = await readCurrentTarget(repoRoot, pullRequest, transport); + if (!target.headSha) { + throw new Error('GitHub did not return the current pull request head.'); + } + if (request.source.headSha && target.headSha !== request.source.headSha) { + throw new Error('The pull request head changed. Refresh before submitting.'); + } + for (const comment of request.comments) { + validateCurrentReviewCommentTarget(comment, target); + } + await transport.request({ + body: createPullRequestReviewPayload(request, target.headSha), + method: 'POST', + path: `repos/${pullRequest.owner}/${pullRequest.repo}/pulls/${pullRequest.number}/reviews`, + }); + submittedDraftIds.push( + ...request.comments + .map((comment) => comment.localDraftId) + .filter((id) => typeof id === 'string'), + ); + return { status: /** @type {const} */ ('submitted'), submittedDraftIds }; + } catch (error) { + return { + reason: error instanceof Error ? error.message : String(error), + status: /** @type {const} */ ('failed'), + submittedDraftIds, + }; + } + }; + + return { submitPullRequestComment, submitPullRequestReview }; +}; + +module.exports = { + PENDING_REVIEW_COMMENT_ERROR, + createGitHubReviewMutations, + normalizePullRequestComment, +}; diff --git a/electron/git-state/gitlab-review-mutations.cjs b/electron/git-state/gitlab-review-mutations.cjs new file mode 100644 index 00000000..c2da42a5 --- /dev/null +++ b/electron/git-state/gitlab-review-mutations.cjs @@ -0,0 +1,338 @@ +// @ts-check + +const { createHash } = require('node:crypto'); +const { git } = require('./common.cjs'); +const { + targetResolutionError, + validateCurrentReviewCommentTarget, +} = require('./review-comment-target.cjs'); + +/** + * @typedef {import('../../core/types.ts').PullRequestReviewComment} PullRequestReviewComment + * @typedef {{request: (request: {body?: unknown, method?: 'DELETE' | 'GET' | 'POST', path: string}) => Promise}} GitLabMutationTransport + */ + +/** @param {string} path @param {number | undefined} oldLine @param {number | undefined} newLine */ +const getGitLabLineCode = (path, oldLine, newLine) => + `${createHash('sha1').update(path).digest('hex')}_${oldLine || 0}_${newLine || 0}`; + +/** @param {string} diff */ +const createGitLabDiffLineMap = (diff) => { + const lines = new Map(); + let oldLine = 0; + let newLine = 0; + for (const line of diff.split('\n')) { + const hunk = line.match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/); + if (hunk) { + oldLine = Number(hunk[1]); + newLine = Number(hunk[2]); + } else if (line.startsWith('+') && !line.startsWith('+++')) { + lines.set(`additions:${newLine}`, { newLine }); + newLine += 1; + } else if (line.startsWith('-') && !line.startsWith('---')) { + lines.set(`deletions:${oldLine}`, { oldLine }); + oldLine += 1; + } else if (oldLine > 0 && newLine > 0 && line.startsWith(' ')) { + const value = { newLine, oldLine }; + lines.set(`additions:${newLine}`, value); + lines.set(`deletions:${oldLine}`, value); + oldLine += 1; + newLine += 1; + } + } + return lines; +}; + +/** @param {PullRequestReviewComment} comment @param {any} metadata @param {any} diff */ +const createGitLabPosition = (comment, metadata, diff) => { + const oldPath = diff?.old_path || comment.filePath; + const newPath = diff?.new_path || comment.filePath; + const targetRange = comment.position?.range; + const targetBaseSha = + targetRange?.base && 'sha' in targetRange.base + ? targetRange.base.sha + : metadata.diff_refs?.base_sha; + const targetHeadSha = + targetRange?.head && 'sha' in targetRange.head + ? targetRange.head.sha + : metadata.diff_refs?.head_sha || metadata.sha; + if (comment.anchor === 'file' || comment.lineNumber == null || comment.side == null) { + return { + base_sha: targetBaseSha, + head_sha: targetHeadSha, + new_path: newPath, + old_path: oldPath, + position_type: 'file', + start_sha: metadata.diff_refs?.start_sha, + }; + } + const lineMap = createGitLabDiffLineMap(diff?.diff || ''); + const endLines = lineMap.get(`${comment.side}:${comment.lineNumber}`) || { + ...(comment.side === 'deletions' + ? { oldLine: comment.lineNumber } + : { newLine: comment.lineNumber }), + }; + const position = { + base_sha: targetBaseSha, + head_sha: targetHeadSha, + new_path: newPath, + old_path: oldPath, + position_type: 'text', + start_sha: metadata.diff_refs?.start_sha, + ...(endLines.oldLine ? { old_line: endLines.oldLine } : {}), + ...(endLines.newLine ? { new_line: endLines.newLine } : {}), + }; + if (typeof comment.startLineNumber === 'number') { + const startSide = comment.startSide ?? comment.side; + const startLines = lineMap.get(`${startSide}:${comment.startLineNumber}`) || { + ...(startSide === 'deletions' + ? { oldLine: comment.startLineNumber } + : { newLine: comment.startLineNumber }), + }; + position.line_range = { + end: { + line_code: getGitLabLineCode(newPath, endLines.oldLine, endLines.newLine), + ...(endLines.oldLine ? { old_line: endLines.oldLine } : {}), + ...(endLines.newLine ? { new_line: endLines.newLine } : {}), + type: comment.side === 'deletions' ? 'old' : 'new', + }, + start: { + line_code: getGitLabLineCode(newPath, startLines.oldLine, startLines.newLine), + ...(startLines.oldLine ? { old_line: startLines.oldLine } : {}), + ...(startLines.newLine ? { new_line: startLines.newLine } : {}), + type: startSide === 'deletions' ? 'old' : 'new', + }, + }; + } + return position; +}; + +const findGitLabTargetDiff = (diffs, filePath) => + diffs.find((candidate) => candidate.new_path === filePath || candidate.old_path === filePath); + +/** @param {PullRequestReviewComment} comment @param {any} metadata @param {ReadonlyArray} diffs */ +const resolveGitLabCommentTarget = async (comment, metadata, diffs) => { + const target = { + baseSha: metadata.diff_refs?.base_sha, + files: diffs.map((diff) => ({ + newPath: diff.new_path, + oldPath: diff.old_path, + patch: diff.diff, + })), + headSha: metadata.diff_refs?.head_sha || metadata.sha, + }; + validateCurrentReviewCommentTarget(comment, target); + const diff = findGitLabTargetDiff(diffs, comment.filePath); + if (!diff) { + throw targetResolutionError(`File ${comment.filePath} is not in the target diff.`); + } + return { diff, metadata }; +}; + +/** @param {unknown} event */ +const getGitLabReviewQuickAction = (event) => { + if (event === 'APPROVE') return '/submit_review approve'; + if (event === 'COMMENT') return null; + if (event === 'REQUEST_CHANGES') return '/submit_review request_changes'; + throw new Error(`GitLab merge request reviews do not support ${String(event)}.`); +}; + +/** + * @param {{ + * createTransport: (repoRoot: string, mergeRequest: any) => GitLabMutationTransport, + * getDiscussionReplyEndpoint: (mergeRequest: any, threadId: string) => string, + * mergeRequestEndpoint: (mergeRequest: any, suffix?: string) => string, + * normalizeSubmittedGitLabReviewComment: (note: any, submittedComment: PullRequestReviewComment, url: string, threadId?: string) => import('../../core/types.ts').PullRequestExistingReviewComment | null, + * parseGitLabMergeRequestUrl: (value: string) => any, + * readMergeRequestDiffs: (repoRoot: string, mergeRequest: any, transport?: GitLabMutationTransport) => Promise>, + * readMergeRequestMetadata: (repoRoot: string, mergeRequest: any, transport?: GitLabMutationTransport) => Promise, + * selectMergeRequestRemote: (repoRoot: string, mergeRequest: any) => any, + * }} dependencies + */ +const createGitLabReviewMutations = ({ + createTransport, + getDiscussionReplyEndpoint, + mergeRequestEndpoint, + normalizeSubmittedGitLabReviewComment, + parseGitLabMergeRequestUrl, + readMergeRequestDiffs, + readMergeRequestMetadata, + selectMergeRequestRemote, +}) => { + /** @param {string} launchPath @param {any} request */ + const submitMergeRequestComment = async (launchPath, request) => { + const repoRoot = (await git(launchPath, ['rev-parse', '--show-toplevel'])).trim(); + const mergeRequest = parseGitLabMergeRequestUrl(request.source.url); + selectMergeRequestRemote(repoRoot, mergeRequest); + const transport = createTransport(repoRoot, mergeRequest); + if (request.comment.threadId) { + const note = await transport.request({ + body: { body: request.comment.body }, + method: 'POST', + path: getDiscussionReplyEndpoint(mergeRequest, request.comment.threadId), + }); + const comment = normalizeSubmittedGitLabReviewComment( + note, + request.comment, + mergeRequest.url, + ); + if (!comment) { + throw new Error('GitLab accepted the reply but did not return comment metadata.'); + } + return comment; + } + const metadata = await readMergeRequestMetadata(repoRoot, mergeRequest, transport); + const diffs = await readMergeRequestDiffs(repoRoot, mergeRequest, transport); + const target = await resolveGitLabCommentTarget(request.comment, metadata, diffs); + const discussion = await transport.request({ + body: { + body: request.comment.body, + position: createGitLabPosition(request.comment, target.metadata, target.diff), + }, + method: 'POST', + path: mergeRequestEndpoint(mergeRequest, '/discussions'), + }); + const comment = normalizeSubmittedGitLabReviewComment( + discussion.notes?.[0], + request.comment, + mergeRequest.url, + discussion.id, + ); + if (!comment) { + throw new Error('GitLab accepted the comment but did not return comment metadata.'); + } + return comment; + }; + + /** @param {string} launchPath @param {any} request */ + const submitMergeRequestReview = async (launchPath, request) => { + /** @type {Array<{localDraftId: string | null, remoteDraftId: string | null}>} */ + const createdDrafts = []; + /** @type {Array} */ + const submittedDraftIds = []; + /** @type {Array} */ + const outcomeUnknownDraftIds = []; + /** @type {GitLabMutationTransport | null} */ + let transport = null; + let mergeRequest = null; + try { + const summary = typeof request.body === 'string' ? request.body.trim() : ''; + if (request.event === 'COMMENT' && request.comments.length === 0 && !summary) { + throw new Error('A neutral review requires an inline comment or summary.'); + } + const quickAction = getGitLabReviewQuickAction(request.event); + const repoRoot = (await git(launchPath, ['rev-parse', '--show-toplevel'])).trim(); + mergeRequest = parseGitLabMergeRequestUrl(request.source.url); + selectMergeRequestRemote(repoRoot, mergeRequest); + transport = createTransport(repoRoot, mergeRequest); + const metadata = await readMergeRequestMetadata(repoRoot, mergeRequest, transport); + const currentHead = metadata.diff_refs?.head_sha || metadata.sha; + if (!currentHead) { + throw targetResolutionError('GitLab did not return the current merge request head.'); + } + if (request.source.headSha && currentHead !== request.source.headSha) { + throw targetResolutionError('The merge request head changed. Refresh before submitting.'); + } + const diffs = await readMergeRequestDiffs(repoRoot, mergeRequest, transport); + const targets = await Promise.all( + request.comments.map(async (comment) => ({ + comment, + target: await resolveGitLabCommentTarget(comment, metadata, diffs), + })), + ); + for (const { comment, target } of targets) { + const localDraftId = typeof comment.localDraftId === 'string' ? comment.localDraftId : null; + let createdDraft; + try { + createdDraft = await transport.request({ + body: { + note: comment.body, + position: createGitLabPosition(comment, target.metadata, target.diff), + }, + method: 'POST', + path: mergeRequestEndpoint(mergeRequest, '/draft_notes'), + }); + } catch (error) { + if (localDraftId) outcomeUnknownDraftIds.push(localDraftId); + throw error; + } + const remoteDraftId = + typeof createdDraft?.id === 'string' || typeof createdDraft?.id === 'number' + ? String(createdDraft.id) + : null; + createdDrafts.push({ localDraftId, remoteDraftId }); + if (!remoteDraftId) { + if (localDraftId) outcomeUnknownDraftIds.push(localDraftId); + throw new Error('GitLab accepted a draft note but did not return its ID.'); + } + } + if (request.event === 'COMMENT') { + await transport.request({ + body: { + ...(summary ? { note: summary } : {}), + reviewer_state: 'reviewed', + }, + method: 'POST', + path: mergeRequestEndpoint(mergeRequest, '/draft_notes/bulk_publish'), + }); + } else { + const finalBody = `${summary ? `${summary}\n\n` : ''}${quickAction}`; + await transport.request({ + body: { body: finalBody }, + method: 'POST', + path: mergeRequestEndpoint(mergeRequest, '/notes'), + }); + } + submittedDraftIds.push( + ...createdDrafts.flatMap(({ localDraftId }) => (localDraftId ? [localDraftId] : [])), + ); + return { status: /** @type {const} */ ('submitted'), submittedDraftIds }; + } catch (error) { + /** @type {Array} */ + const cleanupErrors = []; + if (transport && mergeRequest) { + for (const { localDraftId, remoteDraftId } of [...createdDrafts].reverse()) { + if (!remoteDraftId) { + if (localDraftId) outcomeUnknownDraftIds.push(localDraftId); + continue; + } + try { + await transport.request({ + method: 'DELETE', + path: mergeRequestEndpoint( + mergeRequest, + `/draft_notes/${encodeURIComponent(remoteDraftId)}`, + ), + }); + } catch (cleanupError) { + cleanupErrors.push( + cleanupError instanceof Error ? cleanupError.message : String(cleanupError), + ); + if (localDraftId) outcomeUnknownDraftIds.push(localDraftId); + } + } + } + const reason = error instanceof Error ? error.message : String(error); + const uniqueOutcomeUnknownDraftIds = [...new Set(outcomeUnknownDraftIds)]; + return { + ...(uniqueOutcomeUnknownDraftIds.length > 0 + ? { outcomeUnknownDraftIds: uniqueOutcomeUnknownDraftIds } + : {}), + reason: + cleanupErrors.length > 0 + ? `${reason} GitLab draft cleanup also failed: ${cleanupErrors.join('; ')}` + : reason, + status: /** @type {const} */ ('failed'), + submittedDraftIds: [], + }; + } + }; + + return { submitMergeRequestComment, submitMergeRequestReview }; +}; + +module.exports = { + createGitLabPosition, + createGitLabReviewMutations, + resolveGitLabCommentTarget, +}; diff --git a/electron/git-state/glab-gitlab-transport.cjs b/electron/git-state/glab-gitlab-transport.cjs new file mode 100644 index 00000000..914e3067 --- /dev/null +++ b/electron/git-state/glab-gitlab-transport.cjs @@ -0,0 +1,541 @@ +// @ts-check + +/** + * glab-backed GitLabTransport for local Codiff. + * Keeps executable discovery, process spawning, and credentials in Electron. + */ + +const { spawn } = require('node:child_process'); +const { getCommandActionSignal, startCommandTiming } = require('../command-log.cjs'); +const { homedir } = require('node:os'); +const { join } = require('node:path'); +const { findExecutableOnPath, isExecutableFile } = require('../agent-shared.cjs'); +const { getCommandEnvironment } = require('../login-shell-environment.cjs'); +const { getCurrentCommandSignal } = require('./common.cjs'); + +const DEFAULT_PROVIDER_OUTPUT_BYTES = 8 * 1024 * 1024; +const GLAB_NOT_FOUND_CODE = 'GLAB_NOT_FOUND'; +const GLAB_NOT_FOUND_MESSAGE = + 'GitLab support requires glab. Install glab, authenticate it, and verify `glab --version` works in Terminal. Codiff searches PATH, ~/.local/bin/glab, /opt/homebrew/bin/glab, and /usr/local/bin/glab. If glab is installed somewhere else, launch Codiff with `CODIFF_GLAB_PATH=/absolute/path/to/glab codiff -w`.'; +class ProviderOutputLimitError extends Error { + /** @param {number} maxBytes */ + constructor(maxBytes) { + super(`glab api response exceeded the ${maxBytes}-byte safety limit.`); + this.name = 'ProviderOutputLimitError'; + } +} + +/** @typedef {{errorName?: string, maxBytes?: number, outputLimitExceeded: boolean, promise: Promise, status: 'pending' | 'fulfilled' | 'rejected'}} SharedGetRequest */ + +const sharedGetRequests = new Map(); +const MAX_STDERR_BYTES = 1024 * 1024; + +/** @param {number | undefined} maxBytes */ +const normalizeMaxBytes = (maxBytes) => { + if (maxBytes == null) { + return DEFAULT_PROVIDER_OUTPUT_BYTES; + } + if (!Number.isFinite(maxBytes) || maxBytes < 0) { + throw new RangeError('maxBytes must be a finite non-negative number.'); + } + return Math.floor(maxBytes); +}; + +/** @param {number | undefined} current @param {number | undefined} requested */ +const mergeMaxBytes = (current, requested) => + current == null || requested == null ? undefined : Math.max(current, requested); + +/** @param {Buffer} bytes @param {number | undefined} maxBytes */ +const enforceOutputLimit = (bytes, maxBytes) => { + if (maxBytes != null && bytes.length > maxBytes) { + throw new ProviderOutputLimitError(maxBytes); + } + return bytes; +}; + +/** + * Share GETs within one cancellation boundary, briefly retaining completed + * bytes. The initial loader and history enrichment can apply their own + * response bounds to one provider read. Concurrent consumers can raise the + * acquisition bound until output crosses it; a later consumer with a larger + * bound starts a new read only when an earlier bounded read discarded bytes. + * @param {string} key + * @param {number | undefined} maxBytes + * @param {(options: {getMaxBytes: () => number | undefined, onOutputLimit: () => void}) => Promise} read + * @param {AbortSignal | undefined} signal + * @returns {Promise} + */ +const readSharedGet = (key, maxBytes, read, signal) => { + let requestsBySignal = sharedGetRequests.get(key); + if (!requestsBySignal) { + requestsBySignal = new Map(); + sharedGetRequests.set(key, requestsBySignal); + } + const signalKey = signal || null; + /** @type {SharedGetRequest | undefined} */ + const existing = requestsBySignal.get(signalKey); + if (existing) { + if (existing.status === 'fulfilled') { + return existing.promise; + } + const canReuseLimitFailure = + existing.maxBytes == null || (maxBytes != null && maxBytes <= existing.maxBytes); + if ( + existing.status === 'rejected' && + (existing.errorName !== 'ProviderOutputLimitError' || canReuseLimitFailure) + ) { + return existing.promise; + } + if (existing.status === 'pending' && (!existing.outputLimitExceeded || canReuseLimitFailure)) { + if (!existing.outputLimitExceeded) { + existing.maxBytes = mergeMaxBytes(existing.maxBytes, maxBytes); + } + return existing.promise; + } + } + + /** @type {SharedGetRequest} */ + const entry = { + maxBytes, + outputLimitExceeded: false, + promise: /** @type {Promise} */ (Promise.resolve(Buffer.alloc(0))), + status: 'pending', + }; + const request = Promise.resolve().then(() => + read({ + getMaxBytes: () => entry.maxBytes, + onOutputLimit: () => { + entry.outputLimitExceeded = true; + }, + }), + ); + entry.promise = request; + requestsBySignal.set(signalKey, entry); + const expire = () => { + const timeout = setTimeout(() => { + if (requestsBySignal.get(signalKey) === entry) { + requestsBySignal.delete(signalKey); + if (requestsBySignal.size === 0 && sharedGetRequests.get(key) === requestsBySignal) { + sharedGetRequests.delete(key); + } + } + }, 1000); + timeout.unref?.(); + }; + request.then( + () => { + entry.status = 'fulfilled'; + expire(); + }, + (error) => { + entry.errorName = error instanceof Error ? error.name : undefined; + entry.status = 'rejected'; + expire(); + }, + ); + return request; +}; + +/** @param {string} [detail] */ +const createGlabNotFoundError = (detail) => { + const error = /** @type {Error & { code?: string }} */ ( + new Error(detail ? `${GLAB_NOT_FOUND_MESSAGE} ${detail}` : GLAB_NOT_FOUND_MESSAGE) + ); + error.code = GLAB_NOT_FOUND_CODE; + return error; +}; + +const getGlabCommand = () => { + const glabPath = process.env.CODIFF_GLAB_PATH?.trim(); + if (glabPath) { + if (isExecutableFile(glabPath)) { + return glabPath; + } + throw createGlabNotFoundError( + `CODIFF_GLAB_PATH is set to ${JSON.stringify(glabPath)}, but that file is not executable.`, + ); + } + + const pathCommand = findExecutableOnPath('glab'); + if (pathCommand) { + return pathCommand; + } + + for (const path of [ + join(homedir(), '.local/bin/glab'), + '/opt/homebrew/bin/glab', + '/usr/local/bin/glab', + ]) { + if (isExecutableFile(path)) { + return path; + } + } + + throw createGlabNotFoundError(); +}; + +/** + * @param {string} hostname + * @param {string} path + * @param {Readonly> | undefined} query + * @param {string | undefined} method + * @param {unknown} body + */ +const createGlabApiArgs = (hostname, path, query, method, body) => { + const url = new URL(path, 'https://gitlab.local'); + if (query) { + for (const [key, value] of Object.entries(query)) { + url.searchParams.set(key, String(value)); + } + } + const apiPath = url.pathname.replace(/^\/api\/v4(?=\/|$)/, ''); + const resource = `${apiPath || '/'}${url.search}`; + /** @type {Array} */ + const args = ['api', '--hostname', hostname]; + if (method && method !== 'GET') { + args.push('--method', method); + } + if (body != null) { + args.push('--header', 'Content-Type: application/json', '--input', '-'); + } + args.push(resource); + return args; +}; + +/** + * @param {string} repoRoot + * @param {string} hostname + * @param {ReadonlyArray} args + * @param {unknown} [input] + * @param {{getMaxBytes?: () => number | undefined, maxBytes?: number, onOutputLimit?: () => void, signal?: AbortSignal}} [options] + * @returns {Promise} + */ +const runGlabApiBuffer = async (repoRoot, hostname, args, input, options = {}) => { + const environment = await getCommandEnvironment(); + return new Promise((resolve, reject) => { + const fixedMaxBytes = normalizeMaxBytes(options.maxBytes); + const signal = options.signal || getCurrentCommandSignal() || getCommandActionSignal(); + let command; + try { + command = getGlabCommand(); + } catch (error) { + reject(error); + return; + } + + const timing = startCommandTiming({ args, command, cwd: repoRoot }); + let child; + try { + signal?.throwIfAborted(); + child = spawn(command, args, { + cwd: repoRoot, + env: environment, + signal, + stdio: ['pipe', 'pipe', 'pipe'], + }); + } catch (error) { + timing.finish({ canceled: signal?.aborted, error }); + reject(error); + return; + } + const stdout = []; + const stderr = []; + let stderrBytes = 0; + let outputBytes = 0; + let outputLimit; + let settled = false; + + /** + * @param {unknown} reason + * @param {{canceled?: boolean, exitCode?: number | null, signal?: string | null}} [result] + */ + const fail = (reason, result = {}) => { + if (settled) return; + settled = true; + const error = reason instanceof Error ? reason : new Error(String(reason)); + timing.finish({ + canceled: result.canceled, + error, + exitCode: result.exitCode, + signal: result.signal, + }); + reject(error); + }; + + const abortError = () => { + const reason = signal?.reason; + if (reason instanceof Error) return reason; + const error = new Error('glab api request was aborted.'); + error.name = 'AbortError'; + return error; + }; + + child.stdout.on('data', (value) => { + if (settled) return; + const chunk = Buffer.isBuffer(value) ? value : Buffer.from(value); + outputBytes += chunk.length; + if (outputLimit != null) { + return; + } + const maxBytes = options.getMaxBytes?.() ?? fixedMaxBytes; + if (maxBytes != null && outputBytes > maxBytes) { + outputLimit = maxBytes; + stdout.length = 0; + options.onOutputLimit?.(); + return; + } + stdout.push(chunk); + }); + child.stderr.on('data', (value) => { + if (settled || stderrBytes >= MAX_STDERR_BYTES) return; + const chunk = Buffer.isBuffer(value) ? value : Buffer.from(value); + const retained = chunk.subarray(0, MAX_STDERR_BYTES - stderrBytes); + stderr.push(Buffer.from(retained)); + stderrBytes += retained.length; + }); + child.stdin.on('error', (error) => { + if (!settled && /** @type {NodeJS.ErrnoException} */ (error).code !== 'EPIPE') { + fail(error, { canceled: signal?.aborted }); + child.kill(); + } + }); + child.on('error', (error) => { + fail( + /** @type {NodeJS.ErrnoException} */ (error).code === 'ENOENT' + ? createGlabNotFoundError() + : error, + { canceled: error.name === 'AbortError' || signal?.aborted }, + ); + }); + child.on('close', (code, childSignal) => { + if (settled) return; + if (signal?.aborted) { + fail(abortError(), { canceled: true, exitCode: code, signal: childSignal }); + return; + } + if (code === 0) { + if (outputLimit != null) { + fail(new ProviderOutputLimitError(outputLimit), { + exitCode: code, + signal: childSignal, + }); + } else { + settled = true; + timing.finish({ exitCode: code }); + resolve(Buffer.concat(stdout, outputBytes)); + } + } else { + const error = new Error( + Buffer.concat(stderr).toString('utf8').trim() || `glab api exited with code ${code}.`, + ); + fail(error, { exitCode: code, signal: childSignal }); + } + }); + try { + if (input == null) { + child.stdin.end(); + } else { + child.stdin.end(typeof input === 'string' ? input : JSON.stringify(input)); + } + } catch (error) { + fail(error, { canceled: signal?.aborted }); + child.kill(); + } + }); +}; + +/** + * @param {string} repoRoot + * @param {string} hostname + * @param {ReadonlyArray} args + * @param {unknown} [input] + * @param {{maxBytes?: number, signal?: AbortSignal}} [options] + */ +const runGlabApi = async (repoRoot, hostname, args, input, options = {}) => + (await runGlabApiBuffer(repoRoot, hostname, args, input, options)).toString('utf8'); + +/** + * @param {{ hostname: string, repoRoot: string, signal?: AbortSignal }} options + */ +const createGlabGitLabTransport = ({ hostname, repoRoot, signal: defaultSignal }) => { + /** + * @param {ReadonlyArray} args + * @param {unknown} input + * @param {{maxBytes?: number, sharedKey?: string, signal?: AbortSignal}} options + */ + const readApiBuffer = async (args, input, options) => { + const maxBytes = normalizeMaxBytes(options.maxBytes); + const bytes = options.sharedKey + ? await readSharedGet( + options.sharedKey, + maxBytes, + ({ getMaxBytes, onOutputLimit }) => + runGlabApiBuffer(repoRoot, hostname, args, input, { + getMaxBytes, + onOutputLimit, + signal: options.signal, + }), + options.signal, + ) + : await runGlabApiBuffer(repoRoot, hostname, args, input, { + maxBytes, + signal: options.signal, + }); + return enforceOutputLimit(bytes, maxBytes); + }; + + /** + * @param {{ + * maxBytes?: number, + * method?: 'GET' | 'POST' | 'PUT' | 'DELETE', + * path: string, + * query?: Readonly>, + * body?: unknown, + * signal?: AbortSignal, + * }} request + */ + const requestText = async (request) => { + const args = createGlabApiArgs( + hostname, + request.path, + request.query, + request.method, + request.body, + ); + const signal = + request.signal || defaultSignal || getCurrentCommandSignal() || getCommandActionSignal(); + const sharedKey = + request.body == null && (!request.method || request.method === 'GET') + ? `${repoRoot}\0${hostname}\0text\0${args.join('\0')}` + : undefined; + return ( + await readApiBuffer(args, request.body, { + maxBytes: request.maxBytes, + sharedKey, + signal, + }) + ).toString('utf8'); + }; + + /** + * glab writes each JSON page consecutively when `--paginate` is used. Parse + * the stream as JSON values instead of assuming a particular separator. + * @param {string} text + * @returns {Array} + */ + const parseJsonPages = (text) => { + /** @type {Array} */ + const pages = []; + let depth = 0; + let escape = false; + let inString = false; + let start = -1; + + for (let index = 0; index < text.length; index += 1) { + const character = text[index]; + if (start === -1) { + if (/\s/.test(character)) { + continue; + } + if (character !== '[' && character !== '{') { + throw new SyntaxError(`Unexpected character in glab JSON output at position ${index}.`); + } + start = index; + depth = 1; + continue; + } + + if (inString) { + if (escape) { + escape = false; + } else if (character === '\\') { + escape = true; + } else if (character === '"') { + inString = false; + } + continue; + } + + if (character === '"') { + inString = true; + } else if (character === '[' || character === '{') { + depth += 1; + } else if (character === ']' || character === '}') { + depth -= 1; + if (depth === 0) { + pages.push(JSON.parse(text.slice(start, index + 1))); + start = -1; + } + } + } + + if (start !== -1 || inString) { + throw new SyntaxError('Incomplete JSON document in glab output.'); + } + return pages; + }; + + return { + /** + * @template T + * @param {{ + * maxBytes?: number, + * method?: 'GET' | 'POST' | 'PUT' | 'DELETE', + * path: string, + * query?: Readonly>, + * body?: unknown, + * signal?: AbortSignal, + * }} request + * @returns {Promise} + */ + async request(request) { + const text = await requestText(request); + if (!text.trim()) { + return /** @type {T} */ (null); + } + return /** @type {T} */ (JSON.parse(text)); + }, + async requestBuffer(request) { + const args = createGlabApiArgs(hostname, request.path, request.query, undefined, undefined); + const signal = + request.signal || defaultSignal || getCurrentCommandSignal() || getCommandActionSignal(); + return readApiBuffer(args, undefined, { + maxBytes: request.maxBytes, + sharedKey: `${repoRoot}\0${hostname}\0buffer\0${args.join('\0')}`, + signal, + }); + }, + async requestPages(request) { + const args = createGlabApiArgs(hostname, request.path, request.query, undefined, undefined); + args.splice(-1, 0, '--paginate'); + const signal = + request.signal || defaultSignal || getCurrentCommandSignal() || getCommandActionSignal(); + const pages = parseJsonPages( + ( + await readApiBuffer(args, undefined, { + maxBytes: request.maxBytes, + sharedKey: `${repoRoot}\0${hostname}\0${args.join('\0')}`, + signal, + }) + ).toString('utf8'), + ); + return pages.flatMap((page) => { + if (!Array.isArray(page)) { + throw new Error('glab returned a non-array paginated response.'); + } + return page; + }); + }, + requestText, + }; +}; + +module.exports = { + GLAB_NOT_FOUND_CODE, + ProviderOutputLimitError, + createGlabGitLabTransport, + createGlabNotFoundError, + getGlabCommand, + runGlabApi, + runGlabApiBuffer, +}; diff --git a/electron/git-state/merge-request.cjs b/electron/git-state/merge-request.cjs index 2e3345a8..8c0fb062 100644 --- a/electron/git-state/merge-request.cjs +++ b/electron/git-state/merge-request.cjs @@ -1,74 +1,28 @@ // @ts-check -const { createHash } = require('node:crypto'); -const { spawn } = require('node:child_process'); -const { homedir } = require('node:os'); -const { join } = require('node:path'); -const { findExecutableOnPath, isExecutableFile } = require('../agent-shared.cjs'); -const { getCommandEnvironment } = require('../login-shell-environment.cjs'); +const { getCurrentCommandSignal, git } = require('./common.cjs'); const { - getFingerprint, - git, - gitOrEmpty, - readGitImageFile, - validateRepositoryPath, -} = require('./common.cjs'); -const { readGitFiles } = require('./git-files.cjs'); -const { - createPatchFromPullRequestFile, - createPullRequestSection, - normalizeGitHubCommit, -} = require('./pull-request.cjs'); + createGitLabPosition, + createGitLabReviewMutations, + resolveGitLabCommentTarget, +} = require('./gitlab-review-mutations.cjs'); +const { createGlabGitLabTransport } = require('./glab-gitlab-transport.cjs'); +const { loadGitLabHistory } = require('../gitlab-history-bridge.cjs'); +const { normalizeGitHubCommit } = require('./pull-request.cjs'); +const { rangeArtifactToPullRequestFiles } = require('./review-range-sections.cjs'); const { parseReviewUrl, readReviewRemotes } = require('../review-source.cjs'); /** - * @typedef {import('../../core/types.ts').ChangedFile} ChangedFile * @typedef {import('../../core/types.ts').PullRequestReviewComment} PullRequestReviewComment * @typedef {import('../../core/types.ts').ReviewSource} ReviewSource */ -const GLAB_NOT_FOUND_CODE = 'GLAB_NOT_FOUND'; -const GLAB_NOT_FOUND_MESSAGE = - 'GitLab support requires glab. Install glab, authenticate it, and verify `glab --version` works in Terminal. Codiff searches PATH, ~/.local/bin/glab, /opt/homebrew/bin/glab, and /usr/local/bin/glab. If glab is installed somewhere else, launch Codiff with `CODIFF_GLAB_PATH=/absolute/path/to/glab codiff -w`.'; - -/** @param {string} [detail] */ -const createGlabNotFoundError = (detail) => - Object.assign( - new Error(detail ? `${GLAB_NOT_FOUND_MESSAGE} ${detail}` : GLAB_NOT_FOUND_MESSAGE), - { - code: GLAB_NOT_FOUND_CODE, - }, - ); - -const getGlabCommand = () => { - const glabPath = process.env.CODIFF_GLAB_PATH?.trim(); - if (glabPath) { - if (isExecutableFile(glabPath)) { - return glabPath; - } - - throw createGlabNotFoundError( - `CODIFF_GLAB_PATH is set to ${JSON.stringify(glabPath)}, but that file is not executable.`, - ); - } - - const pathCommand = findExecutableOnPath('glab'); - if (pathCommand) { - return pathCommand; - } - - for (const path of [ - join(homedir(), '.local/bin/glab'), - '/opt/homebrew/bin/glab', - '/usr/local/bin/glab', - ]) { - if (isExecutableFile(path)) { - return path; - } - } - - throw createGlabNotFoundError(); -}; +/** + * Latest initial provider snapshots, keyed by local root and MR URL. + * @type {Map} + */ +const mergeRequestHydrationSnapshots = new Map(); +const MAX_MERGE_REQUEST_HYDRATION_SNAPSHOTS = 8; /** @param {string} value */ const parseGitLabMergeRequestUrl = (value) => { @@ -82,116 +36,6 @@ const parseGitLabMergeRequestUrl = (value) => { /** @param {string} projectPath */ const encodeProjectPath = (projectPath) => encodeURIComponent(projectPath); -/** - * @param {{host: string}} mergeRequest - * @param {ReadonlyArray} args - * @param {unknown} [input] - */ -const createGlabApiArgs = (mergeRequest, args, input) => [ - 'api', - '--hostname', - mergeRequest.host, - ...(input == null ? [] : ['--header', 'Content-Type: application/json']), - ...args, -]; - -/** - * @param {string} repoRoot - * @param {{host: string}} mergeRequest - * @param {ReadonlyArray} args - * @param {unknown} [input] - */ -const glabApi = async (repoRoot, mergeRequest, args, input) => { - const environment = await getCommandEnvironment(); - return new Promise((resolve, reject) => { - let command; - try { - command = getGlabCommand(); - } catch (error) { - reject(error); - return; - } - - const child = spawn(command, createGlabApiArgs(mergeRequest, args, input), { - cwd: repoRoot, - env: environment, - stdio: ['pipe', 'pipe', 'pipe'], - }); - const stdout = []; - const stderr = []; - child.stdout.on('data', (chunk) => stdout.push(chunk)); - child.stderr.on('data', (chunk) => stderr.push(chunk)); - child.on('error', (error) => { - reject(error.code === 'ENOENT' ? createGlabNotFoundError() : error); - }); - child.on('close', (code) => { - if (code === 0) { - resolve(Buffer.concat(stdout).toString('utf8')); - } else { - reject( - new Error( - Buffer.concat(stderr).toString('utf8').trim() || `glab api exited with code ${code}.`, - ), - ); - } - }); - child.stdin.end(input == null ? undefined : JSON.stringify(input)); - }); -}; - -/** @param {string} value */ -const parseGlabJsonPages = (value) => { - const documents = []; - let depth = 0; - let escape = false; - let inString = false; - let start = -1; - - for (let index = 0; index < value.length; index += 1) { - const character = value[index]; - if (start === -1) { - if (/\s/.test(character)) { - continue; - } - if (character !== '[' && character !== '{') { - throw new SyntaxError(`Unexpected character in glab JSON output at position ${index}.`); - } - start = index; - depth = 1; - continue; - } - - if (inString) { - if (escape) { - escape = false; - } else if (character === '\\') { - escape = true; - } else if (character === '"') { - inString = false; - } - continue; - } - - if (character === '"') { - inString = true; - } else if (character === '[' || character === '{') { - depth += 1; - } else if (character === ']' || character === '}') { - depth -= 1; - if (depth === 0) { - documents.push(JSON.parse(value.slice(start, index + 1))); - start = -1; - } - } - } - - if (start !== -1 || inString) { - throw new SyntaxError('Incomplete JSON document in glab output.'); - } - - return documents.flatMap((document) => (Array.isArray(document) ? document : [document])); -}; - /** @param {{number: number; projectPath: string}} mergeRequest */ const mergeRequestEndpoint = (mergeRequest, suffix = '') => `projects/${encodeProjectPath(mergeRequest.projectPath)}/merge_requests/${ @@ -227,36 +71,105 @@ const selectMergeRequestRemote = (repoRoot, mergeRequest) => { return remote; }; -/** @param {string} repoRoot @param {ReturnType} mergeRequest */ -const readMergeRequestMetadata = async (repoRoot, mergeRequest) => - JSON.parse(await glabApi(repoRoot, mergeRequest, [mergeRequestEndpoint(mergeRequest)])); +/** Provider transport backed by the authenticated `glab` process owned by Electron. */ +const createMergeRequestTransport = (repoRoot, mergeRequest) => + createGlabGitLabTransport({ hostname: mergeRequest.host, repoRoot }); + +/** + * @param {string} repoRoot + * @param {ReturnType} mergeRequest + * @param {{request: (request: any) => Promise}} [transport] + */ +const readMergeRequestMetadata = (repoRoot, mergeRequest, transport) => + (transport || createMergeRequestTransport(repoRoot, mergeRequest)).request({ + path: mergeRequestEndpoint(mergeRequest), + }); + +/** + * @param {string} repoRoot + * @param {ReturnType} mergeRequest + * @param {ReturnType} [transport] + */ +const readMergeRequestDiffs = (repoRoot, mergeRequest, transport) => + (transport || createMergeRequestTransport(repoRoot, mergeRequest)).requestPages({ + path: mergeRequestEndpoint(mergeRequest, '/diffs'), + query: { per_page: 100 }, + }); /** @param {string} repoRoot @param {ReturnType} mergeRequest */ -const readMergeRequestDiffs = async (repoRoot, mergeRequest) => - parseGlabJsonPages( - await glabApi(repoRoot, mergeRequest, [ - '--paginate', - `${mergeRequestEndpoint(mergeRequest, '/diffs')}?per_page=100`, - ]), +const mergeRequestHydrationSnapshotKey = (repoRoot, mergeRequest) => + `${repoRoot}:${mergeRequest.url}`; + +/** + * @param {string} repoRoot + * @param {ReturnType} mergeRequest + * @param {{headSha?: string, metadata: any, range: import('../../core/lib/review-artifacts.ts').RangeArtifact}} snapshot + */ +const rememberMergeRequestHydrationSnapshot = (repoRoot, mergeRequest, snapshot) => { + const key = mergeRequestHydrationSnapshotKey(repoRoot, mergeRequest); + mergeRequestHydrationSnapshots.delete(key); + mergeRequestHydrationSnapshots.set(key, snapshot); + while (mergeRequestHydrationSnapshots.size > MAX_MERGE_REQUEST_HYDRATION_SNAPSHOTS) { + mergeRequestHydrationSnapshots.delete(mergeRequestHydrationSnapshots.keys().next().value); + } +}; + +/** + * @param {string} repoRoot + * @param {ReturnType} mergeRequest + * @param {{expectedHeadSha?: string, forceRefresh?: boolean, signal?: AbortSignal}} [options] + */ +const readMergeRequestHydrationSnapshot = async (repoRoot, mergeRequest, options = {}) => { + const key = mergeRequestHydrationSnapshotKey(repoRoot, mergeRequest); + const cached = mergeRequestHydrationSnapshots.get(key); + if ( + !options.forceRefresh && + options.expectedHeadSha && + cached?.headSha === options.expectedHeadSha + ) { + return cached; + } + const transport = createMergeRequestTransport(repoRoot, mergeRequest); + const [gitlab, metadata] = await Promise.all([ + loadGitLabHistory(), + readMergeRequestMetadata(repoRoot, mergeRequest, transport), + ]); + const baseSha = metadata.diff_refs?.base_sha; + const headSha = metadata.diff_refs?.head_sha || metadata.sha; + if (!baseSha || !headSha) { + throw new Error('GitLab did not return complete merge request range coordinates.'); + } + if (options.expectedHeadSha && headSha !== options.expectedHeadSha) { + throw new Error('The merge request head changed. Refresh before loading exact file contents.'); + } + const project = { + host: mergeRequest.host, + project: mergeRequest.projectPath, + provider: /** @type {'gitlab'} */ ('gitlab'), + }; + const artifactSource = gitlab.createGitLabArtifactSource({ + project, + projectPath: mergeRequest.projectPath, + transport, + }); + const { range } = await artifactSource.readStackAndRange( + { requestedBaseSha: baseSha, headSha }, + options.signal ?? getCurrentCommandSignal() ?? new AbortController().signal, ); + const snapshot = { headSha, metadata, range }; + rememberMergeRequestHydrationSnapshot(repoRoot, mergeRequest, snapshot); + return snapshot; +}; -/** @param {any} diff */ -const normalizeGitLabDiffFile = (diff) => ({ - filename: diff.new_path, - patch: diff.diff, - ...(diff.old_path !== diff.new_path ? { previous_filename: diff.old_path } : {}), - status: diff.new_file - ? 'added' - : diff.deleted_file - ? 'removed' - : diff.renamed_file - ? 'renamed' - : 'modified', +/** @param {string} sha */ +const createReviewCommitRevision = (sha) => ({ + label: { kind: /** @type {const} */ ('commit'), text: sha.slice(0, 7) }, + sha, }); -/** @param {any} note @param {string} url */ -const normalizeGitLabReviewComment = (note, url) => { - const position = note.position || note.original_position; +/** @param {any} note @param {string} url @param {string} [threadId] @param {any} [rootNote] */ +const normalizeGitLabReviewComment = (note, url, threadId, rootNote = note) => { + const position = rootNote.position || rootNote.original_position; const isFilePosition = position?.position_type === 'file'; const lineNumber = position?.new_line ?? position?.old_line; const filePath = position?.new_path || position?.old_path; @@ -267,6 +180,12 @@ const normalizeGitLabReviewComment = (note, url) => { const range = position.line_range; const start = range?.start; const end = range?.end; + const endSide = end?.type === 'old' ? 'deletions' : side; + const startSide = start?.type === 'old' ? 'deletions' : 'additions'; + const startLineNumber = start?.new_line ?? start?.old_line; + const endLineNumber = end?.new_line ?? end?.old_line ?? lineNumber; + const hasRange = + startLineNumber != null && (startLineNumber !== endLineNumber || startSide !== endSide); return { author: { avatarUrl: note.author?.avatar_url, @@ -276,27 +195,40 @@ const normalizeGitLabReviewComment = (note, url) => { body: note.body, filePath, id: `gitlab:${note.id}`, - ...(!note.position ? { isOutdated: true } : {}), + ...(!rootNote.position ? { isOutdated: true } : {}), ...(isFilePosition ? { anchor: 'file' } : { - lineNumber: end?.new_line ?? end?.old_line ?? lineNumber, - side: end?.type === 'old' ? 'deletions' : side, + lineNumber: endLineNumber, + side: endSide, }), - ...(start && (start.new_line ?? start.old_line) !== (end?.new_line ?? end?.old_line) + ...(hasRange ? { - startLineNumber: start.new_line ?? start.old_line, - startSide: start.type === 'old' ? 'deletions' : 'additions', + startLineNumber, + ...(startSide !== endSide ? { startSide } : {}), } : {}), + ...(threadId ? { threadId } : {}), + ...(position?.base_sha && position?.head_sha + ? { + position: { + range: { + base: createReviewCommitRevision(position.base_sha), + head: createReviewCommitRevision(position.head_sha), + }, + }, + } + : {}), + ...(rootNote.resolvable === true ? { canResolveThread: true } : {}), + ...(rootNote.resolved === true ? { isThreadResolved: true } : {}), submittedAt: note.created_at, url: `${url}#note_${note.id}`, }; }; -/** @param {any} note @param {PullRequestReviewComment} submittedComment @param {string} url */ -const normalizeSubmittedGitLabReviewComment = (note, submittedComment, url) => { - const normalized = normalizeGitLabReviewComment(note, url); +/** @param {any} note @param {PullRequestReviewComment} submittedComment @param {string} url @param {string} [threadId] */ +const normalizeSubmittedGitLabReviewComment = (note, submittedComment, url, threadId) => { + const normalized = normalizeGitLabReviewComment(note, url, threadId || submittedComment.threadId); if (normalized) { return normalized; } @@ -317,19 +249,60 @@ const normalizeSubmittedGitLabReviewComment = (note, submittedComment, url) => { }; }; -/** @param {string} repoRoot @param {ReturnType} mergeRequest */ -const readMergeRequestComments = async (repoRoot, mergeRequest) => { - const discussions = parseGlabJsonPages( - await glabApi(repoRoot, mergeRequest, [ - '--paginate', - `${mergeRequestEndpoint(mergeRequest, '/discussions')}?per_page=100`, - ]), - ); - return discussions - .flatMap((discussion) => discussion.notes || []) - .filter((note) => !note.system && !note.resolved) - .map((note) => normalizeGitLabReviewComment(note, mergeRequest.url)) - .filter(Boolean); +/** + * @param {string} repoRoot + * @param {ReturnType} mergeRequest + * @param {ReturnType} [transport] + */ +const readMergeRequestComments = async (repoRoot, mergeRequest, transport) => { + const discussions = await ( + transport || createMergeRequestTransport(repoRoot, mergeRequest) + ).requestPages({ + path: mergeRequestEndpoint(mergeRequest, '/discussions'), + query: { per_page: 100 }, + }); + return { + generalComments: discussions + .map((discussion) => normalizeGitLabGeneralDiscussion(discussion, mergeRequest.url)) + .filter(Boolean), + reviewComments: discussions.flatMap((discussion) => { + const notes = (discussion.notes || []).filter((note) => !note.system && note.body); + const root = notes[0]; + if (!root || (!root.position && !root.original_position)) { + return []; + } + return notes + .map((note) => normalizeGitLabReviewComment(note, mergeRequest.url, discussion.id, root)) + .filter(Boolean); + }), + }; +}; + +/** Treat a positionless GitLab discussion as one overview-comment thread. */ +const normalizeGitLabGeneralDiscussion = (discussion, url) => { + const notes = (discussion?.notes || []).filter((note) => !note.system && note.body); + const root = notes[0]; + if (!root || root.position || root.original_position) { + return null; + } + const comments = notes.map((note) => ({ + author: { + avatarUrl: note.author?.avatar_url, + login: note.author?.username || note.author?.name || 'GitLab user', + url: note.author?.web_url, + }, + body: note.body, + id: `gitlab:${note.id}`, + submittedAt: note.created_at, + url: `${url}#note_${note.id}`, + })); + const id = typeof discussion?.id === 'string' ? discussion.id : String(discussion?.id || ''); + return { + ...(root.resolvable === true ? { canResolve: true } : {}), + comments, + id: id || `gitlab:general:${comments[0].id}`, + ...(root.resolved === true ? { isResolved: true } : {}), + }; }; /** @param {ReturnType} mergeRequest @param {any} metadata @returns {Extract} */ @@ -346,11 +319,12 @@ const createMergeRequestSource = (mergeRequest, metadata) => ({ ...(typeof metadata.description === 'string' && metadata.description.trim() ? { description: metadata.description.trim() } : {}), - headSha: metadata.sha, + headSha: metadata.diff_refs?.head_sha || metadata.sha, host: mergeRequest.host, number: mergeRequest.number, projectPath: mergeRequest.projectPath, provider: 'gitlab', + ...(metadata.target_branch ? { targetBranch: metadata.target_branch } : {}), title: metadata.title, type: 'pull-request', url: metadata.web_url || mergeRequest.url, @@ -366,111 +340,42 @@ const createMergeRequestFetchRefspecs = (mergeRequest, metadata) => [ : []), ]; -/** @param {string} repoRoot @param {any} remote @param {any} mergeRequest @param {any} metadata */ -const fetchMergeRequestRefs = (repoRoot, remote, mergeRequest, metadata) => - git(repoRoot, [ - 'fetch', - '--no-tags', - remote.name, - ...createMergeRequestFetchRefspecs(mergeRequest, metadata), - ]); - -/** @param {string} repoRoot @param {any} mergeRequest @param {any} metadata */ -const resolveMergeRequestContentRefs = async (repoRoot, mergeRequest, metadata) => { - const head = `refs/codiff/merge-requests/${mergeRequest.number}/head`; - const base = `refs/codiff/merge-requests/${mergeRequest.number}/base`; - const localHead = (await gitOrEmpty(repoRoot, ['rev-parse', '--verify', '--quiet', head])).trim(); - const localBase = (await gitOrEmpty(repoRoot, ['rev-parse', '--verify', '--quiet', base])).trim(); - if (!localHead || !localBase || (metadata.sha && localHead !== metadata.sha)) { - await fetchMergeRequestRefs( - repoRoot, - selectMergeRequestRemote(repoRoot, mergeRequest), - mergeRequest, - metadata, - ); - } - const metadataBase = metadata.diff_refs?.base_sha; - if ( - metadataBase && - (await gitOrEmpty(repoRoot, ['rev-parse', '--verify', '--quiet', `${metadataBase}^{commit}`])) - ) { - return { base: metadataBase, head }; - } - const mergeBase = (await gitOrEmpty(repoRoot, ['merge-base', base, head])).trim(); - return mergeBase ? { base: mergeBase, head } : null; -}; - /** @param {string} launchPath @param {Extract} source */ const readMergeRequestState = async (launchPath, source) => { const repoRoot = (await git(launchPath, ['rev-parse', '--show-toplevel'])).trim(); const mergeRequest = parseGitLabMergeRequestUrl(source.url); selectMergeRequestRemote(repoRoot, mergeRequest); - const [metadata, diffs, reviewComments] = await Promise.all([ - readMergeRequestMetadata(repoRoot, mergeRequest), - readMergeRequestDiffs(repoRoot, mergeRequest), - readMergeRequestComments(repoRoot, mergeRequest), - ]); - const refs = await resolveMergeRequestContentRefs(repoRoot, mergeRequest, metadata).catch( - () => null, - ); - const reviewFiles = diffs.map((rawDiff) => { - const file = normalizeGitLabDiffFile(rawDiff); - return { - file, - oldPath: file.previous_filename || file.filename, - patch: createPatchFromPullRequestFile(file), - }; + const { metadata, range } = await readMergeRequestHydrationSnapshot(repoRoot, mergeRequest, { + forceRefresh: true, }); - const [oldFiles, newFiles] = refs - ? await Promise.all([ - readGitFiles( - repoRoot, - refs.base, - reviewFiles.map(({ oldPath }) => oldPath), - { refScopedEmptyCacheKey: true }, - ), - readGitFiles( - repoRoot, - refs.head, - reviewFiles.map(({ file }) => file.filename), - { refScopedEmptyCacheKey: true }, - ), - ]) - : [new Map(), new Map()]; - /** @type {Array} */ - const files = reviewFiles.map(({ file, oldPath, patch }) => { - const oldFile = refs ? oldFiles.get(oldPath) : null; - const newFile = refs ? newFiles.get(file.filename) : null; - const section = createPullRequestSection(mergeRequest, file, patch, oldFile, newFile); - return { - fingerprint: getFingerprint( - [metadata.sha || '', file.status, file.previous_filename || '', file.filename, patch].join( - '\n', - ), - ), - oldPath: file.previous_filename, - path: file.filename, - sections: [section], - status: - file.status === 'added' - ? 'added' - : file.status === 'removed' - ? 'deleted' - : file.status === 'renamed' - ? 'renamed' - : 'modified', - }; + const files = rangeArtifactToPullRequestFiles(range, mergeRequest.number, { + deferContents: true, }); return { files: files.sort((left, right) => left.path.localeCompare(right.path)), generatedAt: Date.now(), launchPath, - reviewComments, + reviewCommentsLoadState: 'not-loaded', root: repoRoot, source: createMergeRequestSource(mergeRequest, metadata), }; }; +/** @param {string} launchPath @param {Extract} source */ +const readMergeRequestReviewComments = async (launchPath, source) => { + const repoRoot = (await git(launchPath, ['rev-parse', '--show-toplevel'])).trim(); + const mergeRequest = parseGitLabMergeRequestUrl(source.url); + selectMergeRequestRemote(repoRoot, mergeRequest); + const transport = createMergeRequestTransport(repoRoot, mergeRequest); + const comments = await readMergeRequestComments(repoRoot, mergeRequest, transport); + const metadata = await readMergeRequestMetadata(repoRoot, mergeRequest, transport); + const headSha = metadata.diff_refs?.head_sha || metadata.sha; + if (source.headSha && headSha !== source.headSha) { + throw new Error('The merge request head changed. Refresh before loading review comments.'); + } + return comments; +}; + /** @param {any} commit @param {'base' | 'pull-request'} scope */ const normalizeGitLabCommit = (commit, scope) => normalizeGitHubCommit( @@ -488,18 +393,22 @@ const normalizeGitLabCommit = (commit, scope) => scope, ); -/** @param {string} repoRoot @param {any} mergeRequest @param {string} ref @param {number} limit */ -const readRepositoryCommits = async (repoRoot, mergeRequest, ref, limit) => { +/** + * @param {string} repoRoot + * @param {any} mergeRequest + * @param {string} ref + * @param {number} limit + * @param {ReturnType} [transport] + */ +const readRepositoryCommits = async (repoRoot, mergeRequest, ref, limit, transport) => { + const client = transport || createMergeRequestTransport(repoRoot, mergeRequest); const commits = []; for (let page = 1; commits.length < limit; page += 1) { const perPage = Math.min(limit - commits.length, 100); - const pageCommits = JSON.parse( - await glabApi(repoRoot, mergeRequest, [ - `projects/${encodeProjectPath(mergeRequest.projectPath)}/repository/commits?ref_name=${encodeURIComponent( - ref, - )}&per_page=${perPage}&page=${page}`, - ]), - ); + const pageCommits = await client.request({ + path: `projects/${encodeProjectPath(mergeRequest.projectPath)}/repository/commits`, + query: { page, per_page: perPage, ref_name: ref }, + }); if (!Array.isArray(pageCommits) || pageCommits.length === 0) { break; } @@ -515,15 +424,14 @@ const readRepositoryCommits = async (repoRoot, mergeRequest, ref, limit) => { const listMergeRequestHistory = async (launchPath, source, limit = 200) => { const repoRoot = (await git(launchPath, ['rev-parse', '--show-toplevel'])).trim(); const mergeRequest = parseGitLabMergeRequestUrl(source.url); - const metadata = await readMergeRequestMetadata(repoRoot, mergeRequest); - const commits = parseGlabJsonPages( - await glabApi(repoRoot, mergeRequest, [ - '--paginate', - `${mergeRequestEndpoint(mergeRequest, '/commits')}?per_page=100`, - ]), - ); + const transport = createMergeRequestTransport(repoRoot, mergeRequest); + const metadata = await readMergeRequestMetadata(repoRoot, mergeRequest, transport); + const commits = await transport.requestPages({ + path: mergeRequestEndpoint(mergeRequest, '/commits'), + query: { per_page: 100 }, + }); const baseCommits = metadata.target_branch - ? await readRepositoryCommits(repoRoot, mergeRequest, metadata.target_branch, limit) + ? await readRepositoryCommits(repoRoot, mergeRequest, metadata.target_branch, limit, transport) : []; return { entries: [ @@ -537,220 +445,27 @@ const listMergeRequestHistory = async (launchPath, source, limit = 200) => { }; }; -/** @param {string} path @param {number | undefined} oldLine @param {number | undefined} newLine */ -const getGitLabLineCode = (path, oldLine, newLine) => - `${createHash('sha1').update(path).digest('hex')}_${oldLine || 0}_${newLine || 0}`; - -/** @param {string} diff */ -const createGitLabDiffLineMap = (diff) => { - const lines = new Map(); - let oldLine = 0; - let newLine = 0; - for (const line of diff.split('\n')) { - const hunk = line.match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/); - if (hunk) { - oldLine = Number(hunk[1]); - newLine = Number(hunk[2]); - } else if (line.startsWith('+') && !line.startsWith('+++')) { - lines.set(`additions:${newLine}`, { newLine }); - newLine += 1; - } else if (line.startsWith('-') && !line.startsWith('---')) { - lines.set(`deletions:${oldLine}`, { oldLine }); - oldLine += 1; - } else if (oldLine > 0 && newLine > 0 && !line.startsWith('\\')) { - const value = { newLine, oldLine }; - lines.set(`additions:${newLine}`, value); - lines.set(`deletions:${oldLine}`, value); - oldLine += 1; - newLine += 1; - } - } - return lines; -}; - -/** @param {PullRequestReviewComment} comment @param {any} metadata @param {any} [diff] */ -const createGitLabPosition = (comment, metadata, diff) => { - const oldPath = diff?.old_path || comment.filePath; - const newPath = diff?.new_path || comment.filePath; - if (comment.anchor === 'file' || comment.lineNumber == null || comment.side == null) { - return { - base_sha: metadata.diff_refs?.base_sha, - head_sha: metadata.diff_refs?.head_sha || metadata.sha, - new_path: newPath, - old_path: oldPath, - position_type: 'file', - start_sha: metadata.diff_refs?.start_sha, - }; - } - - const lineMap = createGitLabDiffLineMap(diff?.diff || ''); - const endLines = lineMap.get(`${comment.side}:${comment.lineNumber}`) || { - ...(comment.side === 'deletions' - ? { oldLine: comment.lineNumber } - : { newLine: comment.lineNumber }), - }; - const position = { - base_sha: metadata.diff_refs?.base_sha, - head_sha: metadata.diff_refs?.head_sha || metadata.sha, - new_path: newPath, - old_path: oldPath, - position_type: 'text', - start_sha: metadata.diff_refs?.start_sha, - ...(endLines.oldLine ? { old_line: endLines.oldLine } : {}), - ...(endLines.newLine ? { new_line: endLines.newLine } : {}), - }; - if (typeof comment.startLineNumber === 'number') { - const startSide = comment.startSide ?? comment.side; - const startLines = lineMap.get(`${startSide}:${comment.startLineNumber}`) || { - ...(startSide === 'deletions' - ? { oldLine: comment.startLineNumber } - : { newLine: comment.startLineNumber }), - }; - position.line_range = { - end: { - line_code: getGitLabLineCode(newPath, endLines.oldLine, endLines.newLine), - ...(endLines.oldLine ? { old_line: endLines.oldLine } : {}), - ...(endLines.newLine ? { new_line: endLines.newLine } : {}), - type: comment.side === 'deletions' ? 'old' : 'new', - }, - start: { - line_code: getGitLabLineCode(newPath, startLines.oldLine, startLines.newLine), - ...(startLines.oldLine ? { old_line: startLines.oldLine } : {}), - ...(startLines.newLine ? { new_line: startLines.newLine } : {}), - type: startSide === 'deletions' ? 'old' : 'new', - }, - }; - } - return position; -}; - -/** @param {unknown} event */ -const getGitLabReviewQuickAction = (event) => { - if (event === 'APPROVE') { - return '/submit_review approve'; - } - if (event === 'REQUEST_CHANGES') { - return '/submit_review request_changes'; - } - throw new Error(`GitLab merge request reviews do not support ${String(event)}.`); -}; - -/** @param {string} launchPath @param {any} request */ -const submitMergeRequestComment = async (launchPath, request) => { - const repoRoot = (await git(launchPath, ['rev-parse', '--show-toplevel'])).trim(); - const mergeRequest = parseGitLabMergeRequestUrl(request.source.url); - selectMergeRequestRemote(repoRoot, mergeRequest); - if (request.comment.threadId) { - const note = JSON.parse( - await glabApi( - repoRoot, - mergeRequest, - [ - '--method', - 'POST', - '--input', - '-', - getGitLabDiscussionReplyEndpoint(mergeRequest, request.comment.threadId), - ], - { body: request.comment.body }, - ), - ); - const comment = normalizeSubmittedGitLabReviewComment(note, request.comment, mergeRequest.url); - if (!comment) { - throw new Error('GitLab accepted the reply but did not return comment metadata.'); - } - return comment; - } - const metadata = await readMergeRequestMetadata(repoRoot, mergeRequest); - const diffs = await readMergeRequestDiffs(repoRoot, mergeRequest); - const diff = diffs.find((candidate) => candidate.new_path === request.comment.filePath); - const discussion = JSON.parse( - await glabApi( - repoRoot, - mergeRequest, - ['--method', 'POST', '--input', '-', mergeRequestEndpoint(mergeRequest, '/discussions')], - { - body: request.comment.body, - position: createGitLabPosition(request.comment, metadata, diff), - }, - ), - ); - const comment = normalizeSubmittedGitLabReviewComment( - discussion.notes?.[0], - request.comment, - mergeRequest.url, - ); - if (!comment) { - throw new Error('GitLab accepted the comment but did not return comment metadata.'); - } - return comment; -}; - -/** @param {string} launchPath @param {any} request */ -const submitMergeRequestReview = async (launchPath, request) => { - const quickAction = getGitLabReviewQuickAction(request.event); - const repoRoot = (await git(launchPath, ['rev-parse', '--show-toplevel'])).trim(); - const mergeRequest = parseGitLabMergeRequestUrl(request.source.url); - selectMergeRequestRemote(repoRoot, mergeRequest); - const metadata = await readMergeRequestMetadata(repoRoot, mergeRequest); - const diffs = await readMergeRequestDiffs(repoRoot, mergeRequest); - for (const comment of request.comments) { - const diff = diffs.find((candidate) => candidate.new_path === comment.filePath); - await glabApi( - repoRoot, - mergeRequest, - ['--method', 'POST', '--input', '-', mergeRequestEndpoint(mergeRequest, '/draft_notes')], - { - note: comment.body, - position: createGitLabPosition(comment, metadata, diff), - }, - ); - } - await glabApi( - repoRoot, - mergeRequest, - ['--method', 'POST', '--input', '-', mergeRequestEndpoint(mergeRequest, '/notes')], - { - body: `${request.body ? `${request.body}\n\n` : ''}${quickAction}`, - }, - ); -}; - -/** @param {string} launchPath @param {Extract} source @param {string} requestedPath */ -const readMergeRequestImageContent = async (launchPath, source, requestedPath) => { - try { - const repoRoot = (await git(launchPath, ['rev-parse', '--show-toplevel'])).trim(); - const path = validateRepositoryPath(requestedPath); - const mergeRequest = parseGitLabMergeRequestUrl(source.url); - const metadata = await readMergeRequestMetadata(repoRoot, mergeRequest); - const diffs = await readMergeRequestDiffs(repoRoot, mergeRequest); - const rawDiff = diffs.find((candidate) => candidate.new_path === path); - if (!rawDiff) { - throw new Error('File is not part of this merge request.'); - } - const refs = await resolveMergeRequestContentRefs(repoRoot, mergeRequest, metadata); - const [oldImage, newImage] = await Promise.all([ - refs ? readGitImageFile(repoRoot, refs.base, rawDiff.old_path) : undefined, - refs ? readGitImageFile(repoRoot, refs.head, rawDiff.new_path) : undefined, - ]); - return oldImage || newImage - ? { ...(newImage ? { newImage } : {}), ...(oldImage ? { oldImage } : {}), status: 'ready' } - : { reason: 'Codiff could not load either side of this image.', status: 'unavailable' }; - } catch (error) { - return { - reason: error instanceof Error ? error.message : 'Codiff could not load this image.', - status: 'unavailable', - }; - } -}; +const { submitMergeRequestComment, submitMergeRequestReview } = createGitLabReviewMutations({ + createTransport: createMergeRequestTransport, + getDiscussionReplyEndpoint: getGitLabDiscussionReplyEndpoint, + mergeRequestEndpoint, + normalizeSubmittedGitLabReviewComment, + parseGitLabMergeRequestUrl, + readMergeRequestDiffs, + readMergeRequestMetadata, + selectMergeRequestRemote, +}); module.exports = { createGitLabPosition, createMergeRequestFetchRefspecs, + createMergeRequestSource, listMergeRequestHistory, + normalizeGitLabGeneralDiscussion, normalizeGitLabReviewComment, parseGitLabMergeRequestUrl, - readMergeRequestImageContent, + resolveGitLabCommentTarget, + readMergeRequestReviewComments, readMergeRequestState, submitMergeRequestComment, submitMergeRequestReview, diff --git a/electron/git-state/provider-artifact-sources.cjs b/electron/git-state/provider-artifact-sources.cjs new file mode 100644 index 00000000..9ceac09c --- /dev/null +++ b/electron/git-state/provider-artifact-sources.cjs @@ -0,0 +1,265 @@ +// @ts-check + +const { loadGitHubHistory } = require('../github-history-bridge.cjs'); +const { loadGitLabHistory } = require('../gitlab-history-bridge.cjs'); +const { git, gitBufferWithInput } = require('./common.cjs'); +const { createGhGitHubTransport } = require('./github-history/gh-github-transport.cjs'); +const { createGlabGitLabTransport } = require('./glab-gitlab-transport.cjs'); +const { parseReviewUrl } = require('../review-source.cjs'); + +const fileBlobReadConcurrency = 8; + +/** @param {import('../../core/types.ts').ResolvedReviewSource} source */ +const getProviderIdentity = (source) => { + if (source.type !== 'pull-request') { + return null; + } + const parsed = parseReviewUrl(source.url); + const provider = source.provider || parsed?.provider; + if (provider === 'gitlab') { + const host = source.host || (parsed?.provider === 'gitlab' ? parsed.host : undefined); + const projectPath = + source.projectPath || (parsed?.provider === 'gitlab' ? parsed.projectPath : undefined); + return host && projectPath + ? { + headSha: source.headSha, + host, + number: source.number || (parsed?.provider === 'gitlab' ? parsed.number : undefined), + projectPath, + provider: /** @type {const} */ ('gitlab'), + } + : null; + } + if (provider === 'github') { + const projectParts = source.projectPath?.split('/') || []; + const owner = + source.owner || projectParts[0] || (parsed?.provider === 'github' ? parsed.owner : undefined); + const repo = + source.repo || + projectParts.slice(1).join('/') || + (parsed?.provider === 'github' ? parsed.repo : undefined); + return owner && repo + ? { + host: source.host || 'github.com', + headSha: source.headSha, + number: source.number || (parsed?.provider === 'github' ? parsed.number : undefined), + owner, + provider: /** @type {const} */ ('github'), + repo, + } + : null; + } + return null; +}; + +/** + * One native-first commit-content adapter for every resolved review source. + * Provider-specific modules retain metadata and mutations; exact bytes use + * this common shape after the source has been normalized. + * @param {string} repoRoot + * @param {import('../../core/types.ts').ResolvedReviewSource} source + */ +const createCommitContentAdapter = (repoRoot, source) => { + const provider = getProviderIdentity(source); + if (provider?.provider === 'github') { + return { + identity: githubArtifactProject(provider), + readFileBlobs: (requests) => readGitHubFileBlobArtifacts(repoRoot, provider, requests), + }; + } + if (provider?.provider === 'gitlab') { + return { + identity: gitlabArtifactProject(provider), + readFileBlobs: (requests) => readGitLabFileBlobArtifacts(repoRoot, provider, requests), + }; + } + const identity = { host: 'local', project: repoRoot, provider: /** @type {const} */ ('git') }; + return { + identity, + readFileBlobs: (requests) => readNativeFileBlobArtifacts(repoRoot, identity, requests), + }; +}; + +/** @param {{host?: string, owner: string, repo: string}} pull */ +const githubArtifactProject = (pull) => ({ + host: pull.host || 'github.com', + project: `${pull.owner}/${pull.repo}`, + provider: /** @type {const} */ ('github'), +}); + +/** @param {{host: string, projectPath: string}} mergeRequest */ +const gitlabArtifactProject = (mergeRequest) => ({ + host: mergeRequest.host, + project: mergeRequest.projectPath, + provider: /** @type {const} */ ('gitlab'), +}); + +/** @param {{path: string, ref: string}} request */ +const fileBlobRequestKey = (request) => `${request.ref}:${request.path}`; + +/** + * Provider fallback is allowed only while the logical review still points at + * the immutable head captured by RepositoryState. Native hits stay offline. + * @param {ReturnType} transport + * @param {{headSha?: string, number?: number, owner: string, repo: string}} pull + * @param {AbortSignal} signal + */ +const assertGitHubHeadCurrent = async (transport, pull, signal) => { + if (!pull.headSha || !pull.number) return; + const metadata = await transport.request({ + path: `repos/${pull.owner}/${pull.repo}/pulls/${pull.number}`, + signal, + }); + signal.throwIfAborted(); + if (metadata?.head?.sha !== pull.headSha) { + throw new Error('The pull request head changed. Refresh before loading exact content.'); + } +}; + +/** + * @param {ReturnType} transport + * @param {{headSha?: string, number?: number, projectPath: string}} mergeRequest + * @param {AbortSignal} signal + */ +const assertGitLabHeadCurrent = async (transport, mergeRequest, signal) => { + if (!mergeRequest.headSha || !mergeRequest.number) return; + const metadata = await transport.request({ + path: `projects/${encodeURIComponent(mergeRequest.projectPath)}/merge_requests/${ + mergeRequest.number + }`, + signal, + }); + signal.throwIfAborted(); + const headSha = metadata?.diff_refs?.head_sha || metadata?.sha; + if (headSha !== mergeRequest.headSha) { + throw new Error('The merge request head changed. Refresh before loading exact content.'); + } +}; + +/** + * Resolve immutable ref+path coordinates from native Git in one bounded batch. + * @param {string} repoRoot + * @param {import('../../core/lib/review-artifacts.ts').ReviewArtifactProject} project + * @param {ReadonlyArray<{maxBytes: number, path: string, ref: string, signal?: AbortSignal}>} requests + */ +const readNativeFileBlobArtifacts = async (repoRoot, project, requests) => { + const pending = [ + ...new Map(requests.map((request) => [fileBlobRequestKey(request), request])).values(), + ]; + /** @type {Map} */ + const blobs = new Map(); + let nextIndex = 0; + const worker = async () => { + while (nextIndex < pending.length) { + const request = pending[nextIndex++]; + request.signal?.throwIfAborted(); + try { + const coordinate = `${request.ref}:${request.path}`; + const objectId = (await git(repoRoot, ['rev-parse', '--verify', coordinate])).trim(); + if (!/^[\da-f]{40,64}$/i.test(objectId)) { + continue; + } + const size = Number.parseInt( + (await git(repoRoot, ['cat-file', '-s', objectId])).trim(), + 10, + ); + if (!Number.isFinite(size) || size > request.maxBytes) { + continue; + } + const bytes = await gitBufferWithInput(repoRoot, ['show', coordinate], ''); + if (bytes.byteLength <= request.maxBytes) { + blobs.set(fileBlobRequestKey(request), { + bytes, + objectId, + provenance: { kind: 'native-git', project }, + }); + } + } catch { + request.signal?.throwIfAborted(); + } + } + }; + await Promise.all( + Array.from({ length: Math.min(fileBlobReadConcurrency, pending.length) }, worker), + ); + return blobs; +}; + +/** + * Resolve GitHub ref+path coordinates through native Git first and the + * canonical API Artifact Source second. Returned bytes carry Git object IDs. + * @param {string} repoRoot + * @param {{headSha?: string, host?: string, number?: number, owner: string, repo: string}} pull + * @param {ReadonlyArray<{maxBytes: number, path: string, ref: string, signal?: AbortSignal}>} requests + * @param {ReturnType} [transport] + */ +const readGitHubFileBlobArtifacts = async (repoRoot, pull, requests, transport) => { + const signal = requests.find((request) => request.signal)?.signal || new AbortController().signal; + const coordinates = requests.map(({ maxBytes, path, ref }) => ({ maxBytes, path, ref })); + const project = githubArtifactProject(pull); + const maxBytes = Math.max(0, ...coordinates.map((request) => request.maxBytes)); + const local = await readNativeFileBlobArtifacts( + repoRoot, + project, + coordinates.map((coordinate) => ({ ...coordinate, signal })), + ); + const missing = coordinates.filter((coordinate) => !local.has(fileBlobRequestKey(coordinate))); + if (missing.length === 0) { + return local; + } + const providerTransport = transport || createGhGitHubTransport({ repoRoot }); + await assertGitHubHeadCurrent(providerTransport, pull, signal); + const source = (await loadGitHubHistory()).createGitHubArtifactSource({ + maxBlobArtifactBytes: maxBytes, + project, + pull: { ...pull, number: pull.number || 0 }, + transport: providerTransport, + }); + const provider = (await source.readFileBlobs?.(missing, signal)) || new Map(); + return new Map([...local, ...provider]); +}; + +/** + * Resolve GitLab ref+path coordinates through native Git first and the + * canonical API Artifact Source second. Returned bytes carry Git object IDs. + * @param {string} repoRoot + * @param {{headSha?: string, host: string, number?: number, projectPath: string}} mergeRequest + * @param {ReadonlyArray<{maxBytes: number, path: string, ref: string, signal?: AbortSignal}>} requests + * @param {ReturnType} [transport] + */ +const readGitLabFileBlobArtifacts = async (repoRoot, mergeRequest, requests, transport) => { + const signal = requests.find((request) => request.signal)?.signal || new AbortController().signal; + const coordinates = requests.map(({ maxBytes, path, ref }) => ({ maxBytes, path, ref })); + const project = gitlabArtifactProject(mergeRequest); + const maxBytes = Math.max(0, ...coordinates.map((request) => request.maxBytes)); + const local = await readNativeFileBlobArtifacts( + repoRoot, + project, + coordinates.map((coordinate) => ({ ...coordinate, signal })), + ); + const missing = coordinates.filter((coordinate) => !local.has(fileBlobRequestKey(coordinate))); + if (missing.length === 0) { + return local; + } + const providerTransport = + transport || createGlabGitLabTransport({ hostname: mergeRequest.host, repoRoot }); + await assertGitLabHeadCurrent(providerTransport, mergeRequest, signal); + const source = (await loadGitLabHistory()).createGitLabArtifactSource({ + maxBlobArtifactBytes: maxBytes, + project, + projectPath: mergeRequest.projectPath, + transport: providerTransport, + }); + const provider = (await source.readFileBlobs?.(missing, signal)) || new Map(); + return new Map([...local, ...provider]); +}; + +module.exports = { + createCommitContentAdapter, + getProviderIdentity, + githubArtifactProject, + gitlabArtifactProject, + readGitHubFileBlobArtifacts, + readGitLabFileBlobArtifacts, + readNativeFileBlobArtifacts, +}; diff --git a/electron/git-state/pull-request.cjs b/electron/git-state/pull-request.cjs index 3ae66600..48fa70b5 100644 --- a/electron/git-state/pull-request.cjs +++ b/electron/git-state/pull-request.cjs @@ -1,90 +1,42 @@ // @ts-check -const { spawn } = require('node:child_process'); -const { homedir } = require('node:os'); -const { join } = require('node:path'); -const { findExecutableOnPath, isExecutableFile } = require('../agent-shared.cjs'); -const { getCommandEnvironment } = require('../login-shell-environment.cjs'); +const { git, gitOrEmpty, getCurrentCommandSignal } = require('./common.cjs'); +const { rangeArtifactToPullRequestFiles } = require('./review-range-sections.cjs'); +const { createGhGitHubTransport, runGhApi } = require('./github-history/gh-github-transport.cjs'); const { - IMAGE_FILE_LIMIT, - bufferToImageRevision, - createSummary, - formatBytes, - getFingerprint, - getImageMimeType, - git, - gitOrEmpty, - summarizeContent, - validateRepositoryPath, -} = require('./common.cjs'); -const { readGitFiles } = require('./git-files.cjs'); + PENDING_REVIEW_COMMENT_ERROR, + createGitHubReviewMutations, + normalizePullRequestComment, +} = require('./github-review-mutations.cjs'); +const { loadGitHubHistory } = require('../github-history-bridge.cjs'); const { parseReviewUrl } = require('../review-source.cjs'); /** - * @typedef {import('../../core/types.ts').ChangedFile} ChangedFile - * @typedef {import('../../core/types.ts').DiffImageContentResult} DiffImageContentResult - * @typedef {import('../../core/types.ts').PullRequestReviewComment} PullRequestReviewComment + * @typedef {import('../../core/types.ts').GitSha} GitSha + * @typedef {import('../../core/types.ts').HistoryEntry} HistoryEntry * @typedef {import('../../core/types.ts').RepositoryState} RepositoryState * @typedef {import('../../core/types.ts').ReviewSource} ReviewSource - * @typedef {import('../../core/types.ts').SubmitPullRequestCommentRequest} SubmitPullRequestCommentRequest - * @typedef {import('../../core/types.ts').SubmitPullRequestReviewRequest} SubmitPullRequestReviewRequest * @typedef {{owner: string; repo: string}} GitHubRepositoryReference * @typedef {{name: string; url: string}} LocalGitRemote * @typedef {{full_name?: string; name?: string; owner?: {login?: string}}} GitHubRepositoryMetadata * @typedef {{number: number; owner: string; repo: string; url: string}} PullRequestReference * @typedef {{direction: 'fetch' | 'push'; name: string; owner: string; repo: string}} GitHubRemote - * @typedef {{filename: string; patch?: string; previous_filename?: string; status: string}} GitHubPullRequestFile * @typedef {{base?: {ref?: string; repo?: GitHubRepositoryMetadata | null; sha?: string}; body?: string | null; head?: {ref?: string; repo?: GitHubRepositoryMetadata | null; sha?: string}; title?: string; user?: {avatar_url?: string; html_url?: string; login?: string}}} GitHubPullRequestMetadata * @typedef {{author?: {avatar_url?: string}; commit?: {author?: {date?: string; email?: string; name?: string}; message?: string}; parents?: ReadonlyArray<{sha?: string}>; sha?: string}} GitHubCommit + * @typedef {{merge_base_commit?: {sha?: string}} | null} GitHubComparison * @typedef {{[key: string]: any}} GitHubReviewComment * @typedef {{comments?: {nodes?: ReadonlyArray<{databaseId?: number | null}>} | null; isResolved?: boolean}} GitHubReviewThread */ -const GH_NOT_FOUND_CODE = 'GH_NOT_FOUND'; -const GH_NOT_FOUND_MESSAGE = - 'GitHub support requires gh. Install gh, authenticate it, and verify `gh --version` works in Terminal. Codiff searches PATH, ~/.local/bin/gh, /opt/homebrew/bin/gh, and /usr/local/bin/gh. If gh is installed somewhere else, quit Codiff, then launch it with `CODIFF_GH_PATH=/absolute/path/to/gh codiff`.'; - -/** @param {string} [detail] */ -const createGhNotFoundError = (detail) => - Object.assign(new Error(detail ? `${GH_NOT_FOUND_MESSAGE} ${detail}` : GH_NOT_FOUND_MESSAGE), { - code: GH_NOT_FOUND_CODE, - }); - /** - * A Dock or Spotlight launch gives the app a minimal PATH that omits the - * directories Homebrew and manual installs use, and later `codiff` invocations - * hand their arguments to that first process rather than replacing it, so `gh` - * has to be located the same way the other CLIs Codiff shells out to are. + * The first usable state already has the exact provider metadata and patches + * needed to hydrate a section. Keep only the latest immutable-head snapshot + * for a small number of open review roots so the first deferred file does not + * repeat those provider reads. + * @type {Map} */ -const getGhCommand = () => { - const ghPath = process.env.CODIFF_GH_PATH?.trim(); - if (ghPath) { - if (isExecutableFile(ghPath)) { - return ghPath; - } - - throw createGhNotFoundError( - `CODIFF_GH_PATH is set to ${JSON.stringify(ghPath)}, but that file is not executable.`, - ); - } - - const pathCommand = findExecutableOnPath('gh'); - if (pathCommand) { - return pathCommand; - } - - for (const path of [ - join(homedir(), '.local/bin/gh'), - '/opt/homebrew/bin/gh', - '/usr/local/bin/gh', - ]) { - if (isExecutableFile(path)) { - return path; - } - } - - throw createGhNotFoundError(); -}; +const pullRequestHydrationSnapshots = new Map(); +const MAX_PULL_REQUEST_HYDRATION_SNAPSHOTS = 8; /** @param {string} value @returns {PullRequestReference} */ const parseGitHubPullRequestUrl = (value) => { @@ -251,6 +203,11 @@ const selectPullRequestRemote = async (repoRoot, pullRequest, expectedHeadSha) = ); }; +/** @param {string} repoRoot @param {PullRequestReference} pullRequest */ +const assertPullRequestMatchesRepository = async (repoRoot, pullRequest) => { + await selectPullRequestRemote(repoRoot, pullRequest); +}; + /** @param {PullRequestReference} pullRequest @param {GitHubPullRequestMetadata} metadata */ const createPullRequestHistoryFetchRefspecs = (pullRequest, metadata) => [ `+refs/pull/${pullRequest.number}/head:refs/codiff/pull-requests/${pullRequest.number}/head`, @@ -269,165 +226,137 @@ const fetchPullRequestHistoryRefs = (repoRoot, remote, pullRequest, metadata) => ]); /** - * Runs `gh api` and reports how it exited. Callers decide which exit codes are - * failures because a missing file is a valid answer for content lookups. - * * @param {string} repoRoot * @param {ReadonlyArray} args * @param {unknown} [input] - * @returns {Promise<{code: number | null; stderr: string; stdout: Buffer}>} + * @returns {Promise} */ -const runGhApi = async (repoRoot, args, input) => { - const environment = await getCommandEnvironment(); - return new Promise((resolve, reject) => { - const child = spawn(getGhCommand(), ['api', ...args], { - cwd: repoRoot, - env: environment, - stdio: ['pipe', 'pipe', 'pipe'], - }); - /** @type {Array} */ - const stdout = []; - /** @type {Array} */ - const stderr = []; +const ghApi = (repoRoot, args, input) => runGhApi(repoRoot, args, input); - child.stdout.on('data', (chunk) => stdout.push(chunk)); - child.stderr.on('data', (chunk) => stderr.push(chunk)); - child.on('error', (error) => reject(error.code === 'ENOENT' ? createGhNotFoundError() : error)); - child.on('close', (code) => - resolve({ - code, - stderr: Buffer.concat(stderr).toString('utf8'), - stdout: Buffer.concat(stdout), - }), - ); +/** Provider transport backed by the authenticated `gh` process owned by Electron. */ +const createPullRequestTransport = (repoRoot) => createGhGitHubTransport({ repoRoot }); - child.stdin.end(input == null ? undefined : JSON.stringify(input)); +/** + * @param {string} repoRoot + * @param {PullRequestReference} pullRequest + * @param {{request: (request: any) => Promise}} [transport] + * @returns {Promise} + */ +const readPullRequestMetadata = (repoRoot, pullRequest, transport) => + (transport || createPullRequestTransport(repoRoot)).request({ + path: `repos/${pullRequest.owner}/${pullRequest.repo}/pulls/${pullRequest.number}`, }); -}; /** * @param {string} repoRoot - * @param {ReadonlyArray} args - * @param {unknown} [input] - * @returns {Promise} + * @param {PullRequestReference} pullRequest + * @param {ReturnType} transport */ -const ghApi = async (repoRoot, args, input) => { - const { code, stderr, stdout } = await runGhApi(repoRoot, args, input); - if (code === 0) { - return stdout.toString('utf8'); +const readCurrentPullRequestTarget = async (repoRoot, pullRequest, transport) => { + const metadata = await readPullRequestMetadata(repoRoot, pullRequest, transport); + const baseSha = metadata.base?.sha; + const headSha = metadata.head?.sha; + if (!baseSha || !headSha) { + throw new Error('GitHub did not return complete pull request range coordinates.'); + } + const [comparison, files] = await Promise.all([ + /** @type {Promise} */ ( + transport.request({ + path: `repos/${pullRequest.owner}/${pullRequest.repo}/compare/${encodeURIComponent(baseSha)}...${encodeURIComponent(headSha)}`, + }) + ), + transport.request({ + paginate: true, + path: `repos/${pullRequest.owner}/${pullRequest.repo}/pulls/${pullRequest.number}/files`, + query: { per_page: 100 }, + }), + ]); + const mergeBaseSha = comparison?.merge_base_commit?.sha; + if (!mergeBaseSha) { + throw new Error('GitHub did not return the current pull request merge base.'); } - - throw new Error(stderr.trim() || `gh api exited with code ${code}.`); + return { + baseSha: mergeBaseSha, + files: files.map((file) => ({ + newPath: file.filename, + ...(file.previous_filename ? { oldPath: file.previous_filename } : {}), + ...(file.patch ? { patch: file.patch } : {}), + })), + headSha, + }; }; +/** @param {string} repoRoot @param {PullRequestReference} pullRequest */ +const pullRequestHydrationSnapshotKey = (repoRoot, pullRequest) => `${repoRoot}:${pullRequest.url}`; + /** * @param {string} repoRoot - * @param {ReadonlyArray} args - * @returns {Promise} + * @param {PullRequestReference} pullRequest + * @param {{headSha?: string, metadata: GitHubPullRequestMetadata, range: import('../../core/lib/review-artifacts.ts').RangeArtifact}} snapshot */ -const ghApiBuffer = async (repoRoot, args) => { - const { code, stderr, stdout } = await runGhApi(repoRoot, args); - if (code === 0) { - return stdout; +const rememberPullRequestHydrationSnapshot = (repoRoot, pullRequest, snapshot) => { + const key = pullRequestHydrationSnapshotKey(repoRoot, pullRequest); + pullRequestHydrationSnapshots.delete(key); + pullRequestHydrationSnapshots.set(key, snapshot); + while (pullRequestHydrationSnapshots.size > MAX_PULL_REQUEST_HYDRATION_SNAPSHOTS) { + pullRequestHydrationSnapshots.delete(pullRequestHydrationSnapshots.keys().next().value); } - - if (code === 1 && /not found|404/i.test(stderr)) { - return undefined; - } - - throw new Error(stderr.trim() || `gh api exited with code ${code}.`); -}; - -/** @param {string} repoRoot @param {PullRequestReference} pullRequest @returns {Promise} */ -const readPullRequestMetadata = async (repoRoot, pullRequest) => - JSON.parse( - await ghApi(repoRoot, [ - `repos/${pullRequest.owner}/${pullRequest.repo}/pulls/${pullRequest.number}`, - ]), - ); - -/** @param {string} repoRoot @param {PullRequestReference} pullRequest @returns {Promise>} */ -const readPullRequestFiles = async (repoRoot, pullRequest) => { - const pages = JSON.parse( - await ghApi(repoRoot, [ - '--paginate', - '--slurp', - `repos/${pullRequest.owner}/${pullRequest.repo}/pulls/${pullRequest.number}/files?per_page=100`, - ]), - ); - return pages.flat(); -}; - -/** @param {string} path */ -const encodeGitHubContentPath = (path) => path.split('/').map(encodeURIComponent).join('/'); - -/** @param {GitHubRepositoryMetadata | null | undefined} repository */ -const normalizeGitHubRepositoryReference = (repository) => { - const owner = repository?.owner?.login; - const repo = repository?.name; - if (owner && repo) { - return { owner, repo }; - } - - const [fullNameOwner, fullNameRepo] = repository?.full_name?.split('/') ?? []; - return fullNameOwner && fullNameRepo - ? { - owner: fullNameOwner, - repo: fullNameRepo, - } - : null; -}; - -/** @param {PullRequestReference} pullRequest @param {GitHubPullRequestMetadata} metadata */ -const getPullRequestHeadImageSource = (pullRequest, metadata) => { - const repository = normalizeGitHubRepositoryReference(metadata.head?.repo); - return { - owner: repository?.owner ?? pullRequest.owner, - ref: repository - ? (metadata.head?.sha ?? metadata.head?.ref ?? 'HEAD') - : `refs/pull/${pullRequest.number}/head`, - repo: repository?.repo ?? pullRequest.repo, - }; }; /** + * Read the provider data required to create or hydrate the current PR range. + * A deferred section accepts an in-process snapshot only when its immutable + * head is exactly the source's head; a fresh state read always refreshes it. + * * @param {string} repoRoot - * @param {GitHubRepositoryReference} repository - * @param {string} ref - * @param {string} path + * @param {PullRequestReference} pullRequest + * @param {{expectedHeadSha?: string, forceRefresh?: boolean, signal?: AbortSignal}} [options] */ -const readGitHubImageFile = async (repoRoot, repository, ref, path) => { - if (!getImageMimeType(path)) { - throw new Error('Unsupported image file type.'); +const readPullRequestHydrationSnapshot = async (repoRoot, pullRequest, options = {}) => { + const key = pullRequestHydrationSnapshotKey(repoRoot, pullRequest); + const cached = pullRequestHydrationSnapshots.get(key); + if ( + !options.forceRefresh && + options.expectedHeadSha && + cached?.headSha === options.expectedHeadSha + ) { + return cached; } - - const buffer = await ghApiBuffer(repoRoot, [ - '-H', - 'Accept: application/vnd.github.raw', - `repos/${repository.owner}/${repository.repo}/contents/${encodeGitHubContentPath( - path, - )}?ref=${encodeURIComponent(ref)}`, + const transport = createPullRequestTransport(repoRoot); + const [github, metadata] = await Promise.all([ + loadGitHubHistory(), + readPullRequestMetadata(repoRoot, pullRequest, transport), ]); - - if (!buffer) { - return undefined; + const baseSha = metadata.base?.sha; + const headSha = metadata.head?.sha; + if (!baseSha || !headSha) { + throw new Error('GitHub did not return complete pull request range coordinates.'); } - - if (buffer.length > IMAGE_FILE_LIMIT) { - throw new Error(`Image is ${formatBytes(buffer.length)}, so Codiff skipped rendering it.`); + if (options.expectedHeadSha && headSha !== options.expectedHeadSha) { + throw new Error('The pull request head changed. Refresh before loading exact file contents.'); } - - return bufferToImageRevision(path, buffer); + const project = { + host: 'github.com', + project: `${pullRequest.owner}/${pullRequest.repo}`, + provider: /** @type {'github'} */ ('github'), + }; + const artifactSource = github.createGitHubArtifactSource({ + project, + pull: pullRequest, + transport, + }); + const { range } = await artifactSource.readStackAndRange( + { + requestedBaseSha: /** @type {GitSha} */ (baseSha), + headSha: /** @type {GitSha} */ (headSha), + }, + options.signal ?? getCurrentCommandSignal() ?? new AbortController().signal, + ); + const snapshot = { headSha, metadata, range }; + rememberPullRequestHydrationSnapshot(repoRoot, pullRequest, snapshot); + return snapshot; }; -/** @param {string} repoRoot @param {PullRequestReference} pullRequest */ -const readPullRequestDiff = async (repoRoot, pullRequest) => - ghApi(repoRoot, [ - '-H', - 'Accept: application/vnd.github.v3.diff', - `repos/${pullRequest.owner}/${pullRequest.repo}/pulls/${pullRequest.number}`, - ]); - /** @param {unknown} side */ const fromGitHubReviewSide = (side) => (side === 'LEFT' ? 'deletions' : 'additions'); /** @param {unknown} side */ @@ -436,17 +365,59 @@ const isGitHubReviewSide = (side) => side === 'LEFT' || side === 'RIGHT'; /** @param {...unknown} values */ const firstNumber = (...values) => values.find((value) => typeof value === 'number'); -/** @param {GitHubReviewComment} comment */ -const normalizeGitHubReviewComment = (comment) => { - const lineNumber = firstNumber(comment.line, comment.original_line); - if (lineNumber == null || !comment.path || !comment.body) { +/** @param {string} sha */ +const createReviewCommitRevision = (sha) => ({ + label: { kind: /** @type {const} */ ('commit'), text: sha.slice(0, 7) }, + sha: /** @type {GitSha} */ (sha), +}); + +/** + * @param {GitHubReviewComment} comment + * @param {GitHubReviewComment} [rootComment] + * @param {string} [baseSha] + */ +const normalizeGitHubReviewComment = (comment, rootComment = comment, baseSha) => { + const lineNumber = firstNumber(rootComment.line, rootComment.original_line); + if (!rootComment.path || !comment.body) { + return null; + } + const reviewCommitSha = rootComment.original_commit_id || rootComment.commit_id; + const position = + baseSha && reviewCommitSha + ? { + range: { + base: createReviewCommitRevision(baseSha), + head: createReviewCommitRevision(reviewCommitSha), + }, + } + : undefined; + if (lineNumber == null && rootComment.subject_type === 'file') { + return { + anchor: 'file', + author: { + avatarUrl: comment.user?.avatar_url, + login: comment.user?.login || 'GitHub user', + url: comment.user?.html_url, + }, + body: comment.body, + filePath: rootComment.path, + id: `github:${comment.id}`, + ...(position ? { position } : {}), + threadId: String( + rootComment === comment ? comment.in_reply_to_id || comment.id : rootComment.id, + ), + submittedAt: comment.created_at, + url: comment.html_url, + }; + } + if (lineNumber == null) { return null; } - const side = fromGitHubReviewSide(comment.side); - const startLineNumber = firstNumber(comment.start_line, comment.original_start_line); - const startSide = isGitHubReviewSide(comment.start_side) - ? fromGitHubReviewSide(comment.start_side) + const side = fromGitHubReviewSide(rootComment.side); + const startLineNumber = firstNumber(rootComment.start_line, rootComment.original_start_line); + const startSide = isGitHubReviewSide(rootComment.start_side) + ? fromGitHubReviewSide(rootComment.start_side) : undefined; const hasRange = startLineNumber != null && (startLineNumber !== lineNumber || (startSide ?? side) !== side); @@ -458,13 +429,17 @@ const normalizeGitHubReviewComment = (comment) => { url: comment.user?.html_url, }, body: comment.body, - filePath: comment.path, + filePath: rootComment.path, id: `github:${comment.id}`, - ...(typeof comment.line !== 'number' ? { isOutdated: true } : {}), + ...(typeof rootComment.line !== 'number' ? { isOutdated: true } : {}), lineNumber, + ...(position ? { position } : {}), side, ...(hasRange ? { startLineNumber } : {}), ...(hasRange && startSide != null && startSide !== side ? { startSide } : {}), + threadId: String( + rootComment === comment ? comment.in_reply_to_id || comment.id : rootComment.id, + ), submittedAt: comment.created_at, url: comment.html_url, }; @@ -487,12 +462,56 @@ const collectResolvedReviewCommentIds = (threads) => { return ids; }; -/** @param {ReadonlyArray} comments @param {ReadonlySet} resolvedCommentIds */ -const selectUnresolvedReviewComments = (comments, resolvedCommentIds) => - comments +/** + * @param {ReadonlyArray} comments + * @param {ReadonlySet} resolvedCommentIds + * @param {string} [baseSha] + */ +const selectUnresolvedReviewComments = (comments, resolvedCommentIds, baseSha) => { + const byId = new Map(comments.map((comment) => [comment.id, comment])); + return comments .filter((comment) => !resolvedCommentIds.has(comment.id)) - .map(normalizeGitHubReviewComment) + .map((comment) => { + const root = byId.get(comment.in_reply_to_id) || comment; + return normalizeGitHubReviewComment(comment, root, baseSha); + }) .filter(Boolean); +}; + +/** @param {GitHubReviewComment} comment @param {'issue' | 'review'} kind */ +const normalizeGitHubGeneralComment = (comment, kind) => { + if (!comment?.body || typeof comment.id !== 'number') { + return null; + } + return { + author: { + avatarUrl: comment.user?.avatar_url, + login: comment.user?.login || 'GitHub user', + url: comment.user?.html_url, + }, + body: comment.body, + id: `github:${kind}:${comment.id}`, + submittedAt: comment.submitted_at || comment.created_at, + url: comment.html_url, + }; +}; + +/** + * @param {ReadonlyArray} issueComments + * @param {ReadonlyArray} reviews + */ +const selectGitHubGeneralCommentThreads = (issueComments, reviews) => + [ + ...issueComments.map((comment) => normalizeGitHubGeneralComment(comment, 'issue')), + ...reviews + .filter((review) => review.state !== 'PENDING') + .map((review) => normalizeGitHubGeneralComment(review, 'review')), + ] + .filter(Boolean) + .sort((left, right) => + String(left.submittedAt || '').localeCompare(String(right.submittedAt || '')), + ) + .map((comment) => ({ comments: [comment], id: comment.id })); const RESOLVED_REVIEW_THREADS_QUERY = `query($owner: String!, $repo: String!, $number: Int!, $cursor: String) { repository(owner: $owner, name: $repo) { @@ -555,7 +574,7 @@ const readResolvedReviewCommentIds = async (repoRoot, pullRequest) => { }; /** @param {string} repoRoot @param {PullRequestReference} pullRequest */ -const readPullRequestComments = async (repoRoot, pullRequest) => { +const readPullRequestComments = async (repoRoot, pullRequest, baseSha) => { const [pages, resolvedCommentIds] = await Promise.all([ ghApi(repoRoot, [ '--paginate', @@ -564,7 +583,24 @@ const readPullRequestComments = async (repoRoot, pullRequest) => { ]).then((output) => JSON.parse(output)), readResolvedReviewCommentIds(repoRoot, pullRequest), ]); - return selectUnresolvedReviewComments(pages.flat(), resolvedCommentIds); + return selectUnresolvedReviewComments(pages.flat(), resolvedCommentIds, baseSha); +}; + +/** @param {string} repoRoot @param {PullRequestReference} pullRequest */ +const readPullRequestGeneralComments = async (repoRoot, pullRequest) => { + const [issuePages, reviewPages] = await Promise.all([ + ghApi(repoRoot, [ + '--paginate', + '--slurp', + `repos/${pullRequest.owner}/${pullRequest.repo}/issues/${pullRequest.number}/comments?per_page=100`, + ]).then((output) => JSON.parse(output)), + ghApi(repoRoot, [ + '--paginate', + '--slurp', + `repos/${pullRequest.owner}/${pullRequest.repo}/pulls/${pullRequest.number}/reviews?per_page=100`, + ]).then((output) => JSON.parse(output)), + ]); + return selectGitHubGeneralCommentThreads(issuePages.flat(), reviewPages.flat()); }; /** @param {string} repoRoot @param {PullRequestReference} pullRequest @returns {Promise>} */ @@ -599,12 +635,12 @@ const readRepositoryCommits = async (repoRoot, pullRequest, sha, limit) => { return commits; }; -/** @param {GitHubCommit} commit @param {'base' | 'pull-request'} [scope] */ +/** @param {GitHubCommit} commit @param {'base' | 'pull-request'} [scope] @returns {HistoryEntry | null} */ const normalizeGitHubCommit = (commit, scope) => { - const ref = commit.sha; + const sha = commit.sha; const committedAt = Date.parse(commit.commit?.author?.date || ''); const message = commit.commit?.message || ''; - if (!ref || !message || !Number.isFinite(committedAt)) { + if (!sha || !message || !Number.isFinite(committedAt)) { return null; } @@ -612,8 +648,11 @@ const normalizeGitHubCommit = (commit, scope) => { author: commit.commit?.author?.name || '', committedAt, gravatarUrl: commit.author?.avatar_url, - parents: commit.parents?.map((parent) => parent.sha).filter(Boolean) || [], - ref, + parentShas: + commit.parents?.flatMap((parent) => + parent.sha ? [/** @type {GitSha} */ (parent.sha)] : [], + ) || [], + sha: /** @type {GitSha} */ (sha), ...(scope ? { scope } : {}), subject: message.split('\n')[0], }; @@ -637,63 +676,18 @@ const listPullRequestHistory = async (launchPath, source, limit = 200) => { : []; return { entries: [ - ...commits.map(normalizeGitHubPullRequestCommit).filter(Boolean).reverse(), - ...baseCommits.map((commit) => normalizeGitHubCommit(commit, 'base')).filter(Boolean), + ...commits + .map(normalizeGitHubPullRequestCommit) + .filter((entry) => entry != null) + .reverse(), + ...baseCommits + .map((commit) => normalizeGitHubCommit(commit, 'base')) + .filter((entry) => entry != null), ], root: repoRoot, }; }; -/** @param {string} diff @returns {Map} */ -const splitPullRequestDiff = (diff) => { - const chunks = diff - .split(/(?=^diff --git )/m) - .map((chunk) => chunk.trimEnd()) - .filter((chunk) => chunk.startsWith('diff --git ')); - const map = new Map(); - - for (const chunk of chunks) { - const newPath = chunk.match(/^\+\+\+\s+b\/(.+)$/m)?.[1]; - const oldPath = chunk.match(/^---\s+a\/(.+)$/m)?.[1]; - const renamePath = chunk.match(/^rename to (.+)$/m)?.[1]; - const path = newPath && newPath !== '/dev/null' ? newPath : renamePath || oldPath; - if (path) { - map.set(path, `${chunk}\n`); - } - } - - return map; -}; - -/** @param {string} path */ -const quotePatchPath = (path) => path.replace(/\\/g, '\\\\').replace(/\n/g, '\\n'); - -/** @param {GitHubPullRequestFile} file */ -const createPatchFromPullRequestFile = (file) => { - if (!file.patch) { - return ''; - } - - const oldPath = file.previous_filename || file.filename; - const header = [ - `diff --git a/${quotePatchPath(oldPath)} b/${quotePatchPath(file.filename)}`, - file.status === 'added' ? '--- /dev/null' : `--- a/${quotePatchPath(oldPath)}`, - file.status === 'removed' ? '+++ /dev/null' : `+++ b/${quotePatchPath(file.filename)}`, - ]; - - return `${header.join('\n')}\n${file.patch}\n`; -}; - -/** @param {string} status @returns {GitFileStatus} */ -const normalizePullRequestFileStatus = (status) => - status === 'added' - ? 'added' - : status === 'removed' - ? 'deleted' - : status === 'renamed' - ? 'renamed' - : 'modified'; - /** @param {PullRequestReference} pullRequest @param {GitHubPullRequestMetadata} metadata @returns {Extract} */ const createPullRequestSource = (pullRequest, metadata) => ({ ...(metadata.user?.login @@ -713,413 +707,71 @@ const createPullRequestSource = (pullRequest, metadata) => ({ projectPath: `${pullRequest.owner}/${pullRequest.repo}`, provider: 'github', repo: pullRequest.repo, + ...(metadata.base?.ref ? { targetBranch: metadata.base.ref } : {}), title: metadata.title, type: 'pull-request', url: pullRequest.url, }); -/** - * Make sure the pull request head and base branch are available as local refs - * and resolve the two commits to diff against. GitHub computes the pull request - * diff against the merge base of the base branch and the head, so mirror that to - * keep line numbers and changes aligned with the GitHub review. - * - * Returns `null` when the full file contents cannot be resolved, in which case - * callers fall back to the GitHub-provided patch (which cannot expand - * unmodified context). - * - * @param {string} repoRoot - * @param {PullRequestReference} pullRequest - * @param {GitHubPullRequestMetadata} metadata - * @param {GitHubRemote} [selectedRemote] - * @returns {Promise<{base: string; head: string} | null>} - */ -const resolvePullRequestContentRefs = async (repoRoot, pullRequest, metadata, selectedRemote) => { - if (!metadata.base?.ref) { - return null; - } - - const headRef = `refs/codiff/pull-requests/${pullRequest.number}/head`; - const baseRef = `refs/codiff/pull-requests/${pullRequest.number}/base`; - const headSha = metadata.head?.sha; - const baseSha = metadata.base?.sha; - const localHead = ( - await gitOrEmpty(repoRoot, ['rev-parse', '--verify', '--quiet', headRef]) - ).trim(); - const localBase = ( - await gitOrEmpty(repoRoot, ['rev-parse', '--verify', '--quiet', baseRef]) - ).trim(); - - // Refetch when a ref is missing or has moved -- including when the base branch - // advanced or the pull request was retargeted (localBase !== base sha) -- so - // the merge base is always resolved against the current base and head rather - // than stale contents. - if ( - localBase === '' || - localHead === '' || - (headSha != null && localHead !== headSha) || - (baseSha != null && localBase !== baseSha) - ) { - try { - const remote = - selectedRemote ?? - (await selectPullRequestRemote(repoRoot, pullRequest, metadata.head?.sha)); - await fetchPullRequestHistoryRefs(repoRoot, remote, pullRequest, metadata); - } catch { - return null; - } - } - - const mergeBase = (await gitOrEmpty(repoRoot, ['merge-base', baseRef, headRef])).trim(); - return mergeBase ? { base: mergeBase, head: headRef } : null; -}; - -/** - * git/GitHub emit `Binary files a/x and b/x differ` as a diff metadata line. - * Anchor to the start of a line so the same text appearing inside a patch's - * added/removed/context lines (which are prefixed with `+`/`-`/space) does not - * misclassify a text file as binary. - */ -const BINARY_DIFF_MARKER = /^Binary files .* differ/m; - -/** - * Build a diff section for a pull request file. When the full base and head - * contents are available the section carries `oldFile`/`newFile` so Codiff - * renders a recomputed diff with expandable unmodified context (matching commits - * and the working tree). Otherwise it falls back to the GitHub patch. - * - * @param {PullRequestReference} pullRequest - * @param {GitHubPullRequestFile} file - * @param {string} patch - * @param {import('./common.cjs').FileContentResult} [oldFile] - * @param {import('./common.cjs').FileContentResult} [newFile] - * @returns {import('../../core/types.ts').DiffSection} - */ -const createPullRequestSection = (pullRequest, file, patch, oldFile, newFile) => { - const id = `${file.filename}:pull-request:${pullRequest.number}`; - const patchBinary = !patch || BINARY_DIFF_MARKER.test(patch); - // Expandable context can only be rendered when both sides' contents are present. - const attemptedContent = oldFile != null && newFile != null; - - if (attemptedContent && !patchBinary) { - const summary = summarizeContent(oldFile, newFile); - const status = normalizePullRequestFileStatus(file.status); - const oldContents = oldFile.file?.contents ?? ''; - const newContents = newFile.file?.contents ?? ''; - // A modification that reads empty on both sides means the content failed to - // load; keep the patch instead of rendering it as an empty (no-op) diff. - const contentMissing = - (status === 'modified' || status === 'renamed') && oldContents === '' && newContents === ''; - - if (!summary.binary && summary.loadState === 'ready' && !contentMissing) { - return { - binary: false, - id, - kind: 'pull-request', - loadState: 'ready', - newFile: newFile.file, - oldFile: oldFile.file, - patch, - }; - } - } - - // Pull request contents are loaded up front, so a file that falls back to its - // patch (binary, oversized, or refs unavailable) cannot be loaded on demand. - return { - binary: patchBinary, - id, - kind: 'pull-request', - loadState: patchBinary ? 'binary' : 'ready', - patch, - summary: createSummary( - patchBinary ? 'Binary file changed.' : 'Showing the pull request patch for this file.', - { canLoad: false }, - ), - }; -}; - /** @param {string} launchPath @param {Extract} source @returns {Promise} */ const readPullRequestState = async (launchPath, source) => { const repoRoot = (await git(launchPath, ['rev-parse', '--show-toplevel'])).trim(); const pullRequest = parseGitHubPullRequestUrl(source.url); - - const [metadata, apiFiles, diff, reviewComments] = await Promise.all([ - readPullRequestMetadata(repoRoot, pullRequest), - readPullRequestFiles(repoRoot, pullRequest), - readPullRequestDiff(repoRoot, pullRequest), - readPullRequestComments(repoRoot, pullRequest), - ]); - const remote = await selectPullRequestRemote(repoRoot, pullRequest, metadata.head?.sha); - const diffByPath = splitPullRequestDiff(diff); - // Load every file's base and head contents up front from the local refs, so - // each diff renders in its final collapsed layout immediately and never shifts - // as expandable context becomes available. Files larger than the eager limit - // stay patch-only. - const contentRefs = await resolvePullRequestContentRefs( - repoRoot, - pullRequest, - metadata, - remote, - ).catch(() => null); - const reviewFiles = [...apiFiles].map((file) => { - const patch = diffByPath.get(file.filename) || createPatchFromPullRequestFile(file); - return { - file, - oldPath: file.previous_filename || file.filename, - patch, - }; + await assertPullRequestMatchesRepository(repoRoot, pullRequest); + const { metadata, range } = await readPullRequestHydrationSnapshot(repoRoot, pullRequest, { + forceRefresh: true, }); - const contentFiles = reviewFiles.filter(({ patch }) => !BINARY_DIFF_MARKER.test(patch)); - const [oldFiles, newFiles] = contentRefs - ? await Promise.all([ - readGitFiles( - repoRoot, - contentRefs.base, - contentFiles.map(({ oldPath }) => oldPath), - { refScopedEmptyCacheKey: true }, - ), - readGitFiles( - repoRoot, - contentRefs.head, - contentFiles.map(({ file }) => file.filename), - { refScopedEmptyCacheKey: true }, - ), - ]) - : [new Map(), new Map()]; - - /** @type {Array} */ - const files = reviewFiles - .map(({ file, oldPath, patch }) => { - const oldFile = contentRefs && !BINARY_DIFF_MARKER.test(patch) ? oldFiles.get(oldPath) : null; - const newFile = - contentRefs && !BINARY_DIFF_MARKER.test(patch) ? newFiles.get(file.filename) : null; - const section = createPullRequestSection(pullRequest, file, patch, oldFile, newFile); - - return { - fingerprint: getFingerprint( - [ - metadata.head?.sha || '', - file.status, - file.previous_filename || '', - file.filename, - section.loadState || 'ready', - section.oldFile?.cacheKey || '', - section.newFile?.cacheKey || '', - patch, - ].join('\n'), - ), - oldPath: file.previous_filename, - path: file.filename, - sections: [section], - status: normalizePullRequestFileStatus(file.status), - }; - }) - .sort((left, right) => left.path.localeCompare(right.path)); - + const files = rangeArtifactToPullRequestFiles(range, pullRequest.number, { + deferContents: true, + }).toSorted((left, right) => left.path.localeCompare(right.path)); return { files, generatedAt: Date.now(), launchPath, - reviewComments, + reviewCommentsLoadState: 'not-loaded', root: repoRoot, source: createPullRequestSource(pullRequest, metadata), }; }; -/** - * @param {string} launchPath - * @param {Extract} source - * @param {string} requestedPath - * @returns {Promise} - */ -const readPullRequestImageContent = async (launchPath, source, requestedPath) => { - try { - const repoRoot = (await git(launchPath, ['rev-parse', '--show-toplevel'])).trim(); - const path = validateRepositoryPath(requestedPath); - const pullRequest = parseGitHubPullRequestUrl(source.url); - - const [metadata, files] = await Promise.all([ - readPullRequestMetadata(repoRoot, pullRequest), - readPullRequestFiles(repoRoot, pullRequest), - ]); - await selectPullRequestRemote(repoRoot, pullRequest, metadata.head?.sha); - const file = files.find((candidate) => candidate.filename === path); - if (!file) { - throw new Error('File is not part of this pull request.'); - } - - const headImageSource = getPullRequestHeadImageSource(pullRequest, metadata); - const [oldImage, newImage] = await Promise.all([ - metadata.base?.sha - ? readGitHubImageFile( - repoRoot, - pullRequest, - metadata.base.sha, - file.previous_filename || file.filename, - ) - : undefined, - readGitHubImageFile(repoRoot, headImageSource, headImageSource.ref, file.filename), - ]); - - if (!oldImage && !newImage) { - return { - reason: 'Codiff could not load either side of this image.', - status: 'unavailable', - }; - } - - return { - ...(newImage ? { newImage } : {}), - ...(oldImage ? { oldImage } : {}), - status: 'ready', - }; - } catch (error) { - return { - reason: error instanceof Error ? error.message : 'Codiff could not load this image.', - status: 'unavailable', - }; - } -}; - -/** @param {PullRequestReviewComment['side']} side */ -const toGitHubReviewSide = (side) => (side === 'deletions' ? 'LEFT' : 'RIGHT'); - -/** @param {PullRequestReviewComment} comment */ -const normalizePullRequestComment = (comment) => { - /** @type {{body: string; line: number; path: string; side: string; start_line?: number; start_side?: string}} */ - const payload = { - body: comment.body, - line: comment.lineNumber, - path: comment.filePath, - side: toGitHubReviewSide(comment.side), - }; - const startSide = comment.startSide ?? comment.side; - if ( - typeof comment.startLineNumber === 'number' && - comment.startLineNumber !== comment.lineNumber - ) { - payload.start_line = comment.startLineNumber; - payload.start_side = toGitHubReviewSide(startSide); - } - return payload; -}; - -const PENDING_REVIEW_COMMENT_ERROR = - 'You already have a pending GitHub review on this pull request. Submit or discard it on GitHub, then retry. Your comment draft is still here.'; - -/** @param {unknown} error */ -const isGitHubValidationError = (error) => - error instanceof Error && /(?:validation failed|http 422)/i.test(error.message); - -/** @param {string} repoRoot @param {PullRequestReference} pullRequest */ -const hasPendingPullRequestReview = async (repoRoot, pullRequest) => { - const pages = JSON.parse( - await ghApi(repoRoot, [ - '--paginate', - '--slurp', - `repos/${pullRequest.owner}/${pullRequest.repo}/pulls/${pullRequest.number}/reviews?per_page=100`, - ]), - ); - return Array.isArray(pages) && pages.flat().some((review) => review?.state === 'PENDING'); -}; - -/** @param {string} launchPath @param {SubmitPullRequestCommentRequest} request */ -const submitPullRequestComment = async (launchPath, request) => { +/** @param {string} launchPath @param {Extract} source */ +const readPullRequestReviewComments = async (launchPath, source) => { const repoRoot = (await git(launchPath, ['rev-parse', '--show-toplevel'])).trim(); - const pullRequest = parseGitHubPullRequestUrl(request.source.url); - const metadata = await readPullRequestMetadata(repoRoot, pullRequest); - await selectPullRequestRemote(repoRoot, pullRequest, metadata.head?.sha); - const payload = { - ...normalizePullRequestComment(request.comment), - commit_id: metadata.head?.sha, - }; - - const rawComment = await ghApi( - repoRoot, - [ - '-X', - 'POST', - `repos/${pullRequest.owner}/${pullRequest.repo}/pulls/${pullRequest.number}/comments`, - '--input', - '-', - ], - payload, - ).catch(async (error) => { - if (isGitHubValidationError(error)) { - const hasPendingReview = await hasPendingPullRequestReview(repoRoot, pullRequest).catch( - () => false, - ); - if (hasPendingReview) { - throw new Error(PENDING_REVIEW_COMMENT_ERROR); - } - } - throw error; + const pullRequest = parseGitHubPullRequestUrl(source.url); + await assertPullRequestMatchesRepository(repoRoot, pullRequest); + const { range } = await readPullRequestHydrationSnapshot(repoRoot, pullRequest, { + expectedHeadSha: source.headSha, }); - const comment = normalizeGitHubReviewComment(JSON.parse(rawComment)); - if (!comment) { - throw new Error('GitHub accepted the comment but did not return line metadata.'); - } - return comment; -}; - -/** @param {string} launchPath @param {SubmitPullRequestReviewRequest} request */ -const submitPullRequestReview = async (launchPath, request) => { - const repoRoot = (await git(launchPath, ['rev-parse', '--show-toplevel'])).trim(); - const pullRequest = parseGitHubPullRequestUrl(request.source.url); - const metadata = await readPullRequestMetadata(repoRoot, pullRequest); - await selectPullRequestRemote(repoRoot, pullRequest, metadata.head?.sha); - - await ghApi( - repoRoot, - [ - '-X', - 'POST', - `repos/${pullRequest.owner}/${pullRequest.repo}/pulls/${pullRequest.number}/reviews`, - '--input', - '-', - ], - createPullRequestReviewPayload(request), - ); + const [reviewComments, generalComments] = await Promise.all([ + readPullRequestComments(repoRoot, pullRequest, range.baseSha), + readPullRequestGeneralComments(repoRoot, pullRequest), + ]); + return { generalComments, reviewComments }; }; -/** @param {SubmitPullRequestReviewRequest} request */ -const createPullRequestReviewPayload = (request) => { - const body = request.body?.trim() || ''; - if (request.event === 'COMMENT' && request.comments.length === 0 && !body) { - throw new Error('A comment review requires an inline comment or a review comment.'); - } - - return { - body: - body || - (request.event === 'REQUEST_CHANGES' && request.comments.length === 0 - ? 'Requesting changes.' - : ''), - comments: request.comments.map(normalizePullRequestComment), - event: request.event, - }; -}; +const { submitPullRequestComment, submitPullRequestReview } = createGitHubReviewMutations({ + assertPullRequestMatchesRepository, + createTransport: createPullRequestTransport, + normalizeGitHubReviewComment, + parseGitHubPullRequestUrl, + readCurrentTarget: readCurrentPullRequestTarget, +}); module.exports = { - GH_NOT_FOUND_CODE, PENDING_REVIEW_COMMENT_ERROR, collectResolvedReviewCommentIds, - createPatchFromPullRequestFile, createPullRequestHistoryFetchRefspecs, - createPullRequestSection, createPullRequestSource, - getGhCommand, - getPullRequestHeadImageSource, listPullRequestHistory, + normalizeGitHubGeneralComment, normalizeGitHubCommit, normalizeGitHubPullRequestCommit, normalizeGitHubReviewComment, normalizePullRequestComment, parseGitHubPullRequestUrl, - readPullRequestImageContent, + readPullRequestReviewComments, readPullRequestState, - resolvePullRequestContentRefs, + selectGitHubGeneralCommentThreads, selectPullRequestRemote, selectUnresolvedReviewComments, submitPullRequestComment, diff --git a/electron/git-state/review-comment-target.cjs b/electron/git-state/review-comment-target.cjs new file mode 100644 index 00000000..c2411337 --- /dev/null +++ b/electron/git-state/review-comment-target.cjs @@ -0,0 +1,108 @@ +// @ts-check + +/** + * @typedef {import('../../core/types.ts').PullRequestReviewComment} PullRequestReviewComment + * @typedef {{newPath: string, oldPath?: string, patch?: string}} ReviewTargetFile + * @typedef {{baseSha?: string, files: ReadonlyArray, headSha?: string}} CurrentReviewTarget + */ + +/** @param {string} reason */ +const targetResolutionError = (reason) => + Object.assign(new Error(`target-resolution-failed: ${reason}`), { + code: 'target-resolution-failed', + }); + +/** @param {any} revision */ +const rangeCommitSha = (revision) => + revision && + (revision.kind == null || revision.kind === 'commit') && + typeof revision.sha === 'string' + ? revision.sha + : null; + +/** @param {string} patch */ +const createDiffLineMap = (patch) => { + const lines = new Set(); + let oldLine = 0; + let newLine = 0; + for (const line of patch.split('\n')) { + const hunk = line.match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/); + if (hunk) { + oldLine = Number(hunk[1]); + newLine = Number(hunk[2]); + } else if (line.startsWith('+') && !line.startsWith('+++')) { + lines.add(`additions:${newLine}`); + newLine += 1; + } else if (line.startsWith('-') && !line.startsWith('---')) { + lines.add(`deletions:${oldLine}`); + oldLine += 1; + } else if (oldLine > 0 && newLine > 0 && line.startsWith(' ')) { + lines.add(`additions:${newLine}`); + lines.add(`deletions:${oldLine}`); + oldLine += 1; + newLine += 1; + } + } + return lines; +}; + +/** @param {ReviewTargetFile} file @param {string} filePath */ +const targetFileMatches = (file, filePath) => + file.newPath === filePath || file.oldPath === filePath; + +/** + * Validate every immutable coordinate before a provider mutation is attempted. + * Replies target an existing provider thread and are validated by that thread + * endpoint instead of by a new diff position. + * + * @param {PullRequestReviewComment} comment + * @param {CurrentReviewTarget} target + * @returns {ReviewTargetFile | undefined} + */ +const validateCurrentReviewCommentTarget = (comment, target) => { + if (comment.threadId) { + return undefined; + } + const range = comment.position?.range; + const baseSha = rangeCommitSha(range?.base); + const headSha = rangeCommitSha(range?.head); + if (!baseSha || !headSha) { + throw targetResolutionError('The draft has no immutable review range.'); + } + if (!target.baseSha || !target.headSha) { + throw targetResolutionError('The provider did not return a complete current review range.'); + } + if (baseSha !== target.baseSha || headSha !== target.headSha) { + throw targetResolutionError( + 'The draft range no longer matches the current review. Refresh before submitting.', + ); + } + const file = target.files.find((candidate) => targetFileMatches(candidate, comment.filePath)); + if (!file) { + throw targetResolutionError(`File ${comment.filePath} is not in the target diff.`); + } + if (comment.anchor === 'file' || comment.lineNumber == null || comment.side == null) { + return file; + } + const lineMap = createDiffLineMap(file.patch || ''); + if (!lineMap.has(`${comment.side}:${comment.lineNumber}`)) { + throw targetResolutionError( + `Line ${comment.lineNumber} on the ${comment.side} side is not in the target diff.`, + ); + } + if (typeof comment.startLineNumber === 'number') { + const startSide = comment.startSide ?? comment.side; + if (!lineMap.has(`${startSide}:${comment.startLineNumber}`)) { + throw targetResolutionError( + `Range start ${comment.startLineNumber} on the ${startSide} side is not in the target diff.`, + ); + } + } + return file; +}; + +module.exports = { + createDiffLineMap, + targetResolutionError, + validateCurrentReviewCommentTarget, +}; diff --git a/electron/git-state/review-range-sections.cjs b/electron/git-state/review-range-sections.cjs new file mode 100644 index 00000000..4268acab --- /dev/null +++ b/electron/git-state/review-range-sections.cjs @@ -0,0 +1,287 @@ +// @ts-check + +const { createSummary, getFingerprint, summarizeContent } = require('./common.cjs'); + +/** + * @typedef {import('../../core/types.ts').ChangedFile} ChangedFile + * @typedef {import('../../core/lib/review-artifacts.ts').ArtifactFile} ArtifactFile + */ + +/** + * git and forge APIs emit `Binary files a/x and b/x differ` as diff metadata. + * Anchor to the start of a line so the same text inside an added, removed, or + * context line does not misclassify a textual patch as binary. + * + * @param {string} patch + */ +const isBinaryDiffPatch = (patch) => /^Binary files .* differ/m.test(patch); + +/** + * A provider may omit a textual patch while still supplying immutable blob + * identities. Keep those files loadable; an absent patch alone is not proof + * that the file is binary. + * + * @param {ArtifactFile} file + */ +const canHydrateArtifactFile = (file) => + Boolean(file.patch || file.oldObjectId || file.newObjectId); + +/** + * A bounded provider response can leave a Range Artifact with incomplete file + * coverage. Keep that distinct from a complete range: the renderer needs one + * visible warning rather than a file tree that falsely suggests every change + * is present. + * + * @param {import('../../core/lib/review-artifacts.ts').RangeArtifact} artifact + * @param {number} number + * @returns {ChangedFile} + */ +const createCoverageWarningFile = (artifact, number) => { + const partiallyAvailable = artifact.files.length > 0; + const warningKind = partiallyAvailable ? 'incomplete' : 'unavailable'; + const warningLabel = partiallyAvailable ? 'Review diff incomplete' : 'Review diff unavailable'; + const existingPaths = new Set(artifact.files.map((file) => file.path)); + let warningPath = warningLabel; + let suffix = 2; + while (existingPaths.has(warningPath)) { + warningPath = `${warningLabel} (${suffix})`; + suffix += 1; + } + return { + fingerprint: getFingerprint( + `${artifact.baseSha}:${artifact.headSha}:review-range-${warningKind}:${artifact.incompleteReason ?? ''}`, + ), + path: warningPath, + sections: [ + { + binary: false, + id: `review-range-${warningKind}:${number}`, + kind: 'pull-request', + loadState: 'error', + patch: '', + // Deliberately omit range coordinates: this is evidence about the + // whole review, not a provider file that can accept a comment target. + summary: createSummary( + artifact.incompleteReason ?? + (partiallyAvailable + ? 'Codiff could not load the complete review diff, so some changed files may be missing.' + : 'Codiff could not load a complete review diff, so changed files are unavailable.'), + { canLoad: false }, + ), + }, + ], + status: 'modified', + }; +}; + +/** + * Project a provider-normalized Range Artifact into the review renderer shape + * without rebuilding provider diff semantics in Electron. + * + * @param {import('../../core/lib/review-artifacts.ts').RangeArtifact} artifact + * @param {number} number + * @param {{deferContents?: boolean}} [options] + * @returns {ReadonlyArray} + */ +const rangeArtifactToPullRequestFiles = (artifact, number, options = {}) => { + const files = artifact.files.map((file, index) => { + const patch = file.patch || ''; + const binary = isBinaryDiffPatch(patch); + const patchUnavailable = !patch && !canHydrateArtifactFile(file); + const deferContents = + options.deferContents === true && !patch && canHydrateArtifactFile(file) && !binary; + const contentFingerprint = + file.oldObjectId || file.newObjectId + ? getFingerprint(`${file.oldObjectId || ''}\0${file.newObjectId || ''}`) + : undefined; + return { + fingerprint: getFingerprint( + `${artifact.baseSha}:${artifact.headSha}:${index}:${file.path}:${file.status}:${patch}`, + ), + ...(file.oldPath ? { oldPath: file.oldPath } : {}), + path: file.path, + sections: [ + { + binary, + id: `${file.path}:pull-request:${number}`, + kind: 'pull-request', + ...(file.lineCount ? { lineCount: file.lineCount } : {}), + loadState: patchUnavailable + ? 'error' + : binary + ? 'binary' + : deferContents + ? 'deferred' + : 'ready', + patch, + range: { + base: { + label: { kind: 'commit', text: artifact.baseSha.slice(0, 7) }, + sha: artifact.baseSha, + }, + head: { + label: { kind: 'commit', text: artifact.headSha.slice(0, 7) }, + sha: artifact.headSha, + }, + }, + summary: createSummary( + patchUnavailable + ? 'The provider did not return a patch or immutable content identity for this file.' + : binary + ? 'Binary file changed.' + : deferContents && !patch + ? 'The provider omitted the textual patch; exact file contents load on demand.' + : deferContents + ? 'Showing the provider patch while exact file contents load on demand.' + : 'Showing the provider patch for this file.', + { + canLoad: !binary && canHydrateArtifactFile(file), + ...(contentFingerprint ? { fingerprint: contentFingerprint } : {}), + }, + ), + }, + ], + status: file.status, + }; + }); + return artifact.coverage === 'complete' + ? files + : [...files, createCoverageWarningFile(artifact, number)]; +}; + +/** + * Build a pull-request section from one canonical ArtifactFile. When both + * contents are present, Codiff can render an expandable recomputed diff; + * otherwise it retains the provider patch. + * + * @param {{number: number}} pullRequest + * @param {ArtifactFile} file + * @param {import('./common.cjs').FileContentResult} [oldFile] + * @param {import('./common.cjs').FileContentResult} [newFile] + * @param {{base?: string, contentAttempted?: boolean, contentError?: string, deferContents?: boolean, head?: string}} [rangeRefs] + * @returns {import('../../core/types.ts').DiffSection} + */ +const createPullRequestSection = (pullRequest, file, oldFile, newFile, rangeRefs = {}) => { + const patch = file.patch || ''; + const id = `${file.path}:pull-request:${pullRequest.number}`; + const patchBinary = isBinaryDiffPatch(patch); + const contentLoadable = canHydrateArtifactFile(file); + const patchUnavailable = !patch && !contentLoadable; + const range = + rangeRefs.base && rangeRefs.head + ? { + base: { + sha: rangeRefs.base, + label: { kind: 'commit', text: rangeRefs.base.slice(0, 7) }, + }, + head: { + sha: rangeRefs.head, + label: { kind: 'commit', text: rangeRefs.head.slice(0, 7) }, + }, + } + : undefined; + const attemptedContent = oldFile != null && newFile != null; + const contentAttempted = rangeRefs.contentAttempted === true || attemptedContent; + + if (rangeRefs.deferContents && contentLoadable && !patchBinary) { + return { + binary: false, + id, + kind: 'pull-request', + loadState: 'deferred', + patch, + ...(range ? { range } : {}), + summary: createSummary( + patch + ? 'Showing the provider patch while exact file contents load on demand.' + : 'The provider omitted the textual patch; exact file contents load on demand.', + { canLoad: true }, + ), + }; + } + + if (rangeRefs.contentError && contentLoadable && !patchBinary) { + return { + binary: false, + id, + kind: 'pull-request', + loadState: 'error', + patch, + ...(range ? { range } : {}), + summary: createSummary(rangeRefs.contentError, { canLoad: true }), + }; + } + + if (attemptedContent && !patchBinary) { + const summary = summarizeContent(oldFile, newFile); + const oldContents = oldFile.file?.contents ?? ''; + const newContents = newFile.file?.contents ?? ''; + // A modification that reads empty on both sides means the content failed to + // load; keep the patch instead of rendering it as an empty (no-op) diff. + const contentMissing = + (file.status === 'modified' || file.status === 'renamed') && + oldContents === '' && + newContents === ''; + + if (summary.loadState === 'ready' && !contentMissing) { + return { + binary: false, + id, + kind: 'pull-request', + loadState: 'ready', + newFile: newFile.file, + oldFile: oldFile.file, + patch, + ...(range ? { range } : {}), + }; + } + + if (summary.loadState !== 'ready') { + return { + ...summary, + id, + kind: 'pull-request', + patch, + ...(range ? { range } : {}), + }; + } + } + + const retryOmittedPatch = !patch && contentLoadable && !patchBinary && !contentAttempted; + const loadState = patchUnavailable + ? 'error' + : patchBinary + ? 'binary' + : retryOmittedPatch + ? 'deferred' + : !patch && contentAttempted + ? 'error' + : 'ready'; + return { + binary: patchBinary, + id, + kind: 'pull-request', + loadState, + patch, + ...(range ? { range } : {}), + summary: createSummary( + patchUnavailable + ? 'The provider did not return a patch or immutable content identity for this file.' + : patchBinary + ? 'Binary file changed.' + : retryOmittedPatch + ? 'Exact contents are not available yet; retry loading this provider-omitted patch.' + : !patch && contentAttempted + ? 'The provider omitted the patch and exact file contents could not be loaded.' + : 'Showing the pull request patch for this file.', + { canLoad: retryOmittedPatch }, + ), + }; +}; + +module.exports = { + canHydrateArtifactFile, + createPullRequestSection, + isBinaryDiffPatch, + rangeArtifactToPullRequestFiles, +}; diff --git a/electron/git-state/working-tree.cjs b/electron/git-state/working-tree.cjs index cc9f9a58..4441ebe0 100644 --- a/electron/git-state/working-tree.cjs +++ b/electron/git-state/working-tree.cjs @@ -1,6 +1,10 @@ // @ts-check -const { readRepositoryChangeSignature } = require('../repository-watcher.cjs'); +const { + createRepositoryWatcherSnapshot, + parseRepositoryWatcherStatus, + setRepositoryWatcherInitialSnapshot, +} = require('../repository-watcher.cjs'); const { createSection, createSummary, @@ -12,20 +16,13 @@ const { getWhitespaceDiffArgs, git, MAX_UNTRACKED_INITIAL_ITEMS, - parseStatus, - readFileStat, - readGitImageFile, - readIndexImageFile, - readWorkingTreeImageFile, - validateRepositoryPath, + normalizeStatus, } = require('./common.cjs'); +const { getCommandActionSignal } = require('../command-log.cjs'); /** * @typedef {import('../../core/types.ts').ChangedFile} ChangedFile - * @typedef {import('../../core/types.ts').DiffImageContentRequest} DiffImageContentRequest - * @typedef {import('../../core/types.ts').DiffImageContentResult} DiffImageContentResult * @typedef {import('../../core/types.ts').DiffSection} DiffSection - * @typedef {import('../../core/types.ts').DiffSectionContentRequest} DiffSectionContentRequest * @typedef {import('../../core/types.ts').RepositoryState} RepositoryState * @typedef {import('./common.cjs').StatusItem} StatusItem * @typedef {'staged' | 'unstaged'} WorkingTreeSectionKind @@ -33,6 +30,94 @@ const { const diffGitHeaderPattern = /^diff --git (.+)$/; +/** @param {string} record @param {number} count */ +const readPorcelainV2StatusPath = (record, count) => { + let index = 0; + for (let field = 0; field < count; field += 1) { + index = record.indexOf(' ', index); + if (index === -1) { + return ''; + } + index += 1; + } + return record.slice(index); +}; + +/** @param {string} x @param {string} y @param {string} path @param {string} [oldPath] */ +const createPorcelainV2StatusItem = (x, y, path, oldPath) => { + const conflictCode = `${x}${y}`; + const conflicted = ['AA', 'AU', 'DD', 'DU', 'UA', 'UD', 'UU'].includes(conflictCode); + if (conflicted) { + return { + ...(oldPath ? { oldPath } : {}), + ...(conflictCode === 'DD' + ? { conflictStage: 1 } + : conflictCode === 'DU' || conflictCode === 'UA' + ? {} + : { conflictStage: 2 }), + path, + staged: false, + status: 'conflicted', + unstaged: true, + untracked: false, + }; + } + const staged = x !== '.' && x !== ' '; + const unstaged = y !== '.' && y !== ' '; + return { + ...(oldPath ? { oldPath } : {}), + path, + staged, + status: normalizeStatus(staged ? x : y), + unstaged, + untracked: false, + }; +}; + +/** @param {string} raw @returns {Array} */ +const parsePorcelainV2Status = (raw) => { + const files = []; + const records = raw.split('\0').filter(Boolean); + + for (let index = 0; index < records.length; index += 1) { + const record = records[index]; + if (record.startsWith('? ')) { + files.push({ + path: record.slice(2), + staged: false, + status: 'untracked', + unstaged: true, + untracked: true, + }); + continue; + } + if (record.startsWith('1 ')) { + files.push( + createPorcelainV2StatusItem(record[2], record[3], readPorcelainV2StatusPath(record, 8)), + ); + continue; + } + if (record.startsWith('2 ')) { + files.push( + createPorcelainV2StatusItem( + record[2], + record[3], + readPorcelainV2StatusPath(record, 9), + records[++index], + ), + ); + continue; + } + if (record.startsWith('u ')) { + files.push( + createPorcelainV2StatusItem(record[2], record[3], readPorcelainV2StatusPath(record, 10)), + ); + } + } + + return files; +}; + /** @param {string} value */ const unquoteGitPath = (value) => { if (!value.startsWith('"')) { @@ -133,14 +218,21 @@ const splitPatchByPath = (rawPatch) => { /** * @param {string} repoRoot * @param {WorkingTreeSectionKind} kind - * @param {{showWhitespace?: boolean}} [options] + * @param {{head?: string; showWhitespace?: boolean}} [options] * @returns {Promise>} */ const readPatchMap = async (repoRoot, kind, options = {}) => { const whitespaceArgs = getWhitespaceDiffArgs(options); const args = kind === 'staged' - ? ['diff', '--cached', '--patch', '--no-ext-diff', ...whitespaceArgs] + ? [ + 'diff', + '--cached', + '--patch', + '--no-ext-diff', + ...whitespaceArgs, + ...(options.head ? [options.head] : []), + ] : ['diff', '--patch', '--no-ext-diff', ...whitespaceArgs]; return splitPatchByPath(await git(repoRoot, args)); }; @@ -214,20 +306,30 @@ const listUntrackedItems = async (repoRoot) => { /** * @param {string} launchPath - * @param {{eagerContents?: boolean; showWhitespace?: boolean}} [options] + * @param {{eagerContents?: boolean; repositoryRoot?: string; showWhitespace?: boolean}} [options] * @returns {Promise} */ const readWorkingTreeState = async (launchPath, options = {}) => { - const repoRoot = (await git(launchPath, ['rev-parse', '--show-toplevel'])).trim(); - const [trackedStatus, untrackedItems] = await Promise.all([ - git(repoRoot, ['status', '--porcelain=v1', '-z', '-uno']), + const repoRoot = + options.repositoryRoot || (await git(launchPath, ['rev-parse', '--show-toplevel'])).trim(); + const [watcherStatus, untrackedItems, rawHead] = await Promise.all([ + git(repoRoot, ['status', '--porcelain=v2', '--branch', '-z', '-uall']), listUntrackedItems(repoRoot), + gitOrEmpty(repoRoot, ['rev-parse', '--verify', '--quiet', 'HEAD^{commit}']), ]); - const status = [...parseStatus(trackedStatus), ...untrackedItems].sort(fileSort); + const head = rawHead.trim(); + const initialWatcherSnapshot = createRepositoryWatcherSnapshot( + repoRoot, + parseRepositoryWatcherStatus(watcherStatus), + ); + const status = [ + ...parsePorcelainV2Status(watcherStatus).filter(({ untracked }) => !untracked), + ...untrackedItems, + ].sort(fileSort); const shouldUsePatchOnly = options.eagerContents === false; const [stagedPatches, unstagedPatches] = shouldUsePatchOnly ? await Promise.all([ - readPatchMap(repoRoot, 'staged', options), + readPatchMap(repoRoot, 'staged', { ...options, head }), readPatchMap(repoRoot, 'unstaged', options), ]) : [new Map(), new Map()]; @@ -242,6 +344,7 @@ const readWorkingTreeState = async (launchPath, options = {}) => { if (item.staged) { sections.push( await createSection(repoRoot, item, 'staged', { + head, patch: stagedPatches.get(item.path), patchOnly, showWhitespace: options.showWhitespace, @@ -252,6 +355,7 @@ const readWorkingTreeState = async (launchPath, options = {}) => { if (item.unstaged) { sections.push( await createSection(repoRoot, item, 'unstaged', { + head, patch: item.status === 'conflicted' ? undefined : unstagedPatches.get(item.path), patchOnly, showWhitespace: options.showWhitespace, @@ -281,132 +385,87 @@ const readWorkingTreeState = async (launchPath, options = {}) => { }); } - return { - files, - generatedAt: Date.now(), - launchPath, - root: repoRoot, - source: { - type: 'working-tree', + return setRepositoryWatcherInitialSnapshot( + { + files, + generatedAt: Date.now(), + launchPath, + root: repoRoot, + source: { + type: 'working-tree', + }, }, - }; -}; - -/** @param {string} repoRoot @param {string} path @returns {Promise} */ -const getStatusItemForPath = async (repoRoot, path) => { - const trackedStatus = parseStatus( - await git(repoRoot, ['status', '--porcelain=v1', '-z', '-uno']), + initialWatcherSnapshot, ); - const trackedItem = trackedStatus.find((item) => item.path === path); - if (trackedItem) { - return trackedItem; - } - - const stat = await readFileStat(repoRoot, path); - return { - directory: Boolean(stat?.isDirectory()), - path, - staged: false, - status: 'untracked', - unstaged: true, - untracked: true, - }; -}; - -/** @param {string} launchPath @param {DiffSectionContentRequest} request */ -const readDiffSectionContent = async (launchPath, request) => { - const repoRoot = (await git(launchPath, ['rev-parse', '--show-toplevel'])).trim(); - const path = validateRepositoryPath(request.path); - if (request.kind === 'commit' || request.source?.type === 'commit') { - throw new Error('Lazy loading commit diffs is not supported.'); - } - - const item = await getStatusItemForPath(repoRoot, path); - return createSection(repoRoot, item, /** @type {WorkingTreeSectionKind} */ (request.kind), { - force: request.force, - showWhitespace: request.showWhitespace, - }); -}; - -/** - * @param {string} launchPath - * @param {DiffImageContentRequest} request - * @returns {Promise} - */ -const readDiffImageContent = async (launchPath, request) => { - try { - const repoRoot = (await git(launchPath, ['rev-parse', '--show-toplevel'])).trim(); - const path = validateRepositoryPath(request.path); - if (request.kind === 'commit' || request.source?.type === 'commit') { - throw new Error('Commit image diffs are loaded through the commit reader.'); - } - - const item = await getStatusItemForPath(repoRoot, path); - const oldPath = item.oldPath || item.path; - const [oldImage, newImage] = - request.kind === 'staged' - ? await Promise.all([ - readGitImageFile(repoRoot, 'HEAD', oldPath), - readIndexImageFile(repoRoot, item.path), - ]) - : await Promise.all([ - item.untracked - ? undefined - : readIndexImageFile(repoRoot, item.path, item.conflictStage), - readWorkingTreeImageFile(repoRoot, item.path), - ]); - - if (!oldImage && !newImage) { - return { - reason: 'Codiff could not load either side of this image.', - status: 'unavailable', - }; - } - - return { - ...(newImage ? { newImage } : {}), - ...(oldImage ? { oldImage } : {}), - status: 'ready', - }; - } catch (error) { - return { - reason: error instanceof Error ? error.message : 'Codiff could not load this image.', - status: 'unavailable', - }; - } }; /** @param {string} repoRoot @param {ReadonlyArray} args */ const gitOrEmpty = async (repoRoot, args) => { + const signal = getCommandActionSignal(); try { - return await git(repoRoot, args); + return await git(repoRoot, args, { signal }); } catch { + signal?.throwIfAborted(); return ''; } }; +/** @type {Map>>} */ +const gitIdentityReads = new Map(); + /** @param {string} launchPath */ -const readGitIdentity = async (launchPath) => { - const [configuredName, configuredEmail] = await Promise.all([ +const readGitIdentity = (launchPath) => { + const signal = getCommandActionSignal(); + let readsBySignal = gitIdentityReads.get(launchPath); + if (!readsBySignal) { + readsBySignal = new Map(); + gitIdentityReads.set(launchPath, readsBySignal); + } + const signalKey = signal || null; + const existing = readsBySignal.get(signalKey); + if (existing) { + return existing; + } + const read = Promise.all([ gitOrEmpty(launchPath, ['config', '--get', 'user.name']), gitOrEmpty(launchPath, ['config', '--get', 'user.email']), - ]); - const email = configuredEmail.trim(); - const name = configuredName.trim(); - - return { - email, - gravatarUrl: email - ? `https://www.gravatar.com/avatar/${getGravatarHash(email)}?s=80&d=identicon` - : undefined, - name, + ]).then(([configuredName, configuredEmail]) => { + const email = configuredEmail.trim(); + const name = configuredName.trim(); + return { + email, + gravatarUrl: email + ? `https://www.gravatar.com/avatar/${getGravatarHash(email)}?s=80&d=identicon` + : undefined, + name, + }; + }); + let pending; + const clear = () => { + if (readsBySignal.get(signalKey) !== pending) { + return; + } + readsBySignal.delete(signalKey); + if (readsBySignal.size === 0 && gitIdentityReads.get(launchPath) === readsBySignal) { + gitIdentityReads.delete(launchPath); + } }; + pending = read.then( + (identity) => { + clear(); + return identity; + }, + (error) => { + clear(); + throw error; + }, + ); + readsBySignal.set(signalKey, pending); + return pending; }; module.exports = { - readDiffSectionContent, - readDiffImageContent, + parsePorcelainV2Status, readGitIdentity, - readRepositoryChangeSignature, readWorkingTreeState, }; diff --git a/electron/github-history-bridge.cjs b/electron/github-history-bridge.cjs new file mode 100644 index 00000000..8493a8e1 --- /dev/null +++ b/electron/github-history-bridge.cjs @@ -0,0 +1,23 @@ +// @ts-check + +/** + * CJS bridge to @nkzw/codiff-github (ESM). + */ + +const { pathToFileURL } = require('node:url'); +const { join } = require('node:path'); + +/** @type {Promise | null} */ +let modulePromise = null; + +const loadGitHubHistory = () => { + if (!modulePromise) { + const modulePath = join(__dirname, '../github/dist/index.mjs'); + modulePromise = import(pathToFileURL(modulePath).href); + } + return modulePromise; +}; + +module.exports = { + loadGitHubHistory, +}; diff --git a/electron/gitlab-history-bridge.cjs b/electron/gitlab-history-bridge.cjs new file mode 100644 index 00000000..2e193f0a --- /dev/null +++ b/electron/gitlab-history-bridge.cjs @@ -0,0 +1,23 @@ +// @ts-check + +/** + * CJS bridge to @nkzw/codiff-gitlab (ESM). + */ + +const { pathToFileURL } = require('node:url'); +const { join } = require('node:path'); + +/** @type {Promise | null} */ +let modulePromise = null; + +const loadGitLabHistory = () => { + if (!modulePromise) { + const modulePath = join(__dirname, '../gitlab/dist/index.mjs'); + modulePromise = import(pathToFileURL(modulePath).href); + } + return modulePromise; +}; + +module.exports = { + loadGitLabHistory, +}; diff --git a/electron/main.cjs b/electron/main.cjs index 2edab12d..8095a8e3 100644 --- a/electron/main.cjs +++ b/electron/main.cjs @@ -1,5 +1,7 @@ // @ts-check +const electronProcessStartedAt = new Date(Date.now() - process.uptime() * 1000).toISOString(); + const { existsSync, readFileSync, writeFileSync } = require('node:fs'); const { basename, dirname, join, relative, resolve } = require('node:path'); const { pathToFileURL } = require('node:url'); @@ -15,14 +17,23 @@ const { screen, shell, } = require('electron'); +const { + configureCommandLog, + recordCommandMilestone, + runWithCommandAction, + startCommandAction, +} = require('./command-log.cjs'); + +app.setName('Codiff'); const squirrelStartup = require('electron-squirrel-startup'); const { listRepositoryHistory, - readDiffImageContent, - readDiffSectionContent, readGitIdentity, readRepositoryState, + readRevisionContent, + readReviewComments, readWalkthroughRepositoryState, + runWithCommandSignal, submitPullRequestComment, submitPullRequestReview, validateRepositoryPath, @@ -68,7 +79,7 @@ const { const { findMatchingWindowIdentity, getWindowIdentity, - getWindowIdentityForRepositoryState, + storeResolvedWindowState, } = require('./window-identity.cjs'); const { createPendingCommentsClipboardController } = require('./pending-comments.cjs'); const { @@ -88,11 +99,22 @@ const { writeWindowState, } = require('./window-state.cjs'); const { - getNarrativeWalkthroughCacheKey, + NARRATIVE_WALKTHROUGH_AUTHORING_VERSION, + buildNarrativeWalkthroughPrompt, + createNarrativeWalkthroughGenerationRequest, + narrativeWalkthroughResponseSchema, normalizeNarrativeWalkthrough, - readNarrativeWalkthrough, resolveNarrativeWalkthroughModel, } = require('./narrative-walkthrough.cjs'); +const { runWalkthroughGenerationTasks } = require('./walkthrough-generation-bridge.cjs'); +const { + createWalkthroughGenerationCoordinator, +} = require('./walkthrough-generation-coordinator.cjs'); +const { getWalkthroughGenerationCacheKey } = require('./walkthrough-generation-cache-key.cjs'); +const { + invokeWalkthroughModel, + parseStructuredModelResponse, +} = require('./walkthrough-model-invocation.cjs'); const { readStoredWalkthrough, writeStoredWalkthrough } = require('./walkthrough-store.cjs'); const { uploadSharedSnapshot } = require('./shared-walkthrough-upload.cjs'); const { @@ -110,6 +132,7 @@ const { } = require('./markdown-document.cjs'); const { createRepositoryWatcherCoordinator, + getRepositoryWatcherInitialSnapshot, readRepositoryWatcherSnapshot, } = require('./repository-watcher.cjs'); const { getPlanReviewPath, readPlanReview, writePlanReview } = require('./plan-review.cjs'); @@ -133,12 +156,15 @@ const root = dirname(__dirname); const windowIdentities = new Map(); /** @type {Map} */ const windowRepositories = new Map(); +/** @type {Map>} */ +const diffContentRequests = new Map(); /** @type {Map} */ const windowLaunchOptions = new Map(); /** @type {Map>} */ const windowInitialRepositoryStates = new Map(); /** @type {Map} */ const walkthroughProgressGenerations = new Map(); +const walkthroughGenerationCoordinator = createWalkthroughGenerationCoordinator(); /** @type {Map} */ const planInitialVersions = new Map(); /** @type {Set} */ @@ -149,10 +175,18 @@ const markdownDocumentWatchers = new Map(); const completedPlanWindows = new Set(); /** @type {Set} */ const openWindows = new Set(); +/** @type {Map>} */ +const initialLoadActions = new Map(); const pendingCommentsClipboardController = createPendingCommentsClipboardController({ clipboard }); /** @type {CodiffConfig} */ let config = createDefaultConfig(); +/** @template Value @param {number} webContentsId @param {() => Value} callback */ +const runInInitialLoadAction = (webContentsId, callback) => { + const action = initialLoadActions.get(webContentsId); + return action ? action.run(callback) : callback(); +}; + /** * @type {Map>} */ @@ -183,20 +217,56 @@ const refreshInstalledAgentFiles = () => { }; const getActiveAgent = () => getAgent(config.settings.agentBackend); +const abortDiffContentRequests = (webContentsId) => { + const requests = diffContentRequests.get(webContentsId); + if (!requests) { + return; + } + diffContentRequests.delete(webContentsId); + for (const controller of requests.values()) { + controller.abort(new DOMException('Diff content request canceled.', 'AbortError')); + } +}; + +const runDiffContentRequest = async (event, request, read) => { + const requestId = + typeof request?.requestId === 'string' && request.requestId + ? request.requestId + : `legacy:${Date.now()}:${Math.random()}`; + const webContentsId = event.sender.id; + const requests = diffContentRequests.get(webContentsId) ?? new Map(); + diffContentRequests.set(webContentsId, requests); + requests + .get(requestId) + ?.abort(new DOMException('Diff content request superseded.', 'AbortError')); + const controller = new AbortController(); + requests.set(requestId, controller); + try { + return await runWithCommandSignal(controller.signal, read); + } finally { + if (requests.get(requestId) === controller) { + requests.delete(requestId); + } + if (requests.size === 0) { + diffContentRequests.delete(webContentsId); + } + } +}; -/** @param {string} repositoryPath @param {ReviewSource} [source] */ -const readRepositoryStateWithConfig = (repositoryPath, source) => +/** @param {string} repositoryPath @param {ReviewSource} [source] @param {string} [repositoryRoot] */ +const readRepositoryStateWithConfig = (repositoryPath, source, repositoryRoot) => readRepositoryState(repositoryPath, source, { + repositoryRoot, showWhitespace: config.settings.showWhitespace, }); -/** @param {string} repositoryPath @param {CodiffLaunchOptions} launchOptions */ -const readInitialRepositoryStateWithConfig = (repositoryPath, launchOptions) => +/** @param {string} repositoryPath @param {CodiffLaunchOptions} launchOptions @param {string} [repositoryRoot] */ +const readInitialRepositoryStateWithConfig = (repositoryPath, launchOptions, repositoryRoot) => launchOptions.walkthrough && !launchOptions.walkthroughFile ? readWalkthroughRepositoryState(repositoryPath, launchOptions.source, { showWhitespace: config.settings.showWhitespace, }) - : readRepositoryStateWithConfig(repositoryPath, launchOptions.source); + : readRepositoryStateWithConfig(repositoryPath, launchOptions.source, repositoryRoot); /** @param {number} webContentsId */ const resolveWindowAgent = (webContentsId) => { @@ -240,24 +310,17 @@ const getMarkdownDocumentContext = (webContentsId) => ({ /** @param {number} webContentsId @param {RepositoryState} state */ const storeResolvedRepositoryState = (webContentsId, state) => { - windowRepositories.set(webContentsId, state.root); + storeResolvedWindowState(webContentsId, state, { + identities: windowIdentities, + launchOptions: windowLaunchOptions, + repositories: windowRepositories, + }); const browserWindow = BrowserWindow.getAllWindows().find( (window) => window.webContents.id === webContentsId, ); if (browserWindow && !browserWindow.isDestroyed()) { browserWindow.setTitle(getRepositoryWindowTitle(state)); } - const launchOptions = windowLaunchOptions.get(webContentsId); - if (launchOptions) { - windowLaunchOptions.set(webContentsId, { - ...launchOptions, - source: state.source, - }); - } - const identity = getWindowIdentityForRepositoryState(state); - if (identity) { - windowIdentities.set(webContentsId, identity); - } }; /** @param {number} webContentsId */ @@ -505,8 +568,12 @@ const beginRepositorySelfWrite = (webContentsId, path) => const finishRepositorySelfWrite = (token, version) => repositoryWatcherCoordinator.finishWrite(token, version); -/** @param {import('electron').BrowserWindow} browserWindow @param {string} repositoryPath */ -const startRepositoryWatcher = (browserWindow, repositoryPath) => { +/** + * @param {import('electron').BrowserWindow} browserWindow + * @param {string} repositoryPath + * @param {Promise<{head: string; pathSignatures: Record; pathVersions: Record; root: string; signature: string}> | undefined} initialSnapshot + */ +const startRepositoryWatcher = (browserWindow, repositoryPath, initialSnapshot) => { const webContentsId = browserWindow.webContents.id; void repositoryWatcherCoordinator.attach({ getState: () => ({ @@ -515,6 +582,7 @@ const startRepositoryWatcher = (browserWindow, repositoryPath) => { !browserWindow.isDestroyed() && browserWindow.isVisible() && !browserWindow.isMinimized(), }), id: webContentsId, + initialSnapshot, notify: (root) => { if (!browserWindow.isDestroyed()) { browserWindow.webContents.send('codiff:repositoryChanged', { root }); @@ -866,11 +934,13 @@ ipcMain.on( * @param {string} repositoryPath * @param {CodiffLaunchOptions} [launchOptions] * @param {WindowIdentity | null} [identity] + * @param {ReturnType} initialLoadAction */ const createWindow = ( repositoryPath, launchOptions = { repositoryPathProvided: true, walkthrough: false }, - identity = getWindowIdentity(repositoryPath, launchOptions), + identity, + initialLoadAction, ) => { const savedState = readWindowState(); const validatedState = savedState @@ -919,6 +989,7 @@ const createWindow = ( attachExternalLinkHandling(window.webContents, (url) => shell.openExternal(url)); const webContentsId = window.webContents.id; + initialLoadActions.set(webContentsId, initialLoadAction); openWindows.add(window); if (identity) { windowIdentities.set(webContentsId, identity); @@ -927,13 +998,31 @@ const createWindow = ( windowLaunchOptions.set(webContentsId, launchOptions); const initialRepositoryStatePromise = launchOptions.planFile ? null - : readInitialRepositoryStateWithConfig(repositoryPath, launchOptions); - const initialRepositoryState = initialRepositoryStatePromise?.then((state) => { - if (!window.isDestroyed()) { - storeResolvedRepositoryState(webContentsId, state); - } - return state; - }); + : initialLoadAction.run(() => + readInitialRepositoryStateWithConfig( + repositoryPath, + launchOptions, + identity?.repositoryRoot, + ), + ); + const initialRepositoryState = initialRepositoryStatePromise?.then( + (state) => { + recordCommandMilestone('repository-review-state-available', { + actionId: initialLoadAction.id, + }); + if (!window.isDestroyed()) { + storeResolvedRepositoryState(webContentsId, state); + } + return state; + }, + (error) => { + initialLoadAction.finish({ error }); + if (initialLoadActions.get(webContentsId) === initialLoadAction) { + initialLoadActions.delete(webContentsId); + } + throw error; + }, + ); initialRepositoryState?.catch(() => {}); if (initialRepositoryState) { windowInitialRepositoryStates.set(webContentsId, initialRepositoryState); @@ -950,7 +1039,7 @@ const createWindow = ( (state.source.type === 'working-tree' || state.source.type === 'branch-working-tree') && !window.isDestroyed() ) { - startRepositoryWatcher(window, state.root); + startRepositoryWatcher(window, state.root, getRepositoryWatcherInitialSnapshot(state)); } }) .catch(() => {}); @@ -967,7 +1056,13 @@ const createWindow = ( window.on('minimize', () => repositoryWatcherCoordinator.visibilityChanged(webContentsId)); window.on('restore', () => repositoryWatcherCoordinator.focus(webContentsId)); window.on('show', () => repositoryWatcherCoordinator.focus(webContentsId)); - window.once('ready-to-show', () => window.show()); + window.once('ready-to-show', () => { + window.show(); + if (launchOptions.planFile) { + initialLoadAction.finish(); + initialLoadActions.delete(webContentsId); + } + }); let allowClose = false; let copyingPendingCommentsBeforeClose = false; window.on('close', (event) => { @@ -1016,6 +1111,8 @@ const createWindow = ( }); }); window.on('closed', () => { + initialLoadActions.get(webContentsId)?.cancel(); + initialLoadActions.delete(webContentsId); openWindows.delete(window); definitionSearchCoordinator.cancel(webContentsId); repositoryWatcherCoordinator.detach(webContentsId); @@ -1023,14 +1120,33 @@ const createWindow = ( completedPlanWindows.delete(webContentsId); planInitialVersions.delete(webContentsId); readyPlanWindows.delete(webContentsId); + walkthroughGenerationCoordinator.clear( + webContentsId, + new Error('The walkthrough window was closed.'), + ); + abortDiffContentRequests(webContentsId); windowIdentities.delete(webContentsId); windowInitialRepositoryStates.delete(webContentsId); walkthroughProgressGenerations.delete(webContentsId); windowRepositories.delete(webContentsId); windowLaunchOptions.delete(webContentsId); }); + window.webContents.on('did-start-navigation', (_event, _url, _inPlace, isMainFrame) => { + if (isMainFrame) { + abortDiffContentRequests(webContentsId); + walkthroughGenerationCoordinator.clear( + webContentsId, + new Error('The walkthrough renderer was reloaded.'), + ); + } + }); window.webContents.on('render-process-gone', () => { definitionSearchCoordinator.cancel(webContentsId); + walkthroughGenerationCoordinator.clear( + webContentsId, + new Error('The walkthrough renderer process exited.'), + ); + abortDiffContentRequests(webContentsId); writePlanResult(webContentsId, 'canceled'); }); window.webContents.on( @@ -1094,7 +1210,9 @@ const focusWindow = (window) => { /** @param {number} webContentsId */ const getWalkthroughShareContext = async (webContentsId) => { const repositoryPath = windowRepositories.get(webContentsId) || getLaunchPath(); - const uploader = await readGitIdentity(repositoryPath); + const uploader = await runInInitialLoadAction(webContentsId, () => + readGitIdentity(repositoryPath), + ); return { target: resolveWalkthroughShareTarget({ @@ -1170,7 +1288,15 @@ const focusOrCreateWindow = ( repositoryPath, launchOptions = { repositoryPathProvided: true, walkthrough: false }, ) => { - const identity = getWindowIdentity(repositoryPath, launchOptions); + const initialLoadAction = startCommandAction({ + command: 'initial-load', + cwd: repositoryPath, + details: { + explicitSource: Boolean(launchOptions.source), + sourceType: launchOptions.source?.type, + }, + }); + const identity = initialLoadAction.run(() => getWindowIdentity(repositoryPath, launchOptions)); const matchingWebContentsId = findMatchingWindowIdentity(identity, windowIdentities); const matchingWindow = matchingWebContentsId == null @@ -1181,6 +1307,8 @@ const focusOrCreateWindow = ( if (matchingWindow) { if (launchOptions.planFile || launchOptions.walkthrough || launchOptions.walkthroughFile) { + initialLoadActions.get(matchingWebContentsId)?.cancel(); + initialLoadActions.set(matchingWebContentsId, initialLoadAction); windowRepositories.set(matchingWebContentsId, identity?.repositoryRoot || repositoryPath); windowLaunchOptions.set(matchingWebContentsId, launchOptions); if (launchOptions.planFile) { @@ -1188,21 +1316,48 @@ const focusOrCreateWindow = ( readyPlanWindows.delete(matchingWebContentsId); windowInitialRepositoryStates.delete(matchingWebContentsId); } else { - windowInitialRepositoryStates.set( - matchingWebContentsId, - readInitialRepositoryStateWithConfig(repositoryPath, launchOptions), + const initialState = initialLoadAction.run(() => + readInitialRepositoryStateWithConfig( + repositoryPath, + launchOptions, + identity?.repositoryRoot, + ), + ); + windowInitialRepositoryStates.set(matchingWebContentsId, initialState); + initialState.then( + () => + recordCommandMilestone('repository-review-state-available', { + actionId: initialLoadAction.id, + }), + (error) => { + initialLoadAction.finish({ error }); + if (initialLoadActions.get(matchingWebContentsId) === initialLoadAction) { + initialLoadActions.delete(matchingWebContentsId); + } + }, ); } if (identity) { windowIdentities.set(matchingWebContentsId, identity); } + walkthroughGenerationCoordinator.clear( + matchingWebContentsId, + new Error('The walkthrough window was retargeted.'), + ); + abortDiffContentRequests(matchingWebContentsId); matchingWindow.reload(); + if (launchOptions.planFile) { + initialLoadAction.finish(); + initialLoadActions.delete(matchingWebContentsId); + } + } else { + initialLoadAction.finish(); } focusWindow(matchingWindow); return matchingWindow; } - return createWindow(repositoryPath, launchOptions, identity); + return createWindow(repositoryPath, launchOptions, identity, initialLoadAction); }; const INITIAL_UPDATE_CHECK_DELAY_MS = 10 * 1000; @@ -1289,8 +1444,11 @@ const lock = if (squirrelStartup || !lock) { app.quit(); } else { - app.setName('Codiff'); - + configureCommandLog(app.getPath('logs'), { processStartedAt: electronProcessStartedAt }); + recordCommandMilestone('electron-process-start', { + monotonicMs: 0, + timestamp: electronProcessStartedAt, + }); app.on('second-instance', (event, commandLine, workingDirectory, additionalData) => { const data = /** @type {SingleInstanceAdditionalData} */ (additionalData || {}); const launchOptions = @@ -1443,7 +1601,9 @@ ipcMain.handle('codiff:getRepositoryState', async (event, source) => { : await readRepositoryStateWithConfig(repositoryPath, source || launchOptions?.source); storeResolvedRepositoryState(event.sender.id, state); rememberLastRepositoryPath(state.root); - void resetRepositoryWatcher(event.sender.id, state.root); + if (!initialState) { + void resetRepositoryWatcher(event.sender.id, state.root); + } return state; }); @@ -1562,8 +1722,15 @@ ipcMain.handle('codiff:installTerminalHelper', async (event) => { return getTerminalHelperStatus(); }); +ipcMain.handle('codiff:cancelNarrativeWalkthrough', (event) => { + const progressGeneration = (walkthroughProgressGenerations.get(event.sender.id) || 0) + 1; + walkthroughProgressGenerations.set(event.sender.id, progressGeneration); + walkthroughGenerationCoordinator.cancel(event.sender.id, new Error('The review source changed.')); +}); + ipcMain.handle('codiff:getNarrativeWalkthrough', async (event, source, options) => { const launchOptions = windowLaunchOptions.get(event.sender.id); + const abortController = walkthroughGenerationCoordinator.begin(event.sender.id); const progressGeneration = (walkthroughProgressGenerations.get(event.sender.id) || 0) + 1; walkthroughProgressGenerations.set(event.sender.id, progressGeneration); const reportProgress = createWalkthroughProgressReporter( @@ -1572,11 +1739,12 @@ ipcMain.handle('codiff:getNarrativeWalkthrough', async (event, source, options) ); try { + reportProgress({ phase: 'preparing', summary: 'Loading review state.' }); const repositoryPath = windowRepositories.get(event.sender.id) || getLaunchPath(); - const state = await readRepositoryStateWithConfig( - repositoryPath, - source || launchOptions?.source, + const state = await runWithCommandSignal(abortController.signal, () => + readRepositoryStateWithConfig(repositoryPath, source || launchOptions?.source), ); + abortController.signal.throwIfAborted(); const agent = resolveWindowAgent(event.sender.id); const walkthroughFile = launchOptions?.walkthroughFile; if (walkthroughFile) { @@ -1585,6 +1753,9 @@ ipcMain.handle('codiff:getNarrativeWalkthrough', async (event, source, options) try { contents = readFileSync(walkthroughFile, 'utf8'); } catch (error) { + if (abortController.signal.aborted) { + throw abortController.signal.reason; + } const detail = error instanceof Error ? error.message : String(error); return { reason: `Could not read walkthrough file: ${detail}`, @@ -1595,9 +1766,13 @@ ipcMain.handle('codiff:getNarrativeWalkthrough', async (event, source, options) const sessionContext = await Promise.resolve( agent.readSessionContext(launchOptions?.[agent.sessionLaunchOptionKey]), ).catch(() => null); + abortController.signal.throwIfAborted(); try { input = JSON.parse(contents); } catch (error) { + if (abortController.signal.aborted) { + throw abortController.signal.reason; + } const detail = error instanceof Error ? error.message : String(error); return { reason: `Could not read walkthrough file: ${detail}`, @@ -1606,6 +1781,7 @@ ipcMain.handle('codiff:getNarrativeWalkthrough', async (event, source, options) } try { + abortController.signal.throwIfAborted(); return { status: 'ready', walkthrough: normalizeNarrativeWalkthrough(input, state.files, { @@ -1618,6 +1794,9 @@ ipcMain.handle('codiff:getNarrativeWalkthrough', async (event, source, options) }), }; } catch (error) { + if (abortController.signal.aborted) { + throw abortController.signal.reason; + } const detail = error instanceof Error ? error.message : String(error); // The usual cause of an unanchored working-tree walkthrough is that the // changes were committed (or reverted) after it was authored. Surface a @@ -1638,19 +1817,40 @@ ipcMain.handle('codiff:getNarrativeWalkthrough', async (event, source, options) launchOptions?.walkthroughContext, await agent.readSessionContext(launchOptions?.[agent.sessionLaunchOptionKey]), ); + abortController.signal.throwIfAborted(); const agentOptions = getAgentOptions(agent); const walkthroughModel = resolveNarrativeWalkthroughModel(state, agent, agentOptions.model); const walkthroughPrompt = config.settings.walkthroughPrompt; - const cacheKey = getNarrativeWalkthroughCacheKey( + const modelCandidates = agent.getModelCandidates(walkthroughModel); + const profile = { + agent: agent.id, + authoringVersion: NARRATIVE_WALKTHROUGH_AUTHORING_VERSION, + modelCandidates, + }; + const cacheRequest = { + prompt: buildNarrativeWalkthroughPrompt( + state, + walkthroughContext, + agent.label, + walkthroughPrompt, + ), + responseSchema: narrativeWalkthroughResponseSchema, + }; + const cacheKey = getWalkthroughGenerationCacheKey({ + profile, + request: cacheRequest, state, - agent, - walkthroughModel, - walkthroughContext, - walkthroughPrompt, - ); + }); if (!options?.force) { const cachedWalkthrough = readStoredWalkthrough(cacheKey); if (cachedWalkthrough) { + reportProgress({ + completed: 1, + phase: 'combining', + summary: 'Loaded the cached walkthrough.', + total: 1, + units: [{ id: 'narrative', label: 'Walkthrough narrative', status: 'ready' }], + }); return { status: 'ready', walkthrough: { @@ -1667,46 +1867,116 @@ ipcMain.handle('codiff:getNarrativeWalkthrough', async (event, source, options) } } - let generatedModel = walkthroughModel; - const onModelFallback = agentOptions.onModelFallback; - const result = await readNarrativeWalkthrough( + const generationRequest = createNarrativeWalkthroughGenerationRequest( state, agent, - { - ...agentOptions, - model: walkthroughModel, - onModelFallback: async (fallbackModel, originalModel) => { - generatedModel = fallbackModel; - await onModelFallback(fallbackModel, originalModel); - }, - onProgress: reportProgress, - }, walkthroughContext, walkthroughPrompt, options?.previousWalkthrough, ); + let notFoundCode; + const result = await runWalkthroughGenerationTasks({ + onProgress: reportProgress, + reusableComponents: walkthroughGenerationCoordinator.getReusable( + event.sender.id, + cacheKey, + options?.force, + ), + signal: abortController.signal, + tasks: [ + { + id: 'narrative', + identity: cacheKey, + label: 'Walkthrough narrative', + profile, + run: async ({ profile: taskProfile, semanticInput, signal }) => { + try { + reportProgress('agent-generation'); + const invocation = await invokeWalkthroughModel({ + agent, + agentOptions: { ...agentOptions, onProgress: reportProgress }, + outputName: generationRequest.outputName, + profile: taskProfile, + prompt: semanticInput.prompt, + repoRoot: state.root, + schema: generationRequest.schema, + signal, + timeoutMessage: generationRequest.timeoutMessage, + timeoutMs: generationRequest.timeoutMs, + }); + reportProgress('response-received'); + const walkthrough = normalizeNarrativeWalkthrough( + parseStructuredModelResponse(invocation.response), + state.files, + { + agent: agent.id, + branch: state.branch, + generatedAt: invocation.generationMetadata.generatedAt, + root: state.root, + source: state.source, + }, + generationRequest.hunkIdByAlias, + ); + if (walkthroughContext && !walkthrough.context) { + walkthrough.context = walkthroughContext; + } + return { generationMetadata: invocation.generationMetadata, output: walkthrough }; + } catch (error) { + if (agent.isNotFoundError(error)) { + notFoundCode = agent.notFoundCode; + } + throw error; + } + }, + semanticInput: { prompt: generationRequest.prompt }, + }, + ], + }); + walkthroughGenerationCoordinator.retain( + event.sender.id, + abortController, + cacheKey, + result.components, + ); if (result.status === 'ready') { - const generatedCacheKey = getNarrativeWalkthroughCacheKey( - state, - agent, - generatedModel, - walkthroughContext, - walkthroughPrompt, - ); + const walkthrough = result.components.find( + (component) => component.identity === cacheKey, + )?.output; + if (!walkthrough) { + throw new Error('Walkthrough generation completed without a validated narrative.'); + } + abortController.signal.throwIfAborted(); try { - const cacheableWalkthrough = { ...result.walkthrough }; + const cacheableWalkthrough = { ...walkthrough }; delete cacheableWalkthrough.context; - writeStoredWalkthrough(generatedCacheKey, cacheableWalkthrough); + writeStoredWalkthrough(cacheKey, cacheableWalkthrough); } catch { // Caching is optional; a filesystem failure must not hide a generated result. } + return { status: 'ready', walkthrough }; } - return result; + return { + ...(notFoundCode ? { code: notFoundCode } : {}), + reason: result.reason, + status: 'unavailable', + }; } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + reportProgress({ + completed: 0, + phase: 'generating', + summary: 'Walkthrough generation failed.', + total: 1, + units: [ + { detail: reason, id: 'narrative', label: 'Walkthrough narrative', status: 'failed' }, + ], + }); return { - reason: error instanceof Error ? error.message : String(error), + reason, status: 'unavailable', }; + } finally { + walkthroughGenerationCoordinator.finish(event.sender.id, abortController); } }); @@ -1763,15 +2033,24 @@ ipcMain.handle('codiff:askReviewAssistant', async (event, request) => { ipcMain.handle('codiff:createWalkthroughCommit', async (event, request) => { const repositoryPath = windowRepositories.get(event.sender.id) || getLaunchPath(); - const result = await createWalkthroughCommit(repositoryPath, request, (chunk) => { - if (!event.sender.isDestroyed()) { - event.sender.send('codiff:walkthroughCommitOutput', chunk); - } - }); - if (result.status === 'committed') { - await resetRepositoryWatcher(event.sender.id, repositoryPath); - } - return result; + return runWithCommandAction( + { + command: 'walkthrough-commit', + cwd: repositoryPath, + details: { fileCount: Array.isArray(request?.paths) ? request.paths.length : 0 }, + }, + async () => { + const result = await createWalkthroughCommit(repositoryPath, request, (chunk) => { + if (!event.sender.isDestroyed()) { + event.sender.send('codiff:walkthroughCommitOutput', chunk); + } + }); + if (result.status === 'committed') { + await resetRepositoryWatcher(event.sender.id, repositoryPath); + } + return result; + }, + ); }); ipcMain.handle('codiff:updateWalkthroughCommitMessage', async (event, request) => { @@ -1792,30 +2071,67 @@ ipcMain.handle('codiff:submitPullRequestComment', async (event, request) => { ipcMain.handle('codiff:submitPullRequestReview', async (event, request) => { const repositoryPath = windowRepositories.get(event.sender.id) || getLaunchPath(); - return submitPullRequestReview(repositoryPath, request); + try { + return await submitPullRequestReview(repositoryPath, request); + } catch (error) { + return { + reason: error instanceof Error ? error.message : String(error), + status: 'failed', + submittedDraftIds: [], + }; + } }); -ipcMain.handle('codiff:getDiffSectionContent', async (event, request) => { +ipcMain.handle('codiff:readRevisionContent', async (event, request) => { const repositoryPath = windowRepositories.get(event.sender.id) || getLaunchPath(); - return readDiffSectionContent(repositoryPath, { - ...request, - showWhitespace: request?.showWhitespace ?? config.settings.showWhitespace, - }); + return runDiffContentRequest(event, request, () => readRevisionContent(repositoryPath, request)); }); -ipcMain.handle('codiff:getDiffImageContent', async (event, request) => { - const repositoryPath = windowRepositories.get(event.sender.id) || getLaunchPath(); - return readDiffImageContent(repositoryPath, request); +ipcMain.on('codiff:cancelDiffContentRequest', (event, requestId) => { + if (typeof requestId !== 'string') { + return; + } + diffContentRequests + .get(event.sender.id) + ?.get(requestId) + ?.abort(new DOMException('Diff content request canceled.', 'AbortError')); }); ipcMain.handle('codiff:getRepositoryHistory', async (event, limit, source) => { const repositoryPath = windowRepositories.get(event.sender.id) || getLaunchPath(); - return listRepositoryHistory(repositoryPath, limit, source); + return runInInitialLoadAction(event.sender.id, () => + listRepositoryHistory(repositoryPath, limit, source), + ); +}); + +ipcMain.on('codiff:initialLoadMilestone', (event, name) => { + if (name !== 'first-usable-review-rendered' && name !== 'deferred-review-data-complete') { + return; + } + const action = initialLoadActions.get(event.sender.id); + if (!action) { + return; + } + recordCommandMilestone(name, { actionId: action.id }); + if (name === 'deferred-review-data-complete') { + action.finish(); + initialLoadActions.delete(event.sender.id); + } +}); + +ipcMain.handle('codiff:getReviewComments', async (event, source, requestId) => { + if (source?.type !== 'pull-request') { + throw new Error('Review comments require a pull-request source.'); + } + const repositoryPath = windowRepositories.get(event.sender.id) || getLaunchPath(); + return runDiffContentRequest(event, { requestId }, () => + runInInitialLoadAction(event.sender.id, () => readReviewComments(repositoryPath, source)), + ); }); ipcMain.handle('codiff:getGitIdentity', async (event) => { const repositoryPath = windowRepositories.get(event.sender.id) || getLaunchPath(); - return readGitIdentity(repositoryPath); + return runInInitialLoadAction(event.sender.id, () => readGitIdentity(repositoryPath)); }); ipcMain.handle('codiff:getPreferences', () => configToPreferences(config)); diff --git a/electron/main/command-line.cjs b/electron/main/command-line.cjs index b2406b17..daae6c6a 100644 --- a/electron/main/command-line.cjs +++ b/electron/main/command-line.cjs @@ -1,9 +1,9 @@ // @ts-check -const { execFileSync } = require('node:child_process'); const { existsSync } = require('node:fs'); const { resolve } = require('node:path'); const { parseArgs } = require('node:util'); +const { gitSync } = require('../git-state/common.cjs'); const { readWalkthroughContext } = require('../walkthrough-context.cjs'); const { parseReviewUrl, resolveReviewUrl } = require('../review-source.cjs'); @@ -43,9 +43,7 @@ const parseRangeArgument = (arg) => { /** @param {string} repositoryPath @param {ReadonlyArray} args */ const gitSucceeds = (repositoryPath, args) => { try { - execFileSync('git', ['-C', repositoryPath, ...args], { - stdio: ['ignore', 'ignore', 'ignore'], - }); + gitSync(repositoryPath, args); return true; } catch { return false; diff --git a/electron/narrative-walkthrough.cjs b/electron/narrative-walkthrough.cjs index 6c56aaa8..8ad5f7fb 100644 --- a/electron/narrative-walkthrough.cjs +++ b/electron/narrative-walkthrough.cjs @@ -56,6 +56,7 @@ const TIMEOUT_MS_PER_EXTRA_FILE = 1_000; const TIMEOUT_MS_PER_EXTRA_HUNK = 2_000; const LARGE_WALKTHROUGH_HUNK_THRESHOLD = 100; const WALKTHROUGH_CACHE_KEY_VERSION = 1; +const NARRATIVE_WALKTHROUGH_AUTHORING_VERSION = 'narrative-v4'; /** @param {unknown} value @param {string} [fallback] */ const cleanRich = (value, fallback = '') => { @@ -854,6 +855,31 @@ const buildNarrativeWalkthroughPrompt = ( buildNarrativeWalkthroughRequest(state, context, agentLabel, customPrompt, previousWalkthrough) .prompt; +const createNarrativeWalkthroughGenerationRequest = ( + state, + agent, + context, + customPrompt, + previousWalkthrough, +) => { + const { fileCount, hunkCount } = getWalkthroughSize(state); + const request = buildNarrativeWalkthroughRequest( + state, + context, + agent.label, + customPrompt, + previousWalkthrough, + ); + const timeoutMs = getNarrativeWalkthroughTimeoutMs(state, agent.defaultTimeoutMs); + return { + ...request, + outputName: 'walkthrough.json', + schema: narrativeWalkthroughResponseSchema, + timeoutMessage: `${agent.label} walkthrough timed out after ${Math.ceil(timeoutMs / 1_000)} seconds while processing ${fileCount} files and ${hunkCount} reviewable hunks.`, + timeoutMs, + }; +}; + /** * Cache identity for the exact model input. The previous walkthrough is * intentionally excluded: forced regeneration replaces the cached result for @@ -900,25 +926,23 @@ const readNarrativeWalkthrough = async ( previousWalkthrough, ) => { try { - const timeoutMs = getNarrativeWalkthroughTimeoutMs(state, agent.defaultTimeoutMs); - const { fileCount, hunkCount } = getWalkthroughSize(state); - const { hunkIdByAlias, prompt } = buildNarrativeWalkthroughRequest( + const request = createNarrativeWalkthroughGenerationRequest( state, + agent, context, - agent.label, customPrompt, previousWalkthrough, ); agentOptions?.onProgress?.('agent-generation'); const response = await agent.run( state.root, - prompt, - narrativeWalkthroughResponseSchema, - 'walkthrough.json', - `${agent.label} walkthrough timed out after ${Math.ceil(timeoutMs / 1_000)} seconds while processing ${fileCount} files and ${hunkCount} reviewable hunks.`, + request.prompt, + request.schema, + request.outputName, + request.timeoutMessage, { ...agentOptions, - timeoutMs, + timeoutMs: request.timeoutMs, }, ); agentOptions?.onProgress?.('response-received'); @@ -933,7 +957,7 @@ const readNarrativeWalkthrough = async ( root: state.root, source: state.source, }, - hunkIdByAlias, + request.hunkIdByAlias, ); if (context && !walkthrough.context) { walkthrough.context = context; @@ -960,9 +984,12 @@ const readNarrativeWalkthrough = async ( }; module.exports = { + NARRATIVE_WALKTHROUGH_AUTHORING_VERSION, buildNarrativeWalkthroughPrompt, + createNarrativeWalkthroughGenerationRequest, getNarrativeWalkthroughCacheKey, narrativeWalkthroughSchema, + narrativeWalkthroughResponseSchema, normalizeNarrativeWalkthrough, readNarrativeWalkthrough, resolveNarrativeWalkthroughModel, diff --git a/electron/opencode.cjs b/electron/opencode.cjs index 78216822..153ba927 100644 --- a/electron/opencode.cjs +++ b/electron/opencode.cjs @@ -327,6 +327,7 @@ const getOpenCodeServerModel = (model) => { * onModelFallback?: (fallbackModel: string, originalModel: string) => Promise | void; * onPartialText?: (delta: string) => void; * onProgress?: (phase: import('../core/types.ts').WalkthroughProgressPhase) => void; + * signal?: AbortSignal; * timeoutMs?: number; * }} [options] */ @@ -375,6 +376,7 @@ const runOpenCode = async ( ...environment, OPENCODE_PERMISSION: JSON.stringify({ '*': 'deny' }), }, + signal: options.signal, stdio: ['pipe', 'pipe', 'pipe'], }); @@ -451,10 +453,18 @@ const runOpenCode = async ( ...environment, OPENCODE_PERMISSION: JSON.stringify({ '*': 'deny' }), }, + signal: options.signal, stdio: ['ignore', 'pipe', 'pipe'], }, ); const abortController = new AbortController(); + const abortFromCaller = () => + abortController.abort(options.signal?.reason ?? new Error('Generation canceled.')); + if (options.signal?.aborted) { + abortFromCaller(); + } else { + options.signal?.addEventListener('abort', abortFromCaller, { once: true }); + } let baseUrl = ''; let sessionId = ''; let startupOutput = ''; @@ -608,6 +618,7 @@ const runOpenCode = async ( await eventStream; return normalizeStructuredOutput(output || streamedText, schema, 'OpenCode'); } finally { + options.signal?.removeEventListener('abort', abortFromCaller); clearTimeout(timeout); if (baseUrl && sessionId) { await fetch( diff --git a/electron/pi.cjs b/electron/pi.cjs index d061c2a3..86fb8d09 100644 --- a/electron/pi.cjs +++ b/electron/pi.cjs @@ -29,6 +29,7 @@ const PI_NOT_FOUND_MESSAGE = * model?: string; * onModelFallback?: (fallbackModel: string, originalModel: string) => Promise | void; * onPartialText?: (delta: string) => void; + * signal?: AbortSignal; * timeoutMs?: number; * }} PiOptions */ @@ -150,6 +151,7 @@ const runPi = async ( const child = commandTransport.spawn(commandTransport.command, piArgs, { cwd: repoRoot, env: environment, + signal: options.signal, stdio: ['pipe', 'pipe', 'pipe'], }); diff --git a/electron/preload.cjs b/electron/preload.cjs index 43eacf6d..2773a3fe 100644 --- a/electron/preload.cjs +++ b/electron/preload.cjs @@ -17,6 +17,9 @@ const codiff = { applyUpdate: () => ipcRenderer.invoke('codiff:applyUpdate'), askReviewAssistant: (request) => ipcRenderer.invoke('codiff:askReviewAssistant', request), dismissUpdate: () => ipcRenderer.invoke('codiff:dismissUpdate'), + cancelDiffContentRequest: (requestId) => + ipcRenderer.send('codiff:cancelDiffContentRequest', requestId), + cancelNarrativeWalkthrough: () => ipcRenderer.invoke('codiff:cancelNarrativeWalkthrough'), createWalkthroughCommit: (request) => ipcRenderer.invoke('codiff:createWalkthroughCommit', request), completePlan: (review, status) => ipcRenderer.invoke('codiff:completePlan', review, status), @@ -25,9 +28,7 @@ const codiff = { getAgentSkillStatus: () => ipcRenderer.invoke('codiff:getAgentSkillStatus'), getConfig: () => ipcRenderer.invoke('codiff:getConfig'), decreaseCodeFontSize: () => ipcRenderer.invoke('codiff:decreaseCodeFontSize'), - getDiffSectionContent: (request) => ipcRenderer.invoke('codiff:getDiffSectionContent', request), getFeatureFlags: () => ipcRenderer.invoke('codiff:getFeatureFlags'), - getDiffImageContent: (request) => ipcRenderer.invoke('codiff:getDiffImageContent', request), getGitIdentity: () => ipcRenderer.invoke('codiff:getGitIdentity'), getKeyboardLayout: () => ipcRenderer.invoke('codiff:getKeyboardLayout'), getLaunchOptions: () => ipcRenderer.invoke('codiff:getLaunchOptions'), @@ -37,6 +38,8 @@ const codiff = { getRepositoryHistory: (limit, source) => ipcRenderer.invoke('codiff:getRepositoryHistory', limit, source), getRepositoryState: (source) => ipcRenderer.invoke('codiff:getRepositoryState', source), + getReviewComments: (source, requestId) => + ipcRenderer.invoke('codiff:getReviewComments', source, requestId), getTerminalHelperStatus: () => ipcRenderer.invoke('codiff:getTerminalHelperStatus'), getUpdateStatus: () => ipcRenderer.invoke('codiff:getUpdateStatus'), getNarrativeWalkthrough: (source, options) => @@ -139,6 +142,8 @@ const codiff = { openFile: (path, lineNumber) => ipcRenderer.invoke('codiff:openFile', path, lineNumber), openRepositoryFolder: () => ipcRenderer.invoke('codiff:openRepositoryFolder'), resolvePullRequestUrl: (value) => ipcRenderer.invoke('codiff:resolvePullRequestUrl', value), + reportInitialLoadMilestone: (name) => ipcRenderer.send('codiff:initialLoadMilestone', name), + readRevisionContent: (request) => ipcRenderer.invoke('codiff:readRevisionContent', request), setDiffStyle: (value) => ipcRenderer.invoke('codiff:setDiffStyle', value), setShowOutdated: (value) => ipcRenderer.invoke('codiff:setShowOutdated', value), setWordWrap: (value) => ipcRenderer.invoke('codiff:setWordWrap', value), diff --git a/electron/repository-watcher.cjs b/electron/repository-watcher.cjs index 7162ec4d..5b6e52b9 100644 --- a/electron/repository-watcher.cjs +++ b/electron/repository-watcher.cjs @@ -9,6 +9,19 @@ const HIDDEN_POLL_INTERVAL = 30_000; const SELF_WRITE_CHECK_DELAY = 250; const VISIBLE_POLL_INTERVAL = 10_000; +/** + * @typedef {{ + * head: string; + * pathSignatures: Record; + * pathVersions: Record; + * root: string; + * signature: string; + * }} RepositoryWatcherSnapshot + */ + +/** @type {WeakMap>} */ +const initialRepositoryWatcherSnapshots = new WeakMap(); + /** @param {string} path @param {string} [pathSeparator] */ const normalizeRepositoryWatcherPath = (path, pathSeparator = sep) => pathSeparator === '\\' ? path.replaceAll('\\', '/') : path; @@ -105,43 +118,39 @@ const readRepositoryWatcherPathState = async (repoRoot, path, exact) => { }; /** - * Read a repository watcher snapshot with one Git process. `repoRoot` must - * already be the repository root. + * Build a repository watcher snapshot from a parsed porcelain-v2 status. + * `repoRoot` must already be the repository root. * * @param {string} repoRoot + * @param {{head: string; paths: Iterable}} status * @param {Iterable} [exactPaths] * @param {Iterable} [knownDirtyPaths] + * @returns {Promise} */ -const readRepositoryWatcherSnapshot = async (repoRoot, exactPaths = [], knownDirtyPaths = []) => { +const createRepositoryWatcherSnapshot = async ( + repoRoot, + status, + exactPaths = [], + knownDirtyPaths = [], +) => { const knownDirtyPathSet = new Set(knownDirtyPaths); - const statusArgs = ['status', '--porcelain=v2', '--branch', '-z', '-uall']; - // Git status hashes modified tracked files. Known dirty paths are monitored - // through metadata instead, while this command discovers all new changes. - if (knownDirtyPathSet.size > 0) { - statusArgs.push( - '--', - '.', - ...[...knownDirtyPathSet].map((path) => `:(exclude,literal)${path}`), - ); - } - const status = parseRepositoryWatcherStatus(await git(repoRoot, statusArgs)); const normalizedExactPaths = new Set( [...exactPaths].map((path) => normalizeRepositoryWatcherPath(path)), ); const statusPaths = new Set([...status.paths, ...knownDirtyPathSet]); const paths = new Set([...statusPaths, ...normalizedExactPaths]); const states = await Promise.all( - [...paths].map(async (path) => [ + [...paths].map(async (path) => ({ path, - await readRepositoryWatcherPathState(repoRoot, path, normalizedExactPaths.has(path)), - ]), + state: await readRepositoryWatcherPathState(repoRoot, path, normalizedExactPaths.has(path)), + })), ); /** @type {Record} */ const pathSignatures = {}; /** @type {Record} */ const pathVersions = {}; - for (const [path, state] of states) { + for (const { path, state } of states) { if (statusPaths.has(path)) { pathSignatures[path] = state.metadata; } @@ -164,10 +173,64 @@ const readRepositoryWatcherSnapshot = async (repoRoot, exactPaths = [], knownDir }; }; -/** @param {string} launchPath @param {Iterable} [exactPaths] */ -const readRepositoryChangeSignature = async (launchPath, exactPaths = []) => { - const repoRoot = (await git(launchPath, ['rev-parse', '--show-toplevel'])).trim(); - return readRepositoryWatcherSnapshot(repoRoot, exactPaths); +/** + * Read a repository watcher snapshot with one Git process. `repoRoot` must + * already be the repository root. + * + * @param {string} repoRoot + * @param {Iterable} [exactPaths] + * @param {Iterable} [knownDirtyPaths] + * @returns {Promise} + */ +const readRepositoryWatcherSnapshot = async (repoRoot, exactPaths = [], knownDirtyPaths = []) => { + const knownDirtyPathSet = new Set(knownDirtyPaths); + const statusArgs = ['status', '--porcelain=v2', '--branch', '-z', '-uall']; + // Git status hashes modified tracked files. Known dirty paths are monitored + // through metadata instead, while this command discovers all new changes. + if (knownDirtyPathSet.size > 0) { + statusArgs.push( + '--', + '.', + ...[...knownDirtyPathSet].map((path) => `:(exclude,literal)${path}`), + ); + } + return createRepositoryWatcherSnapshot( + repoRoot, + parseRepositoryWatcherStatus(await git(repoRoot, statusArgs)), + exactPaths, + knownDirtyPathSet, + ); +}; + +/** + * Keep the startup snapshot out of the serialized repository state while it + * moves through state composition. Attach a rejection handler immediately so + * callers that do not start a watcher do not create an unhandled rejection. + * + * @template {object} State + * @param {State} state + * @param {Promise | RepositoryWatcherSnapshot} snapshot + * @returns {State} + */ +const setRepositoryWatcherInitialSnapshot = (state, snapshot) => { + const promise = Promise.resolve(snapshot); + promise.catch(() => {}); + initialRepositoryWatcherSnapshots.set(state, promise); + return state; +}; + +/** @param {object} state */ +const getRepositoryWatcherInitialSnapshot = (state) => initialRepositoryWatcherSnapshots.get(state); + +/** + * @template {object} State + * @param {object} previousState + * @param {State} nextState + * @returns {State} + */ +const transferRepositoryWatcherInitialSnapshot = (previousState, nextState) => { + const snapshot = getRepositoryWatcherInitialSnapshot(previousState); + return snapshot ? setRepositoryWatcherInitialSnapshot(nextState, snapshot) : nextState; }; /** @@ -216,7 +279,7 @@ const getRepositoryWatcherPollInterval = (states) => { /** * @param {{ * clearTimeoutImpl?: typeof clearTimeout; - * readSnapshot: (root: string, exactPaths: Iterable, knownDirtyPaths: Iterable) => Promise<{head: string; pathSignatures: Record; pathVersions?: Record; root: string; signature: string}>; + * readSnapshot: (root: string, exactPaths: Iterable, knownDirtyPaths: Iterable) => Promise; * setTimeoutImpl?: typeof setTimeout; * }} options */ @@ -228,12 +291,14 @@ const createRepositoryWatcherCoordinator = ({ /** * @typedef {{ * checking: boolean; + * initialSnapshotFresh: boolean; * pendingSelfWrites: Map; + * initialSnapshot?: Promise; * recheckRequested: boolean; * resetRequested: Set; * root: string; - * snapshot?: Awaited>; - * subscribers: Map {focused: boolean; visible: boolean}; notify: (root: string) => void; snapshot?: Awaited>}>; + * snapshot?: RepositoryWatcherSnapshot; + * subscribers: Map {focused: boolean; visible: boolean}; notify: (root: string) => void; snapshot?: RepositoryWatcherSnapshot}>; * timer?: ReturnType; * }} RepositoryWatcher */ @@ -283,6 +348,42 @@ const createRepositoryWatcherCoordinator = ({ } }; + /** @param {RepositoryWatcher} watcher */ + const adoptInitialSnapshot = async (watcher) => { + const initialSnapshot = watcher.initialSnapshot; + if (!initialSnapshot) { + return false; + } + + try { + const snapshot = await initialSnapshot; + if ( + watchers.get(watcher.root) !== watcher || + watcher.subscribers.size === 0 || + watcher.snapshot != null || + snapshot.root !== watcher.root + ) { + return watcher.snapshot != null; + } + + watcher.snapshot = snapshot; + watcher.initialSnapshotFresh = true; + for (const subscriber of watcher.subscribers.values()) { + if (subscriber.snapshot == null) { + subscriber.changed = false; + subscriber.snapshot = snapshot; + } + } + return true; + } catch { + return false; + } finally { + if (watcher.initialSnapshot === initialSnapshot) { + watcher.initialSnapshot = undefined; + } + } + }; + /** * @param {RepositoryWatcher} watcher * @param {Iterable} [resetIds] @@ -292,6 +393,11 @@ const createRepositoryWatcherCoordinator = ({ return; } clearTimer(watcher); + const adoptedInitialSnapshot = + watcher.snapshot == null && (await adoptInitialSnapshot(watcher)); + if (watchers.get(watcher.root) !== watcher || watcher.subscribers.size === 0) { + return; + } if (watcher.checking) { watcher.recheckRequested = true; for (const id of resetIds) { @@ -301,7 +407,11 @@ const createRepositoryWatcherCoordinator = ({ } watcher.checking = true; - const requestedResetIds = new Set([...watcher.resetRequested, ...resetIds]); + watcher.initialSnapshotFresh = false; + const requestedResetIds = new Set([ + ...watcher.resetRequested, + ...(adoptedInitialSnapshot ? [] : resetIds), + ]); watcher.resetRequested.clear(); const pendingSelfWrites = new Map(watcher.pendingSelfWrites); try { @@ -411,6 +521,7 @@ const createRepositoryWatcherCoordinator = ({ * @param {{ * getState: () => {focused: boolean; visible: boolean}; * id: number; + * initialSnapshot?: Promise | RepositoryWatcherSnapshot; * notify: (root: string) => void; * root: string; * }} subscriber @@ -425,6 +536,7 @@ const createRepositoryWatcherCoordinator = ({ if (!watcher) { watcher = { checking: false, + initialSnapshotFresh: false, pendingSelfWrites: new Map(), recheckRequested: false, resetRequested: new Set(), @@ -433,6 +545,13 @@ const createRepositoryWatcherCoordinator = ({ }; watchers.set(subscriber.root, watcher); } + if ( + watcher.snapshot == null && + watcher.initialSnapshot == null && + subscriber.initialSnapshot + ) { + watcher.initialSnapshot = Promise.resolve(subscriber.initialSnapshot); + } watcher.subscribers.set(subscriber.id, { changed: false, getState: subscriber.getState, @@ -506,7 +625,11 @@ const createRepositoryWatcherCoordinator = ({ const root = subscriberRoots.get(id); const watcher = root ? watchers.get(root) : undefined; if (watcher?.subscribers.get(id)?.changed === false) { - schedule(watcher, 0); + if (watcher.initialSnapshotFresh) { + schedulePoll(watcher); + } else { + schedule(watcher, 0); + } } }, @@ -540,11 +663,14 @@ const createRepositoryWatcherCoordinator = ({ }; module.exports = { + createRepositoryWatcherSnapshot, createRepositoryWatcherCoordinator, + getRepositoryWatcherInitialSnapshot, getRepositoryWatcherPollInterval, normalizeRepositoryWatcherPath, parseRepositoryWatcherStatus, - readRepositoryChangeSignature, readRepositoryWatcherSnapshot, repositoryWatcherSnapshotsMatchExpectedWrites, + setRepositoryWatcherInitialSnapshot, + transferRepositoryWatcherInitialSnapshot, }; diff --git a/electron/review-content.cjs b/electron/review-content.cjs new file mode 100644 index 00000000..dbd0a9e4 --- /dev/null +++ b/electron/review-content.cjs @@ -0,0 +1,169 @@ +// @ts-check + +const { promises: fs } = require('node:fs'); +const { join } = require('node:path'); +const { + formatBytes, + getCurrentCommandSignal, + git, + gitBufferWithInput, + validateRepositoryPath, +} = require('./git-state/common.cjs'); +const { createCommitContentAdapter } = require('./git-state/provider-artifact-sources.cjs'); + +/** @typedef {import('../core/types.ts').RevisionContentBatchRequest} RevisionContentBatchRequest */ +/** @typedef {import('../core/types.ts').RevisionContentItemResult} RevisionContentItemResult */ +/** @typedef {import('../core/types.ts').ResolvedRevisionBytes} ResolvedRevisionBytes */ + +/** @param {unknown} error */ +const getErrorMessage = (error) => (error instanceof Error ? error.message : String(error)); + +/** @param {import('../core/types.ts').Revision} revision */ +const getRevisionKind = (revision) => revision.kind || 'commit'; + +/** @param {string} repoRoot @param {string} spec @param {number} maxBytes */ +const readGitSpec = async (repoRoot, spec, maxBytes) => { + let objectId; + let size; + try { + objectId = (await git(repoRoot, ['rev-parse', '--verify', spec])).trim(); + size = Number.parseInt((await git(repoRoot, ['cat-file', '-s', objectId])).trim(), 10); + } catch { + getCurrentCommandSignal()?.throwIfAborted(); + return null; + } + if (!Number.isFinite(size)) { + return null; + } + if (size > maxBytes) { + throw new Error( + `File is ${formatBytes(size)}, exceeding the ${formatBytes(maxBytes)} content limit.`, + ); + } + const bytes = await gitBufferWithInput(repoRoot, ['cat-file', 'blob', objectId], ''); + return { bytes, objectId, size }; +}; + +/** + * @param {string} repoRoot + * @param {RevisionContentBatchRequest['requests'][number]} request + * @returns {Promise} + */ +const readMutableRevision = async (repoRoot, request) => { + const path = validateRepositoryPath(request.path); + if (request.revision.kind === 'index') { + const stage = request.revision.stage; + const spec = stage ? `:${stage}:${path}` : `:${path}`; + const result = await readGitSpec(repoRoot, spec, request.maxBytes); + return result + ? { + bytes: result.bytes, + cacheKey: `index:${stage || 0}:${result.objectId}:${path}`, + objectId: result.objectId, + path, + provenance: /** @type {const} */ ('git-index'), + size: result.size, + } + : null; + } + + const absolutePath = join(repoRoot, path); + let stat; + try { + stat = await fs.lstat(absolutePath); + } catch { + getCurrentCommandSignal()?.throwIfAborted(); + return null; + } + if (stat.isDirectory()) { + throw new Error('Path is a directory, so Codiff cannot load it as file content.'); + } + const bytes = stat.isSymbolicLink() + ? Buffer.from(await fs.readlink(absolutePath), 'utf8') + : stat.isFile() + ? await fs.readFile(absolutePath) + : null; + if (!bytes) { + throw new Error('Path is not a regular file.'); + } + if (bytes.byteLength > request.maxBytes) { + throw new Error( + `File is ${formatBytes(bytes.byteLength)}, exceeding the ${formatBytes(request.maxBytes)} content limit.`, + ); + } + return { + bytes, + cacheKey: `working-copy:${request.key}:${bytes.byteLength}`, + path, + provenance: /** @type {const} */ ('filesystem'), + size: bytes.byteLength, + }; +}; + +/** + * Dispatch exact file reads only by Revision kind. Commit transport selection + * is captured once by the source-scoped adapter; callers never rediscover a + * source from section presentation metadata. + * @param {string} launchPath + * @param {RevisionContentBatchRequest} batch + * @returns {Promise} + */ +const readRevisionContent = async (launchPath, batch) => { + const repoRoot = (await git(launchPath, ['rev-parse', '--show-toplevel'])).trim(); + const signal = getCurrentCommandSignal(); + signal?.throwIfAborted(); + const requests = [...new Map(batch.requests.map((request) => [request.key, request])).values()]; + const commitRequests = requests.filter( + (request) => getRevisionKind(request.revision) === 'commit', + ); + const adapter = createCommitContentAdapter(repoRoot, batch.source); + const commitBlobs = + commitRequests.length === 0 + ? new Map() + : await adapter.readFileBlobs( + commitRequests.map((request) => ({ + maxBytes: request.maxBytes, + path: validateRepositoryPath(request.path), + ref: 'sha' in request.revision ? request.revision.sha : '', + ...(signal ? { signal } : {}), + })), + ); + signal?.throwIfAborted(); + + /** @type {Array>} */ + const reads = requests.map(async (request) => { + try { + const kind = getRevisionKind(request.revision); + const value = + kind === 'commit' + ? (() => { + const ref = 'sha' in request.revision ? request.revision.sha : ''; + const blob = commitBlobs.get(`${ref}:${request.path}`); + return blob + ? { + bytes: blob.bytes, + cacheKey: `${blob.provenance.kind}:${blob.objectId}:${request.path}`, + objectId: blob.objectId, + path: request.path, + provenance: blob.provenance.kind, + size: blob.bytes.byteLength, + } + : null; + })() + : await readMutableRevision(repoRoot, request); + return value + ? { key: request.key, status: /** @type {const} */ ('ready'), value } + : { key: request.key, status: /** @type {const} */ ('missing') }; + } catch (error) { + if (signal?.aborted) throw error; + return { + key: request.key, + reason: getErrorMessage(error), + status: /** @type {const} */ ('unavailable'), + }; + } + }); + return { results: await Promise.all(reads) }; +}; + +module.exports = { readRevisionContent }; diff --git a/electron/review-source.cjs b/electron/review-source.cjs index 0e51d270..6a123261 100644 --- a/electron/review-source.cjs +++ b/electron/review-source.cjs @@ -1,6 +1,6 @@ // @ts-check -const { execFileSync } = require('node:child_process'); +const { gitSync } = require('./git-state/common.cjs'); /** @typedef {'github' | 'gitlab'} ReviewProvider */ @@ -123,10 +123,8 @@ const parseRemoteUrl = (value) => { /** @param {string} repositoryPath */ const readReviewRemotes = (repositoryPath) => { - const root = execFileSync('git', ['-C', repositoryPath, 'rev-parse', '--show-toplevel'], { - encoding: 'utf8', - }).trim(); - const raw = execFileSync('git', ['-C', root, 'remote', '-v'], { encoding: 'utf8' }); + const root = gitSync(repositoryPath, ['rev-parse', '--show-toplevel']).trim(); + const raw = gitSync(root, ['remote', '-v']); const remotes = []; for (const line of raw.split('\n')) { const match = line.match(/^(\S+)\s+(\S+)\s+\((fetch|push)\)$/); diff --git a/electron/reviewed-diff-signature.cjs b/electron/reviewed-diff-signature.cjs new file mode 100644 index 00000000..ba1e26f0 --- /dev/null +++ b/electron/reviewed-diff-signature.cjs @@ -0,0 +1,36 @@ +// @ts-check + +const revisionIdentity = (range) => + range + ? `${range.base?.kind || (range.base ? 'commit' : 'absent')}:${range.base?.sha || ''}:${range.head?.kind || (range.head ? 'commit' : 'absent')}:${range.head?.sha || ''}` + : ''; + +/** + * Stable identity for the reviewed diff. Base-only range changes invalidate + * the signature independently of provider ordering. + * + * @param {ReadonlyArray} files + */ +const getReviewedDiffSignature = (files) => { + const input = [...files] + .sort((left, right) => { + const path = left.path.localeCompare(right.path); + return path || (left.oldPath || '').localeCompare(right.oldPath || ''); + }) + .flatMap((file) => [ + file.path, + file.oldPath || '', + file.status, + file.fingerprint, + ...[...file.sections].map((section) => revisionIdentity(section.range)).sort(), + ]) + .join('\0'); + let hash = 2_166_136_261; + for (let index = 0; index < input.length; index += 1) { + hash ^= input.charCodeAt(index); + hash = Math.imul(hash, 16_777_619); + } + return (hash >>> 0).toString(16); +}; + +module.exports = { getReviewedDiffSignature }; diff --git a/electron/shared-walkthrough-upload.cjs b/electron/shared-walkthrough-upload.cjs index da9196c8..1b478de4 100644 --- a/electron/shared-walkthrough-upload.cjs +++ b/electron/shared-walkthrough-upload.cjs @@ -4,6 +4,30 @@ const { trustSystemCertificates } = require('./system-certificates.cjs'); const poll = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); +const REDACTED_REPOSITORY_ROOT = '[redacted]'; + +const sanitizeSharedWalkthroughSnapshot = (snapshot) => { + if (!snapshot || typeof snapshot !== 'object' || snapshot.kind !== 'codiff-walkthrough-share') { + return snapshot; + } + const repository = + snapshot.repository && typeof snapshot.repository === 'object' + ? { ...snapshot.repository, root: REDACTED_REPOSITORY_ROOT } + : snapshot.repository; + let walkthrough = snapshot.walkthrough; + if (walkthrough && typeof walkthrough === 'object') { + const { context: _context, ...walkthroughWithoutContext } = walkthrough; + walkthrough = { + ...walkthroughWithoutContext, + repo: + walkthrough.repo && typeof walkthrough.repo === 'object' + ? { ...walkthrough.repo, root: REDACTED_REPOSITORY_ROOT } + : walkthrough.repo, + }; + } + return { ...snapshot, repository, walkthrough }; +}; + const CERTIFICATE_ERROR_CODES = new Set([ 'DEPTH_ZERO_SELF_SIGNED_CERT', 'SELF_SIGNED_CERT_IN_CHAIN', @@ -93,6 +117,7 @@ const uploadSharedSnapshot = async ({ uploader, }) => { const certificateTrust = trustCertificates(); + const safeSnapshot = sanitizeSharedWalkthroughSnapshot(snapshot); const baseUrl = serviceUrl.replace(/\/+$/, ''); await authenticate(); @@ -153,7 +178,7 @@ const uploadSharedSnapshot = async ({ } const uploadResponse = await fetchShareService('upload request', `${baseUrl}/api/uploads`, { - body: JSON.stringify(uploader ? { snapshot, uploader } : snapshot), + body: JSON.stringify(uploader ? { snapshot: safeSnapshot, uploader } : safeSnapshot), credentials: 'include', headers: { authorization: `Bearer ${uploadToken}`, @@ -171,6 +196,7 @@ const uploadSharedSnapshot = async ({ }; module.exports = { + sanitizeSharedWalkthroughSnapshot, uploadSharedSnapshot, uploadSharedWalkthrough: uploadSharedSnapshot, }; diff --git a/electron/walkthrough-commit.cjs b/electron/walkthrough-commit.cjs index 8063e6ba..16c5b8fd 100644 --- a/electron/walkthrough-commit.cjs +++ b/electron/walkthrough-commit.cjs @@ -9,6 +9,7 @@ const { accessSync, chmodSync, constants, mkdtempSync, rmSync, writeFileSync } = const { tmpdir } = require('node:os'); const { dirname, join } = require('node:path'); +const { startCommandTiming } = require('./command-log.cjs'); const { git, validateRepositoryPath } = require('./git-state/common.cjs'); // `node-pty` is a native addon and only a walkthrough commit needs it, so it is @@ -19,6 +20,7 @@ const loadPty = () => require('node-pty'); /** * @typedef {import('../core/types.ts').WalkthroughCommitRequest} WalkthroughCommitRequest * @typedef {import('../core/types.ts').WalkthroughCommitResult} WalkthroughCommitResult + * @typedef {import('../core/types.ts').GitSha} GitSha */ // Cols must match the xterm instance in @@ -78,10 +80,17 @@ const gitStreaming = (repoPath, args, onOutput) => new Promise((resolve, reject) => { const pty = loadPty(); ensureSpawnHelperIsExecutable(); + const commandArgs = ['-C', repoPath, ...args]; + const timing = startCommandTiming({ + args: commandArgs, + command: 'git', + cwd: repoPath, + details: { interactive: true }, + }); /** @type {import('node-pty').IPty} */ let child; try { - child = pty.spawn('git', ['-C', repoPath, ...args], { + child = pty.spawn('git', commandArgs, { cols: TERMINAL_COLS, cwd: repoPath, env: process.env, @@ -89,6 +98,7 @@ const gitStreaming = (repoPath, args, onOutput) => rows: TERMINAL_ROWS, }); } catch (error) { + timing.finish({ error }); reject(error instanceof Error ? error : new Error(String(error))); return; } @@ -99,10 +109,13 @@ const gitStreaming = (repoPath, args, onOutput) => }); child.onExit(({ exitCode }) => { if (exitCode === 0) { + timing.finish({ exitCode }); resolve(); } else { const output = normalizeTerminalOutput(combined).trim(); - reject(new Error(output || `git exited with status ${exitCode}`)); + const error = new Error(output || `git exited with status ${exitCode}`); + timing.finish({ error, exitCode }); + reject(error); } }); }); @@ -150,7 +163,7 @@ const createWalkthroughCommit = async (repoPath, request, onOutput) => { rmSync(tempDirectory, { force: true, recursive: true }); } const hash = (await git(repoPath, ['rev-parse', 'HEAD'])).trim(); - return { hash, status: 'committed' }; + return { sha: /** @type {GitSha} */ (hash), status: 'committed' }; } catch (error) { return { reason: error instanceof Error ? error.message : String(error), diff --git a/electron/walkthrough-generation-bridge.cjs b/electron/walkthrough-generation-bridge.cjs new file mode 100644 index 00000000..b982e208 --- /dev/null +++ b/electron/walkthrough-generation-bridge.cjs @@ -0,0 +1,27 @@ +// @ts-check + +/** CJS bridge to Core's format-neutral walkthrough task runner (ESM). */ + +const { join } = require('node:path'); +const { pathToFileURL } = require('node:url'); + +/** @type {Promise | null} */ +let modulePromise = null; + +const loadWalkthroughGeneration = async () => { + if (!modulePromise) { + const modulePath = join(__dirname, '../core/dist/walkthrough-generation.mjs'); + modulePromise = import(pathToFileURL(modulePath).href); + } + return modulePromise; +}; + +/** + * @param {Parameters[0]} input + */ +const runWalkthroughGenerationTasks = async (input) => { + const module = await loadWalkthroughGeneration(); + return module.runWalkthroughGenerationTasks(input); +}; + +module.exports = { loadWalkthroughGeneration, runWalkthroughGenerationTasks }; diff --git a/electron/walkthrough-generation-cache-key.cjs b/electron/walkthrough-generation-cache-key.cjs new file mode 100644 index 00000000..e0daa65c --- /dev/null +++ b/electron/walkthrough-generation-cache-key.cjs @@ -0,0 +1,47 @@ +// @ts-check + +const { createHash } = require('node:crypto'); +const { getReviewedDiffSignature } = require('./reviewed-diff-signature.cjs'); + +/** @param {unknown} value */ +const normalizeReviewText = (value) => { + if (typeof value !== 'string') { + return null; + } + return value.trim() || null; +}; + +/** + * @param {{ + * profile: import('../core/types.ts').GenerationProfile, + * request: unknown, + * state: import('../core/types.ts').RepositoryState, + * }} input + */ +const buildWalkthroughGenerationCacheIdentity = ({ profile, request, state }) => ({ + profile, + request, + reviewedDiffSignature: getReviewedDiffSignature(state.files), + source: + state.source.type === 'pull-request' + ? { + description: normalizeReviewText(state.source.description), + headSha: state.source.headSha ?? null, + number: state.source.number ?? null, + provider: state.source.provider ?? null, + projectPath: state.source.projectPath ?? null, + targetBranch: state.source.targetBranch ?? null, + title: normalizeReviewText(state.source.title), + url: state.source.url, + } + : state.source, + version: 1, +}); + +/** @param {Parameters[0]} input */ +const getWalkthroughGenerationCacheKey = (input) => + `walkthrough-generation:${createHash('sha256') + .update(JSON.stringify(buildWalkthroughGenerationCacheIdentity(input))) + .digest('hex')}`; + +module.exports = { buildWalkthroughGenerationCacheIdentity, getWalkthroughGenerationCacheKey }; diff --git a/electron/walkthrough-generation-coordinator.cjs b/electron/walkthrough-generation-coordinator.cjs new file mode 100644 index 00000000..3297e0a2 --- /dev/null +++ b/electron/walkthrough-generation-coordinator.cjs @@ -0,0 +1,67 @@ +// @ts-check + +const SUPERSEDED_GENERATION_REASON = 'A newer walkthrough generation request replaced this one.'; + +const createWalkthroughGenerationCoordinator = () => { + /** @type {Map} */ + const active = new Map(); + /** @type {Map}>} */ + const reusable = new Map(); + + /** @param {number} key @param {unknown} [reason] */ + const cancel = (key, reason = new Error('Walkthrough generation was canceled.')) => { + const controller = active.get(key); + if (!controller) { + return; + } + active.delete(key); + controller.abort(reason); + }; + + return { + /** @param {number} key */ + begin(key) { + active.get(key)?.abort(new Error(SUPERSEDED_GENERATION_REASON)); + const controller = new AbortController(); + active.set(key, controller); + return controller; + }, + + cancel, + + /** @param {number} key @param {unknown} [reason] */ + clear(key, reason = new Error('The walkthrough window was closed.')) { + cancel(key, reason); + reusable.delete(key); + }, + + /** @param {number} key @param {AbortController} controller */ + finish(key, controller) { + if (active.get(key) === controller) { + active.delete(key); + } + }, + + /** @param {number} key @param {string} cacheKey @param {boolean} [force] */ + getReusable(key, cacheKey, force = false) { + const entry = reusable.get(key); + return !force && entry?.cacheKey === cacheKey ? entry.components : undefined; + }, + + /** + * @param {number} key + * @param {AbortController} controller + * @param {string} cacheKey + * @param {ReadonlyArray} components + */ + retain(key, controller, cacheKey, components) { + if (active.get(key) !== controller) { + return false; + } + reusable.set(key, { cacheKey, components }); + return true; + }, + }; +}; + +module.exports = { createWalkthroughGenerationCoordinator, SUPERSEDED_GENERATION_REASON }; diff --git a/electron/walkthrough-model-invocation.cjs b/electron/walkthrough-model-invocation.cjs new file mode 100644 index 00000000..64b1f86a --- /dev/null +++ b/electron/walkthrough-model-invocation.cjs @@ -0,0 +1,98 @@ +// @ts-check + +/** @param {unknown} response */ +const parseStructuredModelResponse = (response) => { + const text = typeof response === 'string' ? response : String(response ?? ''); + const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/i); + const raw = (fenced?.[1] || text).trim(); + try { + return JSON.parse(raw); + } catch { + return raw; + } +}; + +/** @param {AbortSignal | undefined} signal @param {() => Promise} run */ +const runWithCancellation = async (signal, run) => { + if (!signal) { + return run(); + } + signal.throwIfAborted(); + /** @type {((reason?: unknown) => void) | null} */ + let rejectCancellation = null; + const cancelled = new Promise((_, reject) => { + rejectCancellation = reject; + }); + const onAbort = () => rejectCancellation?.(signal.reason ?? new Error('Generation cancelled.')); + signal.addEventListener('abort', onAbort, { once: true }); + try { + return await Promise.race([run(), cancelled]); + } finally { + signal.removeEventListener('abort', onAbort); + } +}; + +/** + * Invoke one configured model profile while recording the actual fallback that + * produced the accepted response. The caller owns schema-specific parsing. + * + * @param {{ + * agent: ReturnType, + * agentOptions?: Parameters['run']>[5], + * generatedAt?: () => string, + * outputName?: string, + * profile: import('../core/types.ts').GenerationProfile, + * prompt: string, + * repoRoot: string, + * schema: unknown, + * signal?: AbortSignal, + * timeoutMessage?: string, + * timeoutMs?: number, + * }} input + */ +const invokeWalkthroughModel = async (input) => { + if (input.profile.agent !== input.agent.id) { + throw new Error('The generation profile does not match the selected agent.'); + } + const candidates = [ + ...new Set(input.profile.modelCandidates.map((model) => input.agent.normalizeModel(model))), + ]; + if (candidates.length === 0) { + throw new Error('A generation profile requires at least one model candidate.'); + } + let generatedModel = candidates[0]; + const onModelFallback = input.agentOptions?.onModelFallback; + const response = await runWithCancellation(input.signal, () => + input.agent.run( + input.repoRoot, + input.prompt, + input.schema, + input.outputName, + input.timeoutMessage, + { + ...input.agentOptions, + fallbackModel: candidates.at(-1), + model: candidates[0], + onModelFallback: async (fallbackModel, originalModel) => { + input.signal?.throwIfAborted(); + generatedModel = input.agent.normalizeModel(fallbackModel); + await onModelFallback?.(fallbackModel, originalModel); + }, + signal: input.signal, + timeoutMs: input.timeoutMs ?? input.agent.defaultTimeoutMs, + }, + ), + ); + input.signal?.throwIfAborted(); + return { + generationMetadata: { + agent: input.agent.id, + generatedAt: input.generatedAt?.() ?? new Date().toISOString(), + model: generatedModel, + profile: input.profile, + }, + response, + }; +}; + +module.exports = { invokeWalkthroughModel, parseStructuredModelResponse }; diff --git a/electron/walkthrough-progress.cjs b/electron/walkthrough-progress.cjs index dc0f0457..565751c5 100644 --- a/electron/walkthrough-progress.cjs +++ b/electron/walkthrough-progress.cjs @@ -8,14 +8,16 @@ * @param {() => boolean} [isCurrent] */ const createWalkthroughProgressReporter = (webContents, isCurrent = () => true) => { - /** @param {import('../core/types.ts').WalkthroughProgressPhase} phase */ - return (phase) => { + /** + * @param {import('../core/types.ts').WalkthroughGenerationProgress | import('../core/types.ts').WalkthroughProgressPhase} update + */ + return (update) => { if (webContents.isDestroyed() || !isCurrent()) { return; } /** @type {import('../core/types.ts').WalkthroughProgressEvent} */ - const progress = { phase }; + const progress = typeof update === 'string' ? { phase: update } : { generation: update }; webContents.send('codiff:walkthroughProgress', progress); }; }; diff --git a/electron/window-identity.cjs b/electron/window-identity.cjs index 6c0eb03c..35086fc9 100644 --- a/electron/window-identity.cjs +++ b/electron/window-identity.cjs @@ -1,15 +1,18 @@ // @ts-check -const { execFileSync } = require('node:child_process'); const { realpathSync } = require('node:fs'); const { dirname, resolve } = require('node:path'); -const { parseReviewUrl } = require('./review-source.cjs'); +const { + decodeReviewSource, + decodeResolvedReviewSource, + formatReviewSourceIdentity, +} = require('../core/lib/review-source-codec.cjs'); +const { gitSync } = require('./git-state/common.cjs'); /** * @typedef {import('../core/types.ts').ReviewSource} ReviewSource * @typedef {import('../core/types.ts').CodiffLaunchOptions} CodiffLaunchOptions * @typedef {{key: string; repositoryRoot: string; sourceKey: string}} WindowIdentity - * @typedef {{number: number; owner: string; repo: string}} ParsedPullRequest */ /** @param {string} path */ @@ -26,11 +29,7 @@ const resolveRepositoryRoot = (repositoryPath) => { const resolvedPath = resolve(repositoryPath); try { - return getRealPath( - execFileSync('git', ['-C', resolvedPath, 'rev-parse', '--show-toplevel'], { - encoding: 'utf8', - }).trim(), - ); + return getRealPath(gitSync(resolvedPath, ['rev-parse', '--show-toplevel']).trim()); } catch { return getRealPath(resolvedPath); } @@ -39,9 +38,7 @@ const resolveRepositoryRoot = (repositoryPath) => { /** @param {string} repositoryRoot @param {string} ref */ const resolveCommitRef = (repositoryRoot, ref) => { try { - return execFileSync('git', ['-C', repositoryRoot, 'rev-parse', '--verify', `${ref}^{commit}`], { - encoding: 'utf8', - }) + return gitSync(repositoryRoot, ['rev-parse', '--verify', `${ref}^{commit}`]) .trim() .toLowerCase(); } catch { @@ -53,11 +50,7 @@ const resolveCommitRef = (repositoryRoot, ref) => { const hasWorkingTreeChanges = (repositoryRoot) => { try { return Boolean( - execFileSync( - 'git', - ['-C', repositoryRoot, 'status', '--porcelain=v1', '-z', '--untracked-files=normal'], - { encoding: 'utf8' }, - ), + gitSync(repositoryRoot, ['status', '--porcelain=v1', '-z', '--untracked-files=normal']), ); } catch { return false; @@ -67,113 +60,78 @@ const hasWorkingTreeChanges = (repositoryRoot) => { /** @param {string} repositoryRoot @param {string} baseRef @param {string} headRef */ const resolveMergeBase = (repositoryRoot, baseRef, headRef) => { try { - return execFileSync('git', ['-C', repositoryRoot, 'merge-base', baseRef, headRef], { - encoding: 'utf8', - }) - .trim() - .toLowerCase(); + return gitSync(repositoryRoot, ['merge-base', baseRef, headRef]).trim().toLowerCase(); } catch { return null; } }; -/** @param {Extract} source */ -const getPullRequestSourceKey = (source) => { - const review = parseReviewUrl(source.url); - if (review?.provider === 'gitlab') { - return `pull-request:gitlab:${review.host}/${review.projectPath.toLowerCase()}#${ - review.number - }`; - } - const pullRequest = - source.owner && source.repo && source.number - ? { - number: source.number, - owner: source.owner, - repo: source.repo, - } - : review?.provider === 'github' - ? /** @type {ParsedPullRequest} */ ({ - number: review.number, - owner: review.owner, - repo: review.repo, - }) - : null; - - return pullRequest - ? `pull-request:${pullRequest.owner.toLowerCase()}/${pullRequest.repo.toLowerCase()}#${ - pullRequest.number - }` - : null; -}; - /** @param {string} repositoryRoot @param {ReviewSource} [source] */ const getSourceKey = (repositoryRoot, source = { type: 'working-tree' }) => { - if (source.type === 'working-tree') { - return 'working-tree'; - } - if (source.type === 'commit') { const commit = resolveCommitRef(repositoryRoot, source.ref); - return commit ? `commit:${commit}` : null; + return commit + ? formatReviewSourceIdentity({ sha: commit, type: /** @type {const} */ ('commit') }) + : null; } if (source.type === 'branch') { const head = resolveCommitRef(repositoryRoot, 'HEAD'); const target = resolveCommitRef(repositoryRoot, source.ref); const nextBase = target && head ? resolveMergeBase(repositoryRoot, target, head) : null; - return nextBase && head ? `branch-diff:${source.ref}:${nextBase}:${head}` : null; + return nextBase && head + ? formatReviewSourceIdentity({ + baseSha: nextBase, + headSha: head, + ref: source.ref, + type: /** @type {const} */ ('branch-diff'), + }) + : null; } if (source.type === 'branch-diff') { - const base = resolveCommitRef(repositoryRoot, source.baseRef); - const head = resolveCommitRef(repositoryRoot, source.headRef); - return base && head ? `branch-diff:${source.ref}:${base}:${head}` : null; + const base = resolveCommitRef(repositoryRoot, source.baseSha); + const head = resolveCommitRef(repositoryRoot, source.headSha); + return base && head + ? formatReviewSourceIdentity({ ...source, baseSha: base, headSha: head }) + : null; } if (source.type === 'branch-working-tree') { if ( - typeof source.baseRef === 'string' && - typeof source.headRef === 'string' && - source.baseRef && - source.headRef + typeof source.baseSha === 'string' && + typeof source.headSha === 'string' && + source.baseSha && + source.headSha ) { - const base = resolveCommitRef(repositoryRoot, source.baseRef); - const head = resolveCommitRef(repositoryRoot, source.headRef); - return base && head ? `branch-working-tree:${source.ref}:${base}:${head}` : null; + const base = resolveCommitRef(repositoryRoot, source.baseSha); + const head = resolveCommitRef(repositoryRoot, source.headSha); + return base && head + ? formatReviewSourceIdentity({ ...source, baseSha: base, headSha: head }) + : null; } const head = resolveCommitRef(repositoryRoot, 'HEAD'); const target = resolveCommitRef(repositoryRoot, source.ref); const nextBase = target && head ? resolveMergeBase(repositoryRoot, target, head) : null; - return nextBase && head ? `branch-working-tree:${source.ref}:${nextBase}:${head}` : null; - } - - if (source.type === 'pull-request') { - return getPullRequestSourceKey(source); + return nextBase && head + ? formatReviewSourceIdentity({ + baseSha: nextBase, + headSha: head, + ref: source.ref, + type: /** @type {const} */ ('branch-working-tree'), + }) + : null; } - return null; + const decoded = decodeReviewSource(source); + return decoded ? formatReviewSourceIdentity(decoded) : null; }; -/** @param {ReviewSource} source */ +/** @param {import('../core/types.ts').ResolvedReviewSource} source */ const getResolvedSourceKey = (source) => { - if (source.type === 'working-tree') { - return 'working-tree'; - } - if (source.type === 'commit') { - return `commit:${source.ref.toLowerCase()}`; - } - if (source.type === 'branch-diff') { - return `branch-diff:${source.ref}:${source.baseRef.toLowerCase()}:${source.headRef.toLowerCase()}`; - } - if (source.type === 'branch-working-tree' && source.baseRef && source.headRef) { - return `branch-working-tree:${source.ref}:${source.baseRef.toLowerCase()}:${source.headRef.toLowerCase()}`; - } - if (source.type === 'pull-request') { - return getPullRequestSourceKey(source); - } - return null; + const resolved = decodeResolvedReviewSource(source); + return resolved ? formatReviewSourceIdentity(resolved) : null; }; /** @param {string} repositoryPath @param {Partial} [launchOptions] */ @@ -209,11 +167,7 @@ const getWindowIdentity = (repositoryPath, launchOptions = {}) => { : null; }; -/** @param {string} repositoryPath @param {ReviewSource} source */ -const getWindowIdentityForSource = (repositoryPath, source) => - getWindowIdentity(repositoryPath, { source }); - -/** @param {{root: string; source: ReviewSource}} state */ +/** @param {{root: string; source: import('../core/types.ts').ResolvedReviewSource}} state */ const getWindowIdentityForRepositoryState = (state) => { const repositoryRoot = getRealPath(state.root); const sourceKey = getResolvedSourceKey(state.source); @@ -226,6 +180,31 @@ const getWindowIdentityForRepositoryState = (state) => { : null; }; +/** + * Retarget one independent viewport after it resolves a new review source. + * Existing viewports are intentionally left untouched, even when this creates + * multiple viewports with the same working-tree identity. + * + * @param {number} webContentsId + * @param {{root: string; source: import('../core/types.ts').ResolvedReviewSource}} state + * @param {{identities: Map, launchOptions: Map, repositories: Map}} stores + */ +const storeResolvedWindowState = (webContentsId, state, stores) => { + stores.repositories.set(webContentsId, state.root); + const launchOptions = stores.launchOptions.get(webContentsId); + if (launchOptions) { + stores.launchOptions.set(webContentsId, { + ...launchOptions, + source: state.source, + }); + } + const identity = getWindowIdentityForRepositoryState(state); + if (identity) { + stores.identities.set(webContentsId, identity); + } + return identity; +}; + /** * @param {WindowIdentity | null} identity * @param {ReadonlyMap} existingIdentities @@ -248,5 +227,5 @@ module.exports = { findMatchingWindowIdentity, getWindowIdentity, getWindowIdentityForRepositoryState, - getWindowIdentityForSource, + storeResolvedWindowState, }; diff --git a/forge.config.cjs b/forge.config.cjs index 0b6cbc53..1b911d82 100644 --- a/forge.config.cjs +++ b/forge.config.cjs @@ -1,7 +1,7 @@ // @ts-check /* eslint-disable @typescript-eslint/no-require-imports, no-undef */ -const { copyFile, mkdir } = require('node:fs/promises'); +const { cp, mkdir } = require('node:fs/promises'); const { existsSync } = require('node:fs'); const { dirname, join } = require('node:path'); @@ -16,7 +16,12 @@ const macAssetCatalogPath = existsSync(join(__dirname, 'electron/icons/Assets.ca : undefined; const linuxIconPath = './electron/icons/icon.png'; const windowsIconPath = './electron/icons/icon.ico'; -const walkthroughDiffRuntimePath = 'core/lib/narrative-walkthrough-diff.cjs'; +const runtimeCopies = [ + ['core/dist', 'core/dist'], + ['core/lib/narrative-walkthrough-diff.cjs', 'core/lib/narrative-walkthrough-diff.cjs'], + ['github/dist', 'github/dist'], + ['gitlab/dist', 'gitlab/dist'], +]; const skipSquirrel = process.env.CODIFF_SKIP_SQUIRREL === '1'; const osxNotarize = process.env.APPLE_ID && process.env.APPLE_PASSWORD && process.env.APPLE_TEAM_ID @@ -53,11 +58,12 @@ const osxNotarize = module.exports = { hooks: { packageAfterCopy: async (_forgeConfig, buildPath) => { - const source = join(__dirname, walkthroughDiffRuntimePath); - const destination = join(buildPath, walkthroughDiffRuntimePath); - - await mkdir(dirname(destination), { recursive: true }); - await copyFile(source, destination); + for (const [sourcePath, destinationPath] of runtimeCopies) { + const source = join(__dirname, sourcePath); + const destination = join(buildPath, destinationPath); + await mkdir(dirname(destination), { recursive: true }); + await cp(source, destination, { recursive: true }); + } }, prePackage: async (forgeConfig, platform) => { if (platform !== 'darwin' || !macAssetCatalogPath) { @@ -122,22 +128,37 @@ module.exports = { /^\/\.enum_manifest\.json$/, /^\/\.env(?:$|[.])/, /^\/\.git(?:$|\/)/, + /^\/\.jj(?:$|\/)/, /^\/\.gitignore$/, /^\/\.github(?:$|\/)/, /^\/\.vite-hooks(?:$|\/)/, /^\/\.vscode(?:$|\/)/, + /^\/AGENTS\.md$/, + /^\/CONTRIBUTING\.md$/, /^\/README\.md$/, /^\/coverage(?:$|\/)/, + /^\/electron\/__tests__(?:$|\/)/, + /^\/electron\/.*\.test\.[cm]?[jt]sx?$/, + /^\/electron-squirrel-startup\.d\.ts$/, + /^\/evals(?:$|\/)/, /^\/docs(?:$|\/)/, /^\/examples(?:$|\/)/, /^\/forge\.config\.cjs$/, + /^\/github(?:$|\/)/, + /^\/gitlab(?:$|\/)/, /^\/index\.html$/, /^\/out(?:$|\/)/, /^\/pnpm-workspace\.yaml$/, /^\/public(?:$|\/)/, + /^\/scripts(?:$|\/)/, + /^\/service(?:$|\/)/, + /^\/test(?:$|\/)/, + /^\/test-scenarios(?:$|\/)/, /^\/core(?:$|\/)/, /^\/tsconfig/, /^\/vite\.config\./, + /^\/vitest\.cloudflare\.config\.ts$/, + /^\/web(?:$|\/)/, ], name: 'Codiff', ...(osxNotarize ? { osxNotarize } : {}), diff --git a/github/__tests__/current-review.test.ts b/github/__tests__/current-review.test.ts new file mode 100644 index 00000000..77362afa --- /dev/null +++ b/github/__tests__/current-review.test.ts @@ -0,0 +1,359 @@ +import { + createCommitArtifactRequestKey, + createFileBlobArtifactRequestKey, + createReviewArtifactRun, + type ReviewArtifactProject, +} from '@nkzw/codiff-core'; +import type { GitSha } from '@nkzw/codiff-core/types'; +import { expect, test } from 'vite-plus/test'; +import { createFakeGitHubTransport } from '../../test/fake-provider-transports.ts'; +import { createGitHubArtifactSource, createGitHubRangeArtifact } from '../src/current-review.ts'; + +const gitSha = (value: string) => value as GitSha; +const project: ReviewArtifactProject = { + host: 'github.com', + project: 'nkzw-tech/codiff', + provider: 'github', +}; +const pull = { number: 12, owner: 'nkzw-tech', repo: 'codiff' }; + +test('normalizes GitHub patches into immutable Range Artifacts', () => { + const range = createGitHubRangeArtifact({ + baseSha: gitSha('a'.repeat(40)), + files: [ + { + additions: 7, + deletions: 3, + filename: 'src/app.ts', + patch: '@@ -1 +1 @@\n-old\n+new\n', + status: 'modified', + }, + ], + headSha: gitSha('b'.repeat(40)), + project, + }); + + expect(range).toMatchObject({ + coverage: 'complete', + files: [ + { + coverage: 'complete', + lineCount: { additions: 7, deletions: 3 }, + patch: + 'diff --git a/src/app.ts b/src/app.ts\n--- a/src/app.ts\n+++ b/src/app.ts\n@@ -1 +1 @@\n-old\n+new\n', + }, + ], + }); +}); + +test('preserves exact zero GitHub counts when a patch is omitted', () => { + const range = createGitHubRangeArtifact({ + baseSha: gitSha('a'.repeat(40)), + files: [ + { + additions: 0, + deletions: 0, + filename: 'src/generated.txt', + sha: 'c'.repeat(40), + status: 'modified', + }, + ], + headSha: gitSha('b'.repeat(40)), + project, + }); + + expect(range.files[0]).toMatchObject({ + lineCount: { additions: 0, deletions: 0 }, + path: 'src/generated.txt', + }); +}); + +test('one GitHub source populates stack, range, commit, and blob caches', async () => { + const baseSha = gitSha('a'.repeat(40)); + const headSha = gitSha('b'.repeat(40)); + const objectId = 'c'.repeat(40); + const file = { + filename: 'src/app.ts', + patch: '@@ -1 +1 @@\n-old\n+new\n', + sha: objectId, + status: 'modified', + }; + const commit = { + commit: { + author: { date: '2026-01-01T00:00:00.000Z', name: 'Ada' }, + message: 'Update app', + }, + files: [file], + parents: [{ sha: baseSha }], + sha: headSha, + }; + const comparePath = `/repos/nkzw-tech/codiff/compare/${baseSha}...${headSha}`; + const commitPath = `/repos/nkzw-tech/codiff/commits/${headSha}`; + const blobPath = `/repos/nkzw-tech/codiff/git/blobs/${objectId}`; + const transport = createFakeGitHubTransport([ + { + path: comparePath, + response: { + commits: [commit], + files: [file], + merge_base_commit: { sha: baseSha }, + total_commits: 1, + }, + }, + { path: commitPath, response: commit }, + { bytes: new Uint8Array([0, 1, 255]), path: blobPath, response: null }, + ]); + const run = createReviewArtifactRun(createGitHubArtifactSource({ project, pull, transport })); + + const firstRange = await run.readStackAndRange( + { headSha, requestedBaseSha: baseSha }, + run.signal, + ); + expect(await run.readStackAndRange({ headSha, requestedBaseSha: baseSha }, run.signal)).toBe( + firstRange, + ); + const artifacts = await run.readCommitArtifacts( + [{ commitSha: headSha, parentSha: baseSha }], + run.signal, + ); + await run.readCommitArtifacts([{ commitSha: headSha, parentSha: baseSha }], run.signal); + const blobs = await run.readBlobs([objectId], run.signal); + await run.readBlobs([objectId], run.signal); + + expect(firstRange.stack.commits.map((entry) => entry.sha)).toEqual([headSha]); + expect(firstRange.range.files).toHaveLength(1); + expect( + artifacts.get(createCommitArtifactRequestKey({ commitSha: headSha, parentSha: baseSha })), + ).toMatchObject({ + parentSha: baseSha, + provenance: { kind: 'github-api', project }, + }); + expect(blobs.get(objectId)?.bytes).toEqual(new Uint8Array([0, 1, 255])); + expect(transport.calls.filter((call) => call.path === comparePath)).toHaveLength(1); + expect(transport.calls.filter((call) => call.path === commitPath)).toHaveLength(1); + expect(transport.calls.filter((call) => call.path === blobPath)).toHaveLength(1); + expect(run.diagnostics().sourceCalls).toEqual({ blobs: 1, commits: 1, stackAndRanges: 1 }); +}); + +test('uses GitHub merge_base_commit as the effective artifact base', async () => { + const requestedBaseSha = gitSha('a'.repeat(40)); + const effectiveBaseSha = gitSha('b'.repeat(40)); + const headSha = gitSha('c'.repeat(40)); + const transport = createFakeGitHubTransport([ + { + path: `/repos/nkzw-tech/codiff/compare/${requestedBaseSha}...${headSha}`, + response: { + commits: [ + { + commit: { + author: { date: '2026-01-01T00:00:00.000Z', name: 'Ada' }, + message: 'Update app', + }, + parents: [{ sha: effectiveBaseSha }], + sha: headSha, + }, + ], + files: [], + merge_base_commit: { sha: effectiveBaseSha }, + total_commits: 1, + }, + }, + ]); + const run = createReviewArtifactRun(createGitHubArtifactSource({ project, pull, transport })); + + const result = await run.readStackAndRange({ headSha, requestedBaseSha }, run.signal); + + expect(result.range).toMatchObject({ baseSha: effectiveBaseSha, headSha }); + expect(result.stack).toMatchObject({ baseSha: effectiveBaseSha, headSha }); + expect(run.diagnostics().acquired.stackAndRanges).toEqual({ + [`${requestedBaseSha}:${headSha}`]: 1, + }); +}); + +test('resolves GitHub ref paths as bounded Blob Artifacts', async () => { + const ref = gitSha('a'.repeat(40)); + const objectId = gitSha('b'.repeat(40)); + const request = { maxBytes: 32, path: 'images/logo.png', ref }; + const path = '/repos/nkzw-tech/codiff/contents/images/logo.png'; + const transport = createFakeGitHubTransport([ + { + path, + query: { ref }, + response: { + content: btoa(String.fromCharCode(0, 1, 255)), + encoding: 'base64', + sha: objectId, + }, + }, + ]); + const run = createReviewArtifactRun(createGitHubArtifactSource({ project, pull, transport })); + + const first = await run.readFileBlobs([request], run.signal); + const warm = await run.readFileBlobs([request], run.signal); + + expect(first.get(createFileBlobArtifactRequestKey(request))).toMatchObject({ + bytes: new Uint8Array([0, 1, 255]), + objectId, + provenance: { kind: 'github-api', project }, + }); + expect(warm).toEqual(first); + expect(transport.calls).toHaveLength(1); +}); + +test('caps current GitHub commit stacks at forty', async () => { + const baseSha = gitSha('f'.repeat(40)); + const commits = Array.from({ length: 41 }, (_, index) => { + const sha = gitSha(index.toString(16).padStart(40, '0')); + const parent = index === 0 ? baseSha : gitSha((index - 1).toString(16).padStart(40, '0')); + return { + commit: { + author: { date: new Date(index * 1000).toISOString(), name: 'Ada' }, + message: `Commit ${index}`, + }, + parents: [{ sha: parent }], + sha, + }; + }); + const headSha = commits.at(-1)!.sha; + const transport = createFakeGitHubTransport([ + { + path: `/repos/nkzw-tech/codiff/compare/${baseSha}...${headSha}`, + response: { + commits, + files: [], + merge_base_commit: { sha: baseSha }, + total_commits: commits.length, + }, + }, + ]); + + const result = await createGitHubArtifactSource({ project, pull, transport }).readStackAndRange( + { headSha, requestedBaseSha: baseSha }, + new AbortController().signal, + ); + + expect(result.stack.commits).toHaveLength(40); + expect(result.stack.commits[0]?.sha).toBe(commits[1]?.sha); + expect(result.stack.coverage).toBe('truncated'); +}); + +test('retains commits 81 through 120 from a paginated GitHub comparison', async () => { + const baseSha = gitSha('f'.repeat(40)); + const commits = Array.from({ length: 120 }, (_, index) => { + const sha = gitSha((index + 1).toString(16).padStart(40, '0')); + const parent = index === 0 ? baseSha : gitSha(index.toString(16).padStart(40, '0')); + return { + commit: { + author: { date: new Date(index * 1000).toISOString(), name: 'Ada' }, + message: `Commit ${index + 1}`, + }, + parents: [{ sha: parent }], + sha, + }; + }); + const headSha = commits.at(-1)!.sha; + const comparePath = `/repos/nkzw-tech/codiff/compare/${baseSha}...${headSha}`; + const transport = createFakeGitHubTransport([ + { + path: comparePath, + response: ({ query }: { query?: Readonly> }) => ({ + commits: query?.page === 2 ? commits.slice(100) : commits.slice(0, 100), + files: [], + merge_base_commit: { sha: baseSha }, + total_commits: commits.length, + }), + }, + ]); + + const result = await createGitHubArtifactSource({ project, pull, transport }).readStackAndRange( + { headSha, requestedBaseSha: baseSha }, + new AbortController().signal, + ); + + expect(result.stack.commits.map((commit) => commit.sha)).toEqual( + commits.slice(80).map((commit) => commit.sha), + ); + expect(transport.calls.map((call) => call.query?.page)).toEqual([1, 2]); + expect(result.stack.coverage).toBe('truncated'); +}); + +test('bounds GitHub Commit Artifact reads at eight concurrent requests', async () => { + const shas = Array.from({ length: 10 }, (_, index) => gitSha(index.toString().padStart(40, '0'))); + let active = 0; + let peak = 0; + const transport = createFakeGitHubTransport( + shas.map((sha, index) => ({ + path: `/repos/nkzw-tech/codiff/commits/${sha}`, + response: async () => { + active += 1; + peak = Math.max(peak, active); + await new Promise((resolve) => setTimeout(resolve, 5)); + active -= 1; + return { + files: [ + { + filename: `src/${index}.ts`, + patch: '@@ -1 +1 @@\n-old\n+new\n', + status: 'modified', + }, + ], + parents: [], + sha, + }; + }, + })), + ); + + const artifacts = await createGitHubArtifactSource({ + project, + pull, + transport, + }).readCommitArtifacts( + shas.map((commitSha) => ({ commitSha, parentSha: null })), + new AbortController().signal, + ); + + expect(artifacts).toHaveLength(10); + expect(peak).toBeGreaterThan(1); + expect(peak).toBeLessThanOrEqual(8); +}); + +test('preserves GitHub merge artifacts for two selected parents without calling mismatches complete', async () => { + const firstParent = gitSha('a'.repeat(40)); + const secondParent = gitSha('b'.repeat(40)); + const mergeSha = gitSha('c'.repeat(40)); + const path = `/repos/nkzw-tech/codiff/commits/${mergeSha}`; + const transport = createFakeGitHubTransport([ + { + path, + response: { + files: [ + { + filename: 'src/merge.ts', + patch: '@@ -1 +1 @@\n-old\n+new\n', + status: 'modified', + }, + ], + parents: [{ sha: firstParent }, { sha: secondParent }], + sha: mergeSha, + }, + }, + ]); + const requests = [ + { commitSha: mergeSha, parentSha: firstParent }, + { commitSha: mergeSha, parentSha: secondParent }, + ]; + + const artifacts = await createGitHubArtifactSource({ + project, + pull, + transport, + }).readCommitArtifacts(requests, new AbortController().signal); + + expect(artifacts).toHaveLength(2); + expect(artifacts.get(createCommitArtifactRequestKey(requests[0]!))?.coverage).toBe('complete'); + expect(artifacts.get(createCommitArtifactRequestKey(requests[1]!))?.coverage).not.toBe( + 'complete', + ); + expect(transport.calls.filter((call) => call.path === path)).toHaveLength(2); +}); diff --git a/github/__tests__/package.test.ts b/github/__tests__/package.test.ts new file mode 100644 index 00000000..c6896065 --- /dev/null +++ b/github/__tests__/package.test.ts @@ -0,0 +1,33 @@ +import { execFileSync } from 'node:child_process'; +import { expect, test } from 'vite-plus/test'; +import corePackageJson from '../../core/package.json' with { type: 'json' }; +import gitlabPackageJson from '../../gitlab/package.json' with { type: 'json' }; + +const packedFiles = (directory: string) => { + const output = execFileSync( + 'pnpm', + ['--dir', directory, '--config.ignore-scripts=true', 'pack', '--dry-run', '--json'], + { encoding: 'utf8' }, + ); + const parsed = JSON.parse(output); + const entry = Array.isArray(parsed) ? parsed[0] : parsed; + return new Set(entry.files.map((file: { path: string }) => file.path)); +}; + +const sourceTargets = (manifest: { exports: Record> }) => + Object.values(manifest.exports).flatMap((entry) => { + const target = entry['@nkzw/codiff-source']; + return target ? [target.replace(/^\.\//, '')] : []; + }); + +test('packed provider packages contain every advertised source-condition target', () => { + const coreFiles = packedFiles('core'); + const gitlabFiles = packedFiles('gitlab'); + + for (const target of sourceTargets(corePackageJson)) { + expect(coreFiles.has(target), `Core package is missing ${target}`).toBe(true); + } + for (const target of sourceTargets(gitlabPackageJson)) { + expect(gitlabFiles.has(target), `GitLab package is missing ${target}`).toBe(true); + } +}); diff --git a/github/package.json b/github/package.json new file mode 100644 index 00000000..e3df56a0 --- /dev/null +++ b/github/package.json @@ -0,0 +1,36 @@ +{ + "name": "@nkzw/codiff-github", + "version": "0.1.0", + "description": "Host-injected GitHub transport for Codiff.", + "license": "MIT", + "author": { + "name": "Christoph Nakazawa", + "email": "christoph.pojer@gmail.com" + }, + "repository": { + "type": "git", + "url": "https://github.com/nkzw-tech/codiff.git", + "directory": "github" + }, + "files": [ + "dist", + "src" + ], + "type": "module", + "main": "./dist/index.mjs", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "@nkzw/codiff-source": "./src/index.ts", + "default": "./dist/index.mjs" + } + }, + "scripts": { + "build": "rm -rf dist && vp pack -d dist --target=node24 src/index.ts && vp exec tsc -p tsconfig.build.json", + "typecheck": "vp exec tsc --noEmit -p tsconfig.build.json" + }, + "dependencies": { + "@nkzw/codiff-core": "workspace:*" + } +} diff --git a/github/src/current-review.ts b/github/src/current-review.ts new file mode 100644 index 00000000..58c829ac --- /dev/null +++ b/github/src/current-review.ts @@ -0,0 +1,554 @@ +import { + createCommitArtifactRequestKey, + createFileBlobArtifactRequestKey, + orderReviewCommitStack, + validateCommitArtifact, + validateRangeArtifact, + validateStackSnapshot, + type ArtifactCoverage, + type ArtifactFile, + type BlobArtifact, + type CommitArtifact, + type CommitArtifactRequest, + type CommitArtifactRequestKey, + type FileBlobArtifactRequest, + type RangeArtifact, + type ReviewArtifactProject, + type ReviewArtifactSource, + type StackSnapshot, +} from '@nkzw/codiff-core'; +import type { GitSha, ReviewCommitSummary } from '@nkzw/codiff-core/types'; +import type { GitHubTransport } from './transport.ts'; + +export type GitHubPullRequestRef = { + /** Optional known head from the local source; provider metadata wins when available. */ + headSha?: GitSha | null; + number: number; + owner: string; + repo: string; + updatedAt?: string | null; +}; + +const artifactReadConcurrency = 8; +const currentCommitStackLimit = 40; +const maxArtifactResponseBytes = 8 * 1024 * 1024; +const maxBlobArtifactBytes = 8 * 1024 * 1024; + +const gitSha = (value: unknown): GitSha | null => + typeof value === 'string' && /^[0-9a-f]{40}$/i.test(value) ? (value as GitSha) : null; +const isRecord = (value: unknown): value is Record => + Boolean(value) && typeof value === 'object' && !Array.isArray(value); +const asArray = (value: unknown): ReadonlyArray => (Array.isArray(value) ? value : []); +const asString = (value: unknown) => (typeof value === 'string' ? value : ''); +const asNumber = (value: unknown) => + typeof value === 'number' && Number.isFinite(value) ? value : null; + +const asLineCount = (additions: unknown, deletions: unknown) => { + const added = asNumber(additions); + const deleted = asNumber(deletions); + return added != null && + Number.isInteger(added) && + added >= 0 && + deleted != null && + Number.isInteger(deleted) && + deleted >= 0 + ? { additions: added, deletions: deleted } + : null; +}; + +const countPatchLines = (patch: string) => { + let additions = 0; + let deletions = 0; + for (const line of patch.split('\n')) { + if (line.startsWith('+') && !line.startsWith('+++')) { + additions += 1; + } else if (line.startsWith('-') && !line.startsWith('---')) { + deletions += 1; + } + } + return { additions, deletions }; +}; + +const normalizeBlobArtifactMaxBytes = (value: number | undefined) => { + const maxBytes = value ?? maxBlobArtifactBytes; + if (!Number.isFinite(maxBytes) || maxBytes < 0) { + throw new RangeError('Blob Artifact byte limit must be a finite non-negative number.'); + } + return Math.floor(maxBytes); +}; + +const githubRepositoryPath = ({ owner, repo }: GitHubPullRequestRef) => + `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`; + +const githubComparePath = (pull: GitHubPullRequestRef, base: GitSha, head: GitSha) => + `${githubRepositoryPath(pull)}/compare/${encodeURIComponent(base)}...${encodeURIComponent(head)}`; + +const githubCommitPath = (pull: GitHubPullRequestRef, sha: GitSha) => + `${githubRepositoryPath(pull)}/commits/${encodeURIComponent(sha)}`; + +const githubBlobPath = (pull: GitHubPullRequestRef, objectId: string) => + `${githubRepositoryPath(pull)}/git/blobs/${encodeURIComponent(objectId)}`; + +const githubContentsPath = (pull: GitHubPullRequestRef, path: string) => + `${githubRepositoryPath(pull)}/contents/${path.split('/').map(encodeURIComponent).join('/')}`; + +const decodeBase64File = (content: string, maxBytes: number) => { + const normalized = content.replaceAll(/\s/g, ''); + if (normalized.length > Math.ceil(maxBytes / 3) * 4 + 4) { + return null; + } + try { + const decoded = atob(normalized); + if (decoded.length > maxBytes) { + return null; + } + const bytes = new Uint8Array(decoded.length); + for (let index = 0; index < decoded.length; index += 1) { + bytes[index] = decoded.charCodeAt(index); + } + return bytes; + } catch { + return null; + } +}; + +const quotePatchPath = (path: string) => + path.replaceAll('\\', String.raw`\\`).replaceAll('\n', String.raw`\n`); + +const createGitHubPatch = ({ + oldPath, + patchBody, + path, + status, +}: { + oldPath: string; + patchBody: string; + path: string; + status: ArtifactFile['status']; +}) => { + if (!patchBody.trim()) { + return ''; + } + if (patchBody.startsWith('diff --git ')) { + return patchBody.endsWith('\n') ? patchBody : `${patchBody}\n`; + } + const header = [ + `diff --git a/${quotePatchPath(oldPath)} b/${quotePatchPath(path)}`, + status === 'added' ? '--- /dev/null' : `--- a/${quotePatchPath(oldPath)}`, + status === 'deleted' ? '+++ /dev/null' : `+++ b/${quotePatchPath(path)}`, + ]; + return `${header.join('\n')}\n${patchBody}${patchBody.endsWith('\n') ? '' : '\n'}`; +}; + +const normalizeGitHubArtifactFile = (value: unknown): ArtifactFile | null => { + if (!isRecord(value)) { + return null; + } + const path = asString(value.filename); + if (!path) { + return null; + } + const providerStatus = asString(value.status); + const status = + providerStatus === 'added' + ? 'added' + : providerStatus === 'removed' + ? 'deleted' + : providerStatus === 'renamed' + ? 'renamed' + : 'modified'; + const oldPath = asString(value.previous_filename); + const patchBody = asString(value.patch); + const lineCount = + asLineCount(value.additions, value.deletions) ?? + (patchBody.trim() ? countPatchLines(patchBody) : null); + const objectId = asString(value.sha) || undefined; + const oldObjectId = status === 'deleted' ? objectId : undefined; + const newObjectId = status === 'deleted' ? undefined : objectId; + const coverage: ArtifactCoverage = + value.truncated === true + ? 'truncated' + : patchBody.trim() || oldObjectId || newObjectId + ? 'complete' + : 'opaque'; + return { + coverage, + ...(lineCount ? { lineCount } : {}), + ...(newObjectId ? { newObjectId } : {}), + ...(oldObjectId ? { oldObjectId } : {}), + ...(oldPath && oldPath !== path ? { oldPath } : {}), + ...(patchBody.trim() + ? { patch: createGitHubPatch({ oldPath: oldPath || path, patchBody, path, status }) } + : {}), + path, + status, + }; +}; + +const artifactCoverage = ( + files: ReadonlyArray, + expectedCount: number, + truncated = false, +): ArtifactCoverage => + truncated || files.length !== expectedCount || files.some((file) => file.coverage === 'truncated') + ? 'truncated' + : files.some((file) => file.coverage === 'opaque') + ? 'opaque' + : 'complete'; + +/** Normalize an acquired GitHub file response into the shared Range Artifact contract. */ +export const createGitHubRangeArtifact = ({ + baseSha, + files: values, + headSha, + incompleteReason, + project, + truncated = false, +}: { + baseSha: GitSha; + files: ReadonlyArray; + headSha: GitSha; + incompleteReason?: string; + project: ReviewArtifactProject; + truncated?: boolean; +}): RangeArtifact => { + const files = values + .map(normalizeGitHubArtifactFile) + .filter((file): file is ArtifactFile => file != null); + return validateRangeArtifact({ + baseSha, + coverage: artifactCoverage(files, values.length, truncated || incompleteReason != null), + files, + headSha, + ...(incompleteReason != null ? { incompleteReason } : {}), + provenance: { kind: 'github-api', project }, + }); +}; + +const normalizeGitHubArtifactCommit = (value: unknown): ReviewCommitSummary | null => { + if (!isRecord(value)) { + return null; + } + const sha = gitSha(value.sha); + if (!sha) { + return null; + } + const commit = isRecord(value.commit) ? value.commit : {}; + const author = isRecord(commit.author) ? commit.author : {}; + const topLevelAuthor = isRecord(value.author) ? value.author : {}; + const message = asString(commit.message); + const webUrl = asString(value.html_url); + return { + authoredAt: asString(author.date) || new Date(0).toISOString(), + authorName: asString(author.name) || asString(topLevelAuthor.login), + parentShas: asArray(value.parents) + .map((parent) => (isRecord(parent) ? gitSha(parent.sha) : null)) + .filter((parent): parent is GitSha => parent != null), + sha, + shortSha: sha.slice(0, 7), + subject: message.split('\n')[0] || sha.slice(0, 7), + ...(webUrl ? { webUrl } : {}), + }; +}; + +const readGitHubCommitArtifact = async ({ + commit, + project, + pull, + signal, + transport, +}: { + commit: CommitArtifactRequest; + project: ReviewArtifactProject; + pull: GitHubPullRequestRef; + signal: AbortSignal; + transport: GitHubTransport; +}) => { + const rawFiles: Array = []; + let parentMismatch = false; + let truncated = false; + for (let page = 1; page <= 30; page += 1) { + signal.throwIfAborted(); + const value = await transport.request({ + maxBytes: maxArtifactResponseBytes, + path: githubCommitPath(pull, commit.commitSha), + query: { page, per_page: 100 }, + signal, + }); + if (!isRecord(value)) { + truncated = true; + break; + } + if (page === 1) { + const firstParent = asArray(value.parents) + .map((parent) => (isRecord(parent) ? gitSha(parent.sha) : null)) + .find((parent): parent is GitSha => parent != null); + parentMismatch = (firstParent ?? null) !== commit.parentSha; + } + const pageFiles = asArray(value.files); + rawFiles.push(...pageFiles); + if (pageFiles.length < 100) { + break; + } + if (page === 30) { + truncated = true; + } + } + const files = rawFiles + .map(normalizeGitHubArtifactFile) + .filter((file): file is ArtifactFile => file != null); + return validateCommitArtifact({ + commitSha: commit.commitSha, + coverage: artifactCoverage(files, rawFiles.length, truncated || parentMismatch), + files, + parentSha: commit.parentSha, + provenance: { kind: 'github-api' as const, project }, + }); +}; + +/** Resolve one immutable GitHub ref+path coordinate to a normalized Blob Artifact. */ +export const readGitHubFileBlobArtifact = async ({ + maxBytes, + path, + project, + pull, + ref, + signal, + transport, +}: FileBlobArtifactRequest & { + maxBytes: number; + project: ReviewArtifactProject; + pull: GitHubPullRequestRef; + signal?: AbortSignal; + transport: GitHubTransport; +}): Promise => { + const limit = normalizeBlobArtifactMaxBytes(maxBytes); + signal?.throwIfAborted(); + const value = await transport.request({ + maxBytes: Math.ceil(limit / 3) * 4 + 64 * 1024, + path: githubContentsPath(pull, path), + query: { ref }, + signal, + }); + signal?.throwIfAborted(); + if (!isRecord(value)) { + return null; + } + const objectId = gitSha(value.sha); + if (!objectId) { + return null; + } + const content = asString(value.content); + const bytes = + asString(value.encoding).toLowerCase() === 'base64' && content + ? decodeBase64File(content, limit) + : null; + if (bytes) { + return { bytes, objectId, provenance: { kind: 'github-api', project } }; + } + if (!transport.requestBuffer) { + return null; + } + const raw = await transport.requestBuffer({ + accept: 'application/vnd.github.raw+json', + maxBytes: limit, + path: githubBlobPath(pull, objectId), + signal, + }); + signal?.throwIfAborted(); + return raw.byteLength <= limit + ? { bytes: raw, objectId, provenance: { kind: 'github-api', project } } + : null; +}; + +/** Create the bounded GitHub API source for one current pull request. */ +export const createGitHubArtifactSource = ({ + maxBlobArtifactBytes: requestedMaxBlobArtifactBytes, + project, + pull, + transport, +}: { + maxBlobArtifactBytes?: number; + project: ReviewArtifactProject; + pull: GitHubPullRequestRef; + transport: GitHubTransport; +}): ReviewArtifactSource => { + const blobArtifactMaxBytes = normalizeBlobArtifactMaxBytes(requestedMaxBlobArtifactBytes); + const provenance = { kind: 'github-api' as const, project }; + return { + async readBlobs(objectIds, signal) { + if (!transport.requestBuffer) { + return new Map(); + } + const pending = [...new Set(objectIds)]; + const blobs = new Map(); + let nextIndex = 0; + const worker = async () => { + while (nextIndex < pending.length) { + signal.throwIfAborted(); + const objectId = pending[nextIndex++]!; + try { + const bytes = await transport.requestBuffer!({ + accept: 'application/vnd.github.raw+json', + maxBytes: blobArtifactMaxBytes, + path: githubBlobPath(pull, objectId), + signal, + }); + if (bytes.byteLength <= blobArtifactMaxBytes) { + blobs.set(objectId, { bytes, objectId, provenance }); + } + } catch { + signal.throwIfAborted(); + } + } + }; + await Promise.all( + Array.from({ length: Math.min(artifactReadConcurrency, pending.length) }, worker), + ); + return blobs; + }, + async readCommitArtifacts(commits, signal) { + const pending = [ + ...new Map( + commits.map((commit) => [createCommitArtifactRequestKey(commit), commit]), + ).values(), + ]; + const artifacts = new Map(); + let nextIndex = 0; + const worker = async () => { + while (nextIndex < pending.length) { + signal.throwIfAborted(); + const commit = pending[nextIndex++]!; + try { + artifacts.set( + createCommitArtifactRequestKey(commit), + await readGitHubCommitArtifact({ commit, project, pull, signal, transport }), + ); + } catch { + signal.throwIfAborted(); + } + } + }; + await Promise.all( + Array.from({ length: Math.min(artifactReadConcurrency, pending.length) }, worker), + ); + return artifacts; + }, + async readFileBlobs(requests, signal) { + const pending = [ + ...new Map( + requests.map((request) => [createFileBlobArtifactRequestKey(request), request]), + ).values(), + ]; + const blobs = new Map(); + let nextIndex = 0; + const worker = async () => { + while (nextIndex < pending.length) { + signal.throwIfAborted(); + const request = pending[nextIndex++]!; + try { + const blob = await readGitHubFileBlobArtifact({ + ...request, + maxBytes: Math.min(request.maxBytes ?? blobArtifactMaxBytes, blobArtifactMaxBytes), + project, + pull, + signal, + transport, + }); + if (blob) { + blobs.set(createFileBlobArtifactRequestKey(request), blob); + } + } catch { + signal.throwIfAborted(); + } + } + }; + await Promise.all( + Array.from({ length: Math.min(artifactReadConcurrency, pending.length) }, worker), + ); + return blobs; + }, + async readStackAndRange({ headSha: head, requestedBaseSha: base }, signal) { + if (base === head) { + return { + range: validateRangeArtifact({ + baseSha: base, + coverage: 'complete', + files: [], + headSha: head, + provenance, + }), + stack: validateStackSnapshot({ + baseSha: base, + commits: [], + coverage: 'complete', + headSha: head, + provenance, + }), + }; + } + const path = githubComparePath(pull, base, head); + const first = await transport.request({ + maxBytes: maxArtifactResponseBytes, + path, + query: { page: 1, per_page: 100 }, + signal, + }); + if (!isRecord(first)) { + throw new Error('GitHub returned an invalid repository comparison.'); + } + const mergeBaseCommit = isRecord(first.merge_base_commit) ? first.merge_base_commit : {}; + const effectiveBaseSha = gitSha(mergeBaseCommit.sha); + if (!effectiveBaseSha) { + throw new Error('GitHub returned an invalid merge base for the repository comparison.'); + } + const totalCommits = asNumber(first.total_commits); + let rawCommits = [...asArray(first.commits)]; + let stackTruncated = totalCommits == null || totalCommits !== rawCommits.length; + if (totalCommits != null && totalCommits > rawCommits.length) { + const firstTailPage = + Math.floor(Math.max(0, totalCommits - currentCommitStackLimit) / 100) + 1; + const lastPage = Math.ceil(totalCommits / 100); + rawCommits = firstTailPage === 1 ? rawCommits : []; + for (let page = Math.max(2, firstTailPage); page <= lastPage; page += 1) { + signal.throwIfAborted(); + const tailPage = await transport.request({ + maxBytes: maxArtifactResponseBytes, + path, + query: { page, per_page: 100 }, + signal, + }); + if (!isRecord(tailPage)) { + throw new Error(`GitHub returned an invalid comparison page ${page}.`); + } + rawCommits.push(...asArray(tailPage.commits)); + } + } + if (rawCommits.length > currentCommitStackLimit) { + rawCommits = rawCommits.slice(-currentCommitStackLimit); + stackTruncated = true; + } + const commits = orderReviewCommitStack( + rawCommits + .map(normalizeGitHubArtifactCommit) + .filter((commit): commit is ReviewCommitSummary => commit != null), + ); + const rawFiles = asArray(first.files); + const range = createGitHubRangeArtifact({ + baseSha: effectiveBaseSha, + files: rawFiles, + headSha: head, + project, + truncated: rawFiles.length >= 300, + }); + const stack: StackSnapshot = { + baseSha: effectiveBaseSha, + commits, + coverage: stackTruncated || commits.length !== rawCommits.length ? 'truncated' : 'complete', + headSha: head, + provenance, + }; + return { range, stack: validateStackSnapshot(stack) }; + }, + }; +}; diff --git a/github/src/index.ts b/github/src/index.ts new file mode 100644 index 00000000..64a4a735 --- /dev/null +++ b/github/src/index.ts @@ -0,0 +1,2 @@ +export * from './current-review.ts'; +export * from './transport.ts'; diff --git a/github/src/transport.ts b/github/src/transport.ts new file mode 100644 index 00000000..b6e074ef --- /dev/null +++ b/github/src/transport.ts @@ -0,0 +1,48 @@ +/** + * Host-injected GitHub transport. + * + * The host authenticates and executes HTTP (gh api / fetch). This package owns + * endpoint construction, pagination policy, and response parsing. + */ +export type GitHubTransport = { + graphql?(request: { + /** Maximum response bytes the host may retain. */ + maxBytes?: number; + query: string; + signal?: AbortSignal; + variables: Readonly>; + }): Promise; + request(request: { + accept?: string; + body?: unknown; + /** Maximum response bytes the host may retain. */ + maxBytes?: number; + method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; + /** When true, hosts should follow pagination and return a combined array. */ + paginate?: boolean; + path: string; + query?: Readonly>; + signal?: AbortSignal; + }): Promise; + /** Optional binary response reader for images and other raw assets. */ + requestBuffer?(request: { + accept?: string; + /** Maximum raw response bytes the host may retain. */ + maxBytes?: number; + path: string; + query?: Readonly>; + signal?: AbortSignal; + }): Promise; + /** Optional raw text reader. */ + requestText?(request: { + accept?: string; + body?: unknown; + /** Maximum response bytes the host may retain. */ + maxBytes?: number; + method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; + paginate?: boolean; + path: string; + query?: Readonly>; + signal?: AbortSignal; + }): Promise; +}; diff --git a/github/tsconfig.build.json b/github/tsconfig.build.json new file mode 100644 index 00000000..bddab4a7 --- /dev/null +++ b/github/tsconfig.build.json @@ -0,0 +1,13 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "declaration": true, + "declarationMap": false, + "emitDeclarationOnly": true, + "incremental": false, + "noEmit": false, + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*.ts"] +} diff --git a/github/vite.config.ts b/github/vite.config.ts new file mode 100644 index 00000000..e965e0af --- /dev/null +++ b/github/vite.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'vite-plus'; + +export default defineConfig({ + pack: { + copy: [], + deps: { + alwaysBundle: ['@nkzw/codiff-core'], + dts: { neverBundle: ['@nkzw/codiff-core'] }, + }, + dts: false, + }, +}); diff --git a/gitlab/__tests__/current-review.test.ts b/gitlab/__tests__/current-review.test.ts new file mode 100644 index 00000000..ecea7923 --- /dev/null +++ b/gitlab/__tests__/current-review.test.ts @@ -0,0 +1,295 @@ +import { + createCommitArtifactRequestKey, + createFileBlobArtifactRequestKey, + createReviewArtifactRun, + type ReviewArtifactProject, +} from '@nkzw/codiff-core'; +import type { GitSha } from '@nkzw/codiff-core/types'; +import { expect, test } from 'vite-plus/test'; +import { createFakeGitLabTransport } from '../../test/fake-provider-transports.ts'; +import { + createGitLabArtifactSource, + createGitLabRangeArtifact, + fetchGitLabCommitArtifacts, +} from '../src/current-review.ts'; + +const gitSha = (value: string) => value as GitSha; +const project: ReviewArtifactProject = { + host: 'gitlab.example.com', + project: 'group/project', + provider: 'gitlab', +}; + +test('normalizes GitLab patches into immutable Range Artifacts', () => { + const range = createGitLabRangeArtifact({ + baseSha: gitSha('a'.repeat(40)), + diffs: [ + { + diff: '@@ -1 +1,2 @@\n-old\n+new\n+second\n', + new_path: 'src/app.ts', + old_path: 'src/app.ts', + }, + ], + headSha: gitSha('b'.repeat(40)), + project, + }); + + expect(range).toMatchObject({ + coverage: 'complete', + files: [ + { + coverage: 'complete', + lineCount: { additions: 2, deletions: 1 }, + patch: + 'diff --git a/src/app.ts b/src/app.ts\n--- a/src/app.ts\n+++ b/src/app.ts\n@@ -1 +1,2 @@\n-old\n+new\n+second\n', + }, + ], + }); +}); + +test('one GitLab source populates stack, range, commit, and blob caches', async () => { + const baseSha = gitSha('a'.repeat(40)); + const headSha = gitSha('b'.repeat(40)); + const objectId = 'c'.repeat(40); + const diff = { + diff: '@@ -1 +1 @@\n-old\n+new\n', + new_path: 'src/app.ts', + old_path: 'src/app.ts', + }; + const transport = createFakeGitLabTransport([ + { + path: '/api/v4/projects/group%2Fproject/repository/compare', + query: { from: baseSha, straight: 'true', to: headSha }, + response: { + commits: [ + { + authored_date: '2026-01-01T00:00:00.000Z', + id: headSha, + parent_ids: [baseSha], + title: 'Update app', + }, + ], + diffs: [diff], + }, + }, + { + bytes: new Uint8Array([0, 1, 255]), + path: `/api/v4/projects/group%2Fproject/repository/blobs/${objectId}/raw`, + response: null, + }, + ]); + const run = createReviewArtifactRun( + createGitLabArtifactSource({ project, projectPath: 'group/project', transport }), + ); + + const firstRange = await run.readStackAndRange( + { headSha, requestedBaseSha: baseSha }, + run.signal, + ); + expect(await run.readStackAndRange({ headSha, requestedBaseSha: baseSha }, run.signal)).toBe( + firstRange, + ); + const artifacts = await run.readCommitArtifacts( + [{ commitSha: headSha, parentSha: baseSha }], + run.signal, + ); + await run.readCommitArtifacts([{ commitSha: headSha, parentSha: baseSha }], run.signal); + const blobs = await run.readBlobs([objectId], run.signal); + await run.readBlobs([objectId], run.signal); + + expect(firstRange.stack.commits.map((entry) => entry.sha)).toEqual([headSha]); + expect(firstRange.range.files).toHaveLength(1); + expect( + artifacts.get(createCommitArtifactRequestKey({ commitSha: headSha, parentSha: baseSha })), + ).toMatchObject({ + parentSha: baseSha, + provenance: { kind: 'gitlab-api', project }, + }); + expect(blobs.get(objectId)?.bytes).toEqual(new Uint8Array([0, 1, 255])); + expect(run.diagnostics().sourceCalls).toEqual({ blobs: 1, commits: 1, stackAndRanges: 1 }); +}); + +test('resolves GitLab ref paths as bounded Blob Artifacts', async () => { + const ref = gitSha('a'.repeat(40)); + const objectId = gitSha('b'.repeat(40)); + const request = { maxBytes: 32, path: 'images/logo.png', ref }; + const transport = createFakeGitLabTransport([ + { + path: '/api/v4/projects/group%2Fproject/repository/files/images%2Flogo.png', + query: { ref }, + response: { + blob_id: objectId, + content: btoa(String.fromCharCode(0, 1, 255)), + encoding: 'base64', + }, + }, + ]); + const run = createReviewArtifactRun( + createGitLabArtifactSource({ project, projectPath: 'group/project', transport }), + ); + + const first = await run.readFileBlobs([request], run.signal); + const warm = await run.readFileBlobs([request], run.signal); + + expect(first.get(createFileBlobArtifactRequestKey(request))).toMatchObject({ + bytes: new Uint8Array([0, 1, 255]), + objectId, + provenance: { kind: 'gitlab-api', project }, + }); + expect(warm).toEqual(first); + expect(transport.calls).toHaveLength(1); +}); + +test('bounds GitLab Commit Artifact reads at eight concurrent requests', async () => { + const shas = Array.from({ length: 10 }, (_, index) => gitSha(index.toString().padStart(40, '0'))); + let active = 0; + let peak = 0; + const transport = createFakeGitLabTransport( + shas.map((sha, index) => ({ + path: `/api/v4/projects/group%2Fproject/repository/commits/${sha}/diff`, + response: async () => { + active += 1; + peak = Math.max(peak, active); + await new Promise((resolve) => setTimeout(resolve, 5)); + active -= 1; + return [ + { + diff: '@@ -1 +1 @@\n-old\n+new\n', + new_path: `src/${index}.ts`, + old_path: `src/${index}.ts`, + }, + ]; + }, + })), + ); + + const artifacts = await fetchGitLabCommitArtifacts({ + commits: shas.map((commitSha) => ({ commitSha, parentSha: null })), + project, + projectPath: 'group/project', + transport, + }); + + expect(artifacts).toHaveLength(10); + expect(peak).toBeGreaterThan(1); + expect(peak).toBeLessThanOrEqual(8); +}); + +test('preserves one GitLab merge commit relative to two selected parents', async () => { + const firstParent = gitSha('a'.repeat(40)); + const secondParent = gitSha('b'.repeat(40)); + const mergeSha = gitSha('c'.repeat(40)); + const comparePath = '/api/v4/projects/group%2Fproject/repository/compare'; + const transport = createFakeGitLabTransport([ + { + path: comparePath, + query: { from: firstParent, straight: 'true', to: mergeSha }, + response: { + diffs: [ + { + diff: '@@ -1 +1 @@\n-old\n+first\n', + new_path: 'src/first.ts', + old_path: 'src/first.ts', + }, + ], + }, + }, + { + path: comparePath, + query: { from: secondParent, straight: 'true', to: mergeSha }, + response: { + diffs: [ + { + diff: '@@ -1 +1 @@\n-old\n+second\n', + new_path: 'src/second.ts', + old_path: 'src/second.ts', + }, + ], + }, + }, + ]); + const requests = [ + { commitSha: mergeSha, parentSha: firstParent }, + { commitSha: mergeSha, parentSha: secondParent }, + ]; + + const artifacts = await fetchGitLabCommitArtifacts({ + commits: requests, + project, + projectPath: 'group/project', + transport, + }); + + expect(artifacts).toHaveLength(2); + expect(artifacts.get(createCommitArtifactRequestKey(requests[0]!))?.files[0]?.path).toBe( + 'src/first.ts', + ); + expect(artifacts.get(createCommitArtifactRequestKey(requests[1]!))?.files[0]?.path).toBe( + 'src/second.ts', + ); + expect(transport.calls.map((call) => call.query?.from)).toEqual([firstParent, secondParent]); +}); + +test.each(['compare_timeout', 'overflow'] as const)( + 'does not mark GitLab %s comparison evidence complete', + async (flag) => { + const parentSha = gitSha('a'.repeat(40)); + const commitSha = gitSha('b'.repeat(40)); + const transport = createFakeGitLabTransport([ + { + path: '/api/v4/projects/group%2Fproject/repository/compare', + query: { from: parentSha, straight: 'true', to: commitSha }, + response: { + diffs: [ + { + diff: '@@ -1 +1 @@\n-old\n+new\n', + new_path: 'src/app.ts', + old_path: 'src/app.ts', + }, + ], + [flag]: true, + }, + }, + ]); + const request = { commitSha, parentSha }; + + const artifacts = await fetchGitLabCommitArtifacts({ + commits: [request], + project, + projectPath: 'group/project', + transport, + }); + + expect(artifacts.get(createCommitArtifactRequestKey(request))?.coverage).toBe('truncated'); + }, +); + +test('uses the GitLab commit-diff endpoint only for root commits', async () => { + const commitSha = gitSha('d'.repeat(40)); + const path = `/api/v4/projects/group%2Fproject/repository/commits/${commitSha}/diff`; + const transport = createFakeGitLabTransport([ + { + path, + response: [ + { + diff: '@@ -1 +1 @@\n-old\n+root\n', + new_path: 'src/root.ts', + old_path: 'src/root.ts', + }, + ], + }, + ]); + const request = { commitSha, parentSha: null }; + + const artifacts = await fetchGitLabCommitArtifacts({ + commits: [request], + project, + projectPath: 'group/project', + transport, + }); + + expect(artifacts.get(createCommitArtifactRequestKey(request))?.files[0]?.path).toBe( + 'src/root.ts', + ); + expect(transport.calls[0]?.path).toBe(path); +}); diff --git a/gitlab/__tests__/transport.test.ts b/gitlab/__tests__/transport.test.ts new file mode 100644 index 00000000..09547c13 --- /dev/null +++ b/gitlab/__tests__/transport.test.ts @@ -0,0 +1,103 @@ +import { expect, test } from 'vite-plus/test'; +import { createFakeGitLabTransport } from '../../test/fake-provider-transports.ts'; + +test('routes host-injected requests by method, path, and normalized query', async () => { + const transport = createFakeGitLabTransport([ + { + method: 'POST', + path: '/api/v4/projects/example/merge_requests', + query: { page: 2, state: 'opened' }, + response: { id: 7 }, + }, + ]); + + await expect( + transport.request({ + method: 'POST', + path: '/api/v4/projects/example/merge_requests', + query: { page: 2, state: 'opened' }, + }), + ).resolves.toEqual({ id: 7 }); + expect(transport.calls).toEqual([ + { + method: 'POST', + path: '/api/v4/projects/example/merge_requests', + query: { page: 2, state: 'opened' }, + }, + ]); +}); + +test('collects paginated responses until GitLab returns a short page', async () => { + const firstPage = Array.from({ length: 100 }, (_, index) => index); + const transport = createFakeGitLabTransport([ + { + path: '/api/v4/projects/example/repository/commits', + query: { page: 1, per_page: 100, ref_name: 'main' }, + response: firstPage, + }, + { + path: '/api/v4/projects/example/repository/commits', + query: { page: 2, per_page: 100, ref_name: 'main' }, + response: [100], + }, + ]); + + await expect( + transport.requestPages?.({ + path: '/api/v4/projects/example/repository/commits', + query: { ref_name: 'main' }, + }), + ).resolves.toEqual([...firstPage, 100]); + expect(transport.calls).toHaveLength(2); +}); + +test('reads raw repository text without exposing host authentication', async () => { + const transport = createFakeGitLabTransport([ + { + path: '/api/v4/projects/example/repository/files/readme/raw', + query: { ref: 'main' }, + response: null, + text: '# Example', + }, + ]); + + await expect( + transport.requestText?.({ + path: '/api/v4/projects/example/repository/files/readme/raw', + query: { ref: 'main' }, + }), + ).resolves.toBe('# Example'); +}); + +test('reads immutable repository blob bytes without text coercion', async () => { + const bytes = new Uint8Array([0, 1, 2, 255]); + const transport = createFakeGitLabTransport([ + { + bytes, + path: '/api/v4/projects/example/repository/blobs/deadbeef/raw', + response: null, + }, + ]); + + await expect( + transport.requestBuffer?.({ + path: '/api/v4/projects/example/repository/blobs/deadbeef/raw', + }), + ).resolves.toEqual(bytes); +}); + +test('cancels in-flight host requests through the shared transport signal', async () => { + const controller = new AbortController(); + const transport = createFakeGitLabTransport([ + { + path: '/api/v4/projects/example/repository/commits/slow/diff', + response: () => new Promise((resolve) => setTimeout(() => resolve([]), 1000)), + }, + ]); + const request = transport.request({ + path: '/api/v4/projects/example/repository/commits/slow/diff', + signal: controller.signal, + }); + controller.abort(new DOMException('Superseded', 'AbortError')); + await expect(request).rejects.toMatchObject({ name: 'AbortError' }); +}); diff --git a/gitlab/package.json b/gitlab/package.json new file mode 100644 index 00000000..629650e1 --- /dev/null +++ b/gitlab/package.json @@ -0,0 +1,36 @@ +{ + "name": "@nkzw/codiff-gitlab", + "version": "0.1.0", + "description": "Host-injected GitLab transport for Codiff.", + "license": "MIT", + "author": { + "name": "Christoph Nakazawa", + "email": "christoph.pojer@gmail.com" + }, + "repository": { + "type": "git", + "url": "https://github.com/nkzw-tech/codiff.git", + "directory": "gitlab" + }, + "files": [ + "dist", + "src" + ], + "type": "module", + "main": "./dist/index.mjs", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "@nkzw/codiff-source": "./src/index.ts", + "default": "./dist/index.mjs" + } + }, + "scripts": { + "build": "rm -rf dist && vp pack -d dist --target=node24 src/index.ts && vp exec tsc -p tsconfig.build.json", + "typecheck": "vp exec tsc --noEmit -p tsconfig.build.json" + }, + "dependencies": { + "@nkzw/codiff-core": "workspace:*" + } +} diff --git a/gitlab/src/current-review.ts b/gitlab/src/current-review.ts new file mode 100644 index 00000000..a2cef858 --- /dev/null +++ b/gitlab/src/current-review.ts @@ -0,0 +1,548 @@ +import { + createCommitArtifactRequestKey, + createFileBlobArtifactRequestKey, + orderReviewCommitStack, + validateCommitArtifact, + validateRangeArtifact, + validateStackSnapshot, + type ArtifactFile, + type BlobArtifact, + type CommitArtifact, + type CommitArtifactRequest, + type CommitArtifactRequestKey, + type FileBlobArtifactRequest, + type RangeArtifact, + type ReviewArtifactProject, + type ReviewArtifactSource, + type StackSnapshot, +} from '@nkzw/codiff-core'; +import type { GitSha, ReviewCommitSummary } from '@nkzw/codiff-core/types'; +import type { GitLabTransport } from './transport.ts'; + +const artifactReadConcurrency = 8; +const maxPages = 20; +const maxArtifactResponseBytes = 8 * 1024 * 1024; +const maxBlobArtifactBytes = 8 * 1024 * 1024; + +type JsonRecord = Record; +const isRecord = (value: unknown): value is JsonRecord => + Boolean(value) && typeof value === 'object' && !Array.isArray(value); +const asRecord = (value: unknown): JsonRecord => (isRecord(value) ? value : {}); +const asArray = (value: unknown): Array => (Array.isArray(value) ? value : []); +const asString = (value: unknown, fallback = '') => (typeof value === 'string' ? value : fallback); +const gitShaPattern = /^(?:[\da-f]{40}|[\da-f]{64})$/i; +const asGitSha = (value: unknown): GitSha | null => { + const candidate = asString(value).trim(); + return gitShaPattern.test(candidate) ? (candidate as GitSha) : null; +}; +const trimmedString = (value: unknown) => { + if (typeof value !== 'string') { + return null; + } + const trimmed = value.trim(); + return trimmed ? trimmed : null; +}; + +const normalizeBlobArtifactMaxBytes = (value: number | undefined) => { + const maxBytes = value ?? maxBlobArtifactBytes; + if (!Number.isFinite(maxBytes) || maxBytes < 0) { + throw new RangeError('Blob Artifact byte limit must be a finite non-negative number.'); + } + return Math.floor(maxBytes); +}; + +export const validateProjectPath = (projectPath: string) => { + const normalized = projectPath.trim().replaceAll(/^\/+|\/+$/g, ''); + if ( + !normalized || + normalized.length > 500 || + !normalized.includes('/') || + normalized.split('/').some((segment) => !segment || segment === '.' || segment === '..') + ) { + throw new Error('Invalid GitLab project path.'); + } + return normalized; +}; + +const repositoryCompareEndpoint = (projectPath: string, from: string, to: string) => ({ + path: `/api/v4/projects/${encodeURIComponent(projectPath)}/repository/compare`, + query: { from, straight: 'true', to }, +}); + +const repositoryCommitDiffEndpoint = (projectPath: string, sha: string) => + `/api/v4/projects/${encodeURIComponent(projectPath)}/repository/commits/${encodeURIComponent(sha)}/diff`; + +const repositoryBlobRawEndpoint = (projectPath: string, objectId: string) => + `/api/v4/projects/${encodeURIComponent(projectPath)}/repository/blobs/${encodeURIComponent(objectId)}/raw`; + +const repositoryFileEndpoint = (projectPath: string, filePath: string) => + `/api/v4/projects/${encodeURIComponent(projectPath)}/repository/files/${encodeURIComponent(filePath)}`; + +const decodeBase64File = (content: string, maxBytes: number) => { + const normalized = content.replaceAll(/\s/g, ''); + if (normalized.length > Math.ceil(maxBytes / 3) * 4 + 4) { + return null; + } + try { + const decoded = atob(normalized); + if (decoded.length > maxBytes) { + return null; + } + const bytes = new Uint8Array(decoded.length); + for (let index = 0; index < decoded.length; index += 1) { + bytes[index] = decoded.charCodeAt(index); + } + return bytes; + } catch { + return null; + } +}; + +const readPages = async ( + transport: GitLabTransport, + path: string, + signal?: AbortSignal, +): Promise> => { + if (transport.requestPages) { + return transport.requestPages({ maxBytes: maxArtifactResponseBytes, path, signal }); + } + const values: Array = []; + let page = 1; + while (page <= maxPages) { + signal?.throwIfAborted(); + const result = await transport.request({ + maxBytes: maxArtifactResponseBytes, + path, + query: { page, per_page: 100 }, + signal, + }); + const pageValues = asArray(result); + values.push(...pageValues); + if (pageValues.length < 100) { + break; + } + page += 1; + } + if (page > maxPages) { + throw new Error('GitLab merge request data exceeded the pagination limit.'); + } + return values; +}; + +const createPatch = (diff: JsonRecord) => { + const oldPath = asString(diff.old_path); + const newPath = asString(diff.new_path); + const body = asString(diff.diff); + const oldHeader = diff.new_file === true ? '/dev/null' : `a/${oldPath}`; + const newHeader = diff.deleted_file === true ? '/dev/null' : `b/${newPath}`; + return `diff --git a/${oldPath} b/${newPath}\n--- ${oldHeader}\n+++ ${newHeader}\n${body}${ + body.endsWith('\n') ? '' : '\n' + }`; +}; + +const countPatchLines = (patch: string) => { + let additions = 0; + let deletions = 0; + for (const line of patch.split('\n')) { + if (line.startsWith('+') && !line.startsWith('+++')) { + additions += 1; + } else if (line.startsWith('-') && !line.startsWith('---')) { + deletions += 1; + } + } + return { additions, deletions }; +}; + +const normalizeMergeRequestCommit = ( + value: unknown, + projectPath: string, +): ReviewCommitSummary | null => { + const commit = asRecord(value); + const sha = asGitSha(commit.id ?? commit.sha); + if (!sha) { + return null; + } + const title = asString(commit.title, asString(commit.message).split('\n')[0] || sha.slice(0, 8)); + const parentShas = asArray(commit.parent_ids) + .map(asGitSha) + .filter((parent): parent is GitSha => parent != null); + const authorName = + trimmedString(commit.author_name) ?? trimmedString(asRecord(commit.author).name) ?? 'Unknown'; + const authoredAt = + asString(commit.authored_date) || + asString(commit.created_at) || + asString(commit.committed_date) || + new Date(0).toISOString(); + return { + authoredAt, + authorName, + parentShas, + sha, + shortSha: asString(commit.short_id, sha.slice(0, 8)), + subject: title, + webUrl: asString(commit.web_url) || `/${projectPath}/-/commit/${encodeURIComponent(sha)}`, + }; +}; + +const normalizeCommitArtifactFile = (value: unknown): ArtifactFile | null => { + const diff = asRecord(value); + const oldPath = asString(diff.old_path); + const path = asString(diff.new_path); + if (!oldPath || !path) { + return null; + } + const patch = asString(diff.diff); + const oldMode = asString(diff.a_mode) || undefined; + const newMode = asString(diff.b_mode) || undefined; + const oldObjectId = asString(diff.old_id) || undefined; + const newObjectId = asString(diff.new_id) || undefined; + const status = diff.new_file + ? 'added' + : diff.deleted_file + ? 'deleted' + : diff.renamed_file + ? 'renamed' + : 'modified'; + const coverage = + diff.too_large === true || diff.collapsed === true + ? 'truncated' + : patch.trim() || oldObjectId || newObjectId || (oldMode && newMode && oldMode !== newMode) + ? 'complete' + : 'opaque'; + return { + coverage, + ...(patch.trim() ? { lineCount: countPatchLines(patch) } : {}), + ...(newMode ? { newMode } : {}), + ...(newObjectId ? { newObjectId } : {}), + ...(oldMode ? { oldMode } : {}), + ...(oldObjectId ? { oldObjectId } : {}), + ...(oldPath !== path ? { oldPath } : {}), + ...(patch.trim() ? { patch: createPatch(diff) } : {}), + path, + status, + }; +}; + +const toGitLabCommitArtifact = ( + commit: CommitArtifactRequest, + project: ReviewArtifactProject, + values: ReadonlyArray, + truncated = false, +): CommitArtifact => { + const files = values + .map(normalizeCommitArtifactFile) + .filter((file): file is ArtifactFile => file != null); + const coverage = + truncated || + files.length !== values.length || + files.some((file) => file.coverage === 'truncated') + ? 'truncated' + : files.some((file) => file.coverage === 'opaque') + ? 'opaque' + : 'complete'; + return validateCommitArtifact({ + commitSha: commit.commitSha, + coverage, + files, + parentSha: commit.parentSha, + provenance: { kind: 'gitlab-api', project }, + }); +}; + +/** Read immutable current-review Commit Artifacts with one bounded scheduler. */ +export const fetchGitLabCommitArtifacts = async ({ + commits, + project, + projectPath: rawProjectPath, + signal, + transport, +}: { + commits: ReadonlyArray; + project: ReviewArtifactProject; + projectPath: string; + signal?: AbortSignal; + transport: GitLabTransport; +}): Promise> => { + const projectPath = validateProjectPath(rawProjectPath); + const pending = [ + ...new Map(commits.map((commit) => [createCommitArtifactRequestKey(commit), commit])).values(), + ]; + const artifacts = new Map(); + let nextIndex = 0; + const worker = async () => { + while (nextIndex < pending.length) { + signal?.throwIfAborted(); + const commit = pending[nextIndex++]!; + try { + let values: ReadonlyArray; + let truncated = false; + if (commit.parentSha) { + const endpoint = repositoryCompareEndpoint( + projectPath, + commit.parentSha, + commit.commitSha, + ); + const comparison = asRecord( + await transport.request({ + maxBytes: maxArtifactResponseBytes, + path: endpoint.path, + query: endpoint.query, + signal, + }), + ); + values = asArray(comparison.diffs); + truncated = comparison.compare_timeout === true || comparison.overflow === true; + } else { + values = await readPages( + transport, + repositoryCommitDiffEndpoint(projectPath, commit.commitSha), + signal, + ); + } + artifacts.set( + createCommitArtifactRequestKey(commit), + toGitLabCommitArtifact(commit, project, values, truncated), + ); + } catch { + signal?.throwIfAborted(); + } + } + }; + await Promise.all( + Array.from({ length: Math.min(artifactReadConcurrency, pending.length) }, worker), + ); + return artifacts; +}; + +const artifactCoverage = (files: ReadonlyArray, expectedCount: number) => + files.length !== expectedCount || files.some((file) => file.coverage === 'truncated') + ? 'truncated' + : files.some((file) => file.coverage === 'opaque') + ? 'opaque' + : 'complete'; + +/** Normalize an acquired GitLab diff response into the shared Range Artifact contract. */ +export const createGitLabRangeArtifact = ({ + baseSha, + diffs, + headSha, + incompleteReason, + project, + truncated = false, +}: { + baseSha: GitSha; + diffs: ReadonlyArray; + headSha: GitSha; + incompleteReason?: string; + project: ReviewArtifactProject; + truncated?: boolean; +}): RangeArtifact => { + const files = diffs + .map(normalizeCommitArtifactFile) + .filter((file): file is ArtifactFile => file != null); + return validateRangeArtifact({ + baseSha, + coverage: + truncated || incompleteReason != null ? 'truncated' : artifactCoverage(files, diffs.length), + files, + headSha, + ...(incompleteReason != null ? { incompleteReason } : {}), + provenance: { kind: 'gitlab-api', project }, + }); +}; + +/** Resolve one immutable GitLab ref+path coordinate to a normalized Blob Artifact. */ +export const readGitLabFileBlobArtifact = async ({ + maxBytes, + path, + project, + projectPath: rawProjectPath, + ref, + signal, + transport, +}: FileBlobArtifactRequest & { + maxBytes: number; + project: ReviewArtifactProject; + projectPath: string; + signal?: AbortSignal; + transport: GitLabTransport; +}): Promise => { + const projectPath = validateProjectPath(rawProjectPath); + const limit = normalizeBlobArtifactMaxBytes(maxBytes); + signal?.throwIfAborted(); + const value = asRecord( + await transport.request({ + maxBytes: Math.ceil(limit / 3) * 4 + 64 * 1024, + path: repositoryFileEndpoint(projectPath, path), + query: { ref }, + signal, + }), + ); + signal?.throwIfAborted(); + const objectId = asGitSha(value.blob_id); + if (!objectId) { + return null; + } + const content = asString(value.content); + const bytes = + asString(value.encoding).toLowerCase() === 'base64' && content + ? decodeBase64File(content, limit) + : null; + if (bytes) { + return { bytes, objectId, provenance: { kind: 'gitlab-api', project } }; + } + if (!transport.requestBuffer) { + return null; + } + const raw = await transport.requestBuffer({ + maxBytes: limit, + path: repositoryBlobRawEndpoint(projectPath, objectId), + signal, + }); + signal?.throwIfAborted(); + return raw.byteLength <= limit + ? { bytes: raw, objectId, provenance: { kind: 'gitlab-api', project } } + : null; +}; + +/** Create the bounded GitLab API source for one current merge request. */ +export const createGitLabArtifactSource = ({ + maxBlobArtifactBytes: requestedMaxBlobArtifactBytes, + project, + projectPath: rawProjectPath, + transport, +}: { + maxBlobArtifactBytes?: number; + project: ReviewArtifactProject; + projectPath: string; + transport: GitLabTransport; +}): ReviewArtifactSource => { + const blobArtifactMaxBytes = normalizeBlobArtifactMaxBytes(requestedMaxBlobArtifactBytes); + const projectPath = validateProjectPath(rawProjectPath); + const provenance = { kind: 'gitlab-api' as const, project }; + return { + async readBlobs(objectIds, signal) { + if (!transport.requestBuffer) { + return new Map(); + } + const pending = [...new Set(objectIds)]; + const blobs = new Map(); + let nextIndex = 0; + const worker = async () => { + while (nextIndex < pending.length) { + signal.throwIfAborted(); + const objectId = pending[nextIndex++]!; + try { + const bytes = await transport.requestBuffer!({ + maxBytes: blobArtifactMaxBytes, + path: repositoryBlobRawEndpoint(projectPath, objectId), + signal, + }); + if (bytes.byteLength <= blobArtifactMaxBytes) { + blobs.set(objectId, { bytes, objectId, provenance }); + } + } catch { + signal.throwIfAborted(); + } + } + }; + await Promise.all( + Array.from({ length: Math.min(artifactReadConcurrency, pending.length) }, worker), + ); + return blobs; + }, + readCommitArtifacts: (commits: ReadonlyArray, signal) => + fetchGitLabCommitArtifacts({ + commits, + project, + projectPath, + signal, + transport, + }), + async readFileBlobs(requests, signal) { + const pending = [ + ...new Map( + requests.map((request) => [createFileBlobArtifactRequestKey(request), request]), + ).values(), + ]; + const blobs = new Map(); + let nextIndex = 0; + const worker = async () => { + while (nextIndex < pending.length) { + signal.throwIfAborted(); + const request = pending[nextIndex++]!; + try { + const blob = await readGitLabFileBlobArtifact({ + ...request, + maxBytes: Math.min(request.maxBytes ?? blobArtifactMaxBytes, blobArtifactMaxBytes), + project, + projectPath, + signal, + transport, + }); + if (blob) { + blobs.set(createFileBlobArtifactRequestKey(request), blob); + } + } catch { + signal.throwIfAborted(); + } + } + }; + await Promise.all( + Array.from({ length: Math.min(artifactReadConcurrency, pending.length) }, worker), + ); + return blobs; + }, + async readStackAndRange({ headSha: head, requestedBaseSha: base }, signal) { + if (base === head) { + return { + range: validateRangeArtifact({ + baseSha: base, + coverage: 'complete', + files: [], + headSha: head, + provenance, + }), + stack: validateStackSnapshot({ + baseSha: base, + commits: [], + coverage: 'complete', + headSha: head, + provenance, + }), + }; + } + const endpoint = repositoryCompareEndpoint(projectPath, base, head); + const value = asRecord( + await transport.request({ + maxBytes: maxArtifactResponseBytes, + path: endpoint.path, + query: endpoint.query, + signal, + }), + ); + const rawCommits = asArray(value.commits); + const commits = orderReviewCommitStack( + rawCommits + .map((entry) => normalizeMergeRequestCommit(entry, projectPath)) + .filter((commit): commit is ReviewCommitSummary => commit != null), + ); + const truncated = value.compare_timeout === true || value.overflow === true; + const range = createGitLabRangeArtifact({ + baseSha: base, + diffs: asArray(value.diffs), + headSha: head, + project, + truncated, + }); + const stack: StackSnapshot = { + baseSha: base, + commits, + coverage: truncated || commits.length !== rawCommits.length ? 'truncated' : 'complete', + headSha: head, + provenance, + }; + return { range, stack: validateStackSnapshot(stack) }; + }, + }; +}; diff --git a/gitlab/src/index.ts b/gitlab/src/index.ts new file mode 100644 index 00000000..64a4a735 --- /dev/null +++ b/gitlab/src/index.ts @@ -0,0 +1,2 @@ +export * from './current-review.ts'; +export * from './transport.ts'; diff --git a/gitlab/src/transport.ts b/gitlab/src/transport.ts new file mode 100644 index 00000000..c1f66474 --- /dev/null +++ b/gitlab/src/transport.ts @@ -0,0 +1,44 @@ +/** + * Host-injected GitLab transport. + * + * The host authenticates and executes HTTP. This package owns endpoint + * construction, pagination policy, and response parsing. + */ +export type GitLabTransport = { + request(request: { + body?: unknown; + /** Maximum response bytes the host may retain. */ + maxBytes?: number; + method?: 'GET' | 'POST' | 'PUT' | 'DELETE'; + path: string; + query?: Readonly>; + signal?: AbortSignal; + }): Promise; + /** Optional raw byte reader for immutable repository blobs. */ + requestBuffer?(request: { + /** Maximum raw response bytes the host may retain. */ + maxBytes?: number; + path: string; + query?: Readonly>; + signal?: AbortSignal; + }): Promise; + /** + * Optional paginated reader. When omitted, {@link request} is called with + * `page` / `per_page` until a short page is returned. + */ + requestPages?(request: { + /** Maximum combined response bytes the host may retain. */ + maxBytes?: number; + path: string; + query?: Readonly>; + signal?: AbortSignal; + }): Promise>; + /** Optional raw text reader for repository file blobs. */ + requestText?(request: { + /** Maximum response bytes the host may retain. */ + maxBytes?: number; + path: string; + query?: Readonly>; + signal?: AbortSignal; + }): Promise; +}; diff --git a/gitlab/tsconfig.build.json b/gitlab/tsconfig.build.json new file mode 100644 index 00000000..bddab4a7 --- /dev/null +++ b/gitlab/tsconfig.build.json @@ -0,0 +1,13 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "declaration": true, + "declarationMap": false, + "emitDeclarationOnly": true, + "incremental": false, + "noEmit": false, + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*.ts"] +} diff --git a/gitlab/vite.config.ts b/gitlab/vite.config.ts new file mode 100644 index 00000000..e965e0af --- /dev/null +++ b/gitlab/vite.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'vite-plus'; + +export default defineConfig({ + pack: { + copy: [], + deps: { + alwaysBundle: ['@nkzw/codiff-core'], + dts: { neverBundle: ['@nkzw/codiff-core'] }, + }, + dts: false, + }, +}); diff --git a/package.json b/package.json index 1b51c4df..b93260fa 100644 --- a/package.json +++ b/package.json @@ -23,27 +23,30 @@ "core", "dist", "electron", + "github/dist", + "gitlab/dist", "opencode", "pi" ], "type": "module", "main": "./electron/main.cjs", "scripts": { - "build": "vp run --filter '@nkzw/codiff-core' build && vp run --filter '@nkzw/codiff-service' build && vp run --filter '@nkzw/codiff-web' build && vp build", + "build": "vp run --filter '@nkzw/codiff-core' build && vp run --filter '@nkzw/codiff-gitlab' build && vp run --filter '@nkzw/codiff-github' build && vp run --filter '@nkzw/codiff-service' build && vp run --filter '@nkzw/codiff-web' build && vp build && node ./scripts/verify-package-runtime.mjs", + "build:runtime": "vp run --filter '@nkzw/codiff-core' build && vp run --filter '@nkzw/codiff-gitlab' build && vp run --filter '@nkzw/codiff-github' build", "codiff": "node ./bin/codiff.js", "dev": "vp dev --host 127.0.0.1", "dev:app": "ELECTRON_RENDERER_URL=http://127.0.0.1:5173 node ./bin/codiff.js", "electron": "electron .", "eval:walkthrough": "node ./evals/run.mjs", "example:definition-navigation": "node ./examples/definition-navigation/run.mjs", - "forge:make": "electron-forge make", - "forge:package": "electron-forge package", - "link:global": "vp build && pnpm link --global", - "make": "vpr build && electron-forge make", - "make:ci": "vpr build && electron-forge make && electron-forge package --platform=linux --arch=x64 && electron-forge make --platform=win32", - "make:mac": "vpr build && electron-forge make --platform=darwin --arch=arm64", - "package:app": "vp build && electron-forge package", - "prepack": "vp build", + "forge:make": "pnpm run build:runtime && vpr build && electron-forge make", + "forge:package": "pnpm run build:runtime && vpr build && electron-forge package", + "link:global": "pnpm add --global .", + "make": "pnpm run build:runtime && vpr build && electron-forge make", + "make:ci": "pnpm run build:runtime && vpr build && electron-forge make && electron-forge package --platform=linux --arch=x64 && electron-forge make --platform=win32", + "make:mac": "pnpm run build:runtime && vpr build && electron-forge make --platform=darwin --arch=arm64", + "package:app": "pnpm run build:runtime && vpr build && electron-forge package", + "prepack": "pnpm run build:runtime && vpr build", "prepare": "vp config", "test": "vp test", "test:integration": "vp run build && vp test -c vitest.cloudflare.config.ts" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 969b924d..a09b773f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -144,6 +144,18 @@ importers: specifier: ^1.4.2 version: 1.4.2(typescript@7.0.2) + github: + dependencies: + '@nkzw/codiff-core': + specifier: workspace:* + version: link:../core + + gitlab: + dependencies: + '@nkzw/codiff-core': + specifier: workspace:* + version: link:../core + service: dependencies: '@nkzw/codiff-core': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 0a9d69b3..5b8d206f 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,5 +1,7 @@ packages: - core + - gitlab + - github - service - web diff --git a/scripts/verify-package-runtime.mjs b/scripts/verify-package-runtime.mjs new file mode 100644 index 00000000..05694133 --- /dev/null +++ b/scripts/verify-package-runtime.mjs @@ -0,0 +1,137 @@ +#!/usr/bin/env node + +import { Buffer } from 'node:buffer'; +import { spawn } from 'node:child_process'; +import { cp, mkdir, mkdtemp, readFile, rm } from 'node:fs/promises'; +import { builtinModules, createRequire } from 'node:module'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import process from 'node:process'; + +const root = process.cwd(); +const runtimeFiles = [ + 'electron/github-history-bridge.cjs', + 'electron/gitlab-history-bridge.cjs', + 'electron/walkthrough-generation-bridge.cjs', + 'core/dist/walkthrough-generation.mjs', + 'github/dist/index.mjs', + 'gitlab/dist/index.mjs', +]; +const runtimeDirectories = ['core/dist', 'github/dist', 'gitlab/dist']; +const builtin = new Set([...builtinModules, ...builtinModules.map((name) => `node:${name}`)]); + +const run = (command, args) => + new Promise((resolve, reject) => { + const child = spawn(command, args, { + cwd: root, + env: { ...process.env, npm_config_ignore_scripts: 'true' }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + const stdout = []; + const stderr = []; + child.stdout.on('data', (chunk) => stdout.push(chunk)); + child.stderr.on('data', (chunk) => stderr.push(chunk)); + child.on('error', reject); + child.on('close', (code) => { + if (code === 0) { + resolve(Buffer.concat(stdout).toString('utf8')); + } else { + reject(new Error(`${command} ${args.join(' ')} failed: ${Buffer.concat(stderr)}`)); + } + }); + }); + +const parsePackEntry = (value) => { + const entry = Array.isArray(value) ? value[0] : value; + return Array.isArray(entry?.files) ? new Set(entry.files.map((file) => file.path)) : null; +}; + +const parsePackList = (output) => { + try { + const files = parsePackEntry(JSON.parse(output)); + if (files) { + return files; + } + } catch { + // pnpm may prefix a JSON payload with informational output. + } + for (const line of output.trim().split('\n').reverse()) { + try { + const files = parsePackEntry(JSON.parse(line)); + if (files) { + return files; + } + } catch { + // Continue looking for a one-line JSON payload. + } + } + throw new Error('pnpm pack did not return a JSON package file list.'); +}; + +const nonBuiltinImports = async (path) => { + const source = await readFile(path, 'utf8'); + const specifiers = [ + ...source.matchAll( + /(?:\b(?:import|export)\s+(?:[^'";]*?\s+from\s+)?|\bimport\s*\()\s*["']([^"']+)["']/g, + ), + ...source.matchAll(/\brequire\s*\(\s*["']([^"']+)["']\s*\)/g), + ].map((match) => match[1]); + return specifiers.filter( + (specifier) => + specifier && + !specifier.startsWith('.') && + !specifier.startsWith('/') && + !builtin.has(specifier), + ); +}; + +const packList = parsePackList( + await run('pnpm', ['--config.ignore-scripts=true', 'pack', '--dry-run', '--json']), +); +for (const path of runtimeFiles) { + if (!packList.has(path)) { + throw new Error(`Root package is missing required runtime artifact ${path}.`); + } +} +for (const path of ['github/dist/index.mjs', 'gitlab/dist/index.mjs']) { + const imports = await nonBuiltinImports(join(root, path)); + if (imports.length > 0) { + throw new Error(`${path} retains non-builtin runtime imports: ${imports.join(', ')}.`); + } +} + +const directory = await mkdtemp(join(tmpdir(), 'codiff-package-runtime-')); +try { + for (const path of runtimeFiles.filter((path) => path.startsWith('electron/'))) { + const destination = join(directory, path); + await mkdir(dirname(destination), { recursive: true }); + await cp(join(root, path), destination, { recursive: true }); + } + for (const path of runtimeDirectories) { + const destination = join(directory, path); + await mkdir(dirname(destination), { recursive: true }); + await cp(join(root, path), destination, { recursive: true }); + } + const require = createRequire(join(directory, 'package.json')); + const [{ loadGitHubHistory }, { loadGitLabHistory }, { loadWalkthroughGeneration }] = [ + require(join(directory, 'electron/github-history-bridge.cjs')), + require(join(directory, 'electron/gitlab-history-bridge.cjs')), + require(join(directory, 'electron/walkthrough-generation-bridge.cjs')), + ]; + const [github, gitlab, walkthroughGeneration] = await Promise.all([ + loadGitHubHistory(), + loadGitLabHistory(), + loadWalkthroughGeneration(), + ]); + if (typeof github.createGitHubArtifactSource !== 'function') { + throw new Error('GitHub runtime bridge did not load createGitHubArtifactSource.'); + } + if (typeof gitlab.createGitLabArtifactSource !== 'function') { + throw new Error('GitLab runtime bridge did not load createGitLabArtifactSource.'); + } + if (typeof walkthroughGeneration.runWalkthroughGenerationTasks !== 'function') { + throw new Error('Core walkthrough-generation runtime did not load the task runner.'); + } +} finally { + await rm(directory, { force: true, recursive: true }); +} diff --git a/service/api.ts b/service/api.ts index 296b70ab..00f34068 100644 --- a/service/api.ts +++ b/service/api.ts @@ -1,6 +1,7 @@ import { parsePlanShareUpload } from '@nkzw/codiff-core/share'; import { z } from 'zod'; import { and, db, eq, sql, withDatabase, type D1Binding } from './db.ts'; +import { reviewCommentPositionSchema } from './review-position.ts'; import { plan, shareDailyUsage, uploadIntent, user, walkthrough, type UserRow } from './schema.ts'; import { createUploadIntentSecret, @@ -71,6 +72,7 @@ const reviewCommentSchema = z id: nonEmptyString(256), isThreadResolved: z.boolean().optional(), lineNumber: z.number().int().positive().optional(), + position: reviewCommentPositionSchema.optional(), sectionId: nonEmptyString(4096).optional(), side: z.enum(['additions', 'deletions']).optional(), startLineNumber: z.number().int().positive().optional(), @@ -79,6 +81,10 @@ const reviewCommentSchema = z threadId: nonEmptyString(256).optional(), }) .passthrough() + .refine( + (comment) => !(comment.position != null && comment.sectionId != null), + 'A review comment cannot contain both sectionId and position.', + ) .refine( (comment) => comment.anchor === 'file' || diff --git a/service/fate.ts b/service/fate.ts index 18da05b5..fd64eff5 100644 --- a/service/fate.ts +++ b/service/fate.ts @@ -21,6 +21,7 @@ import { type Database, type DatabaseBatchStatement, } from './db.ts'; +import { reviewCommentPositionSchema } from './review-position.ts'; import schema, { plan, shareCommentMessage, @@ -168,6 +169,7 @@ type WalkthroughReviewComment = { id: string; isThreadResolved?: boolean; lineNumber?: number; + position?: unknown; sectionId?: string; side?: 'additions' | 'deletions'; startLineNumber?: number; @@ -197,6 +199,12 @@ const importWalkthroughComments = async ( const statements: Array = []; const groups = new Map>(); for (const comment of snapshot.reviewComments ?? []) { + if (comment.sectionId != null && comment.position != null) { + throw new FateRequestError( + 'BAD_REQUEST', + 'A review comment cannot contain both sectionId and position.', + ); + } const key = comment.threadId ? `thread:${comment.threadId}` : `comment:${comment.id}`; const group = groups.get(key); if (group) { @@ -228,6 +236,7 @@ const importWalkthroughComments = async ( id: threadId, kind: 'walkthrough-diff', lineNumber: isFileComment ? null : first.lineNumber, + positionJson: first.position ? JSON.stringify(first.position) : null, resolvedAt: resolved ? createdAt : null, sectionId: first.sectionId ?? null, side: isFileComment ? null : first.side, @@ -471,6 +480,7 @@ export const shareCommentAnchorSchema = z const walkthroughCommentTargetBaseSchema = z.object({ filePath: z.string().min(1).max(4096), kind: z.literal('walkthrough-diff'), + position: reviewCommentPositionSchema.optional(), }); export const shareCommentTargetSchema = z.union([ z.object({ @@ -480,14 +490,19 @@ export const shareCommentTargetSchema = z.union([ walkthroughCommentTargetBaseSchema.extend({ anchor: z.literal('file'), }), - walkthroughCommentTargetBaseSchema.extend({ - anchor: z.literal('line').optional(), - lineNumber: z.number().int().positive(), - sectionId: z.string().min(1).max(4096).optional(), - side: z.enum(['additions', 'deletions']), - startLineNumber: z.number().int().positive().optional(), - startSide: z.enum(['additions', 'deletions']).optional(), - }), + walkthroughCommentTargetBaseSchema + .extend({ + anchor: z.literal('line').optional(), + lineNumber: z.number().int().positive(), + sectionId: z.string().min(1).max(4096).optional(), + side: z.enum(['additions', 'deletions']), + startLineNumber: z.number().int().positive().optional(), + startSide: z.enum(['additions', 'deletions']).optional(), + }) + .refine( + ({ position, sectionId }) => !(position != null && sectionId != null), + 'A comment target cannot contain both sectionId and position.', + ), z.object({ kind: z.literal('walkthrough-general'), }), @@ -744,6 +759,7 @@ export const createSharingFateServer = ({ kind: 'plan', lineNumber: null, planId: record.id, + positionJson: null, resolvedAt: null, sectionId: null, side: null, @@ -780,6 +796,8 @@ export const createSharingFateServer = ({ input.target.kind === 'walkthrough-diff' && input.target.anchor !== 'file' ? input.target : null; + const position = + input.target.kind === 'walkthrough-diff' ? input.target.position : undefined; thread = { anchorJson: null, createdAt: now, @@ -788,6 +806,7 @@ export const createSharingFateServer = ({ kind: input.target.kind, lineNumber: lineTarget?.lineNumber ?? null, planId: null, + positionJson: position ? JSON.stringify(position) : null, resolvedAt: null, sectionId: lineTarget?.sectionId ?? null, side: lineTarget?.side ?? null, diff --git a/service/react.test.ts b/service/react.test.ts index d28f0c45..ad4c0f24 100644 --- a/service/react.test.ts +++ b/service/react.test.ts @@ -1,5 +1,29 @@ -import { expect, test } from 'vite-plus/test'; -import { resolveSubmittedShareReply, resolveSubmittedShareThread } from './react.tsx'; +/** + * @vitest-environment jsdom + */ + +import type { GitSha, ResolvedReviewSource, SharedWalkthroughSnapshot } from '@nkzw/codiff-core'; +import { act, createElement } from 'react'; +import { createRoot } from 'react-dom/client'; +import { expect, test, vi } from 'vite-plus/test'; +import { + createSharedReviewCommentCapabilities, + resolveSubmittedShareReply, + resolveSubmittedShareThread, + SharedWalkthroughApp, +} from './react.tsx'; + +const reactActEnvironment = globalThis as typeof globalThis & { + ResizeObserver?: typeof ResizeObserver; +}; +reactActEnvironment.ResizeObserver ??= class ResizeObserver { + disconnect() {} + observe() {} + unobserve() {} +}; +HTMLElement.prototype.scrollBy ??= function scrollBy() {}; +HTMLElement.prototype.scrollIntoView ??= function scrollIntoView() {}; +HTMLElement.prototype.scrollTo ??= function scrollTo() {}; const comment = { body: 'Keep this visible while it saves.', @@ -68,3 +92,169 @@ test('rejects incomplete mutation results instead of inventing a submitted comme }), ).toThrow('Unable to load the submitted walkthrough comment.'); }); + +test('maps every shared discussion callback to share capabilities without provider naming', () => { + const onDeleteComment = vi.fn(async () => {}); + const onDeleteGeneralComment = vi.fn(async () => {}); + const onReplyGeneralComment = vi.fn(async () => {}); + const onResolveDiscussion = vi.fn(async () => {}); + const onSignIn = vi.fn(); + const onSubmitComment = vi.fn(async () => ({ + author: { login: 'ada', name: 'Ada' }, + body: comment.body, + destination: 'share' as const, + filePath: comment.filePath, + id: 'submitted', + isReadOnly: true as const, + lineNumber: comment.lineNumber, + sectionId: comment.sectionId, + side: comment.side, + })); + const onSubmitGeneralComment = vi.fn(async () => {}); + const onUpdateComment = vi.fn(async () => {}); + const onUpdateGeneralComment = vi.fn(async () => {}); + + const capabilities = createSharedReviewCommentCapabilities({ + canComment: true, + onDeleteComment, + onDeleteGeneralComment, + onReplyGeneralComment, + onResolveDiscussion, + onSignIn, + onSubmitComment, + onSubmitGeneralComment, + onUpdateComment, + onUpdateGeneralComment, + }); + + expect(capabilities).toMatchObject({ + authoring: { canCreateInline: true }, + destination: 'share', + }); + expect(capabilities?.inline).toEqual({ + onDelete: onDeleteComment, + onResolve: onResolveDiscussion, + onSubmit: onSubmitComment, + onUpdate: onUpdateComment, + }); + expect(capabilities?.general).toEqual({ + onCreate: onSubmitGeneralComment, + onDelete: onDeleteGeneralComment, + onReply: onReplyGeneralComment, + onResolve: onResolveDiscussion, + onUpdate: onUpdateGeneralComment, + }); + expect(capabilities?.onSignIn).toBe(onSignIn); +}); + +test('signed-out shared review capabilities expose no mutation handlers', () => { + const onSignIn = vi.fn(); + const mutation = vi.fn(async () => {}); + const capabilities = createSharedReviewCommentCapabilities({ + canComment: false, + onDeleteComment: mutation, + onDeleteGeneralComment: mutation, + onReplyGeneralComment: mutation, + onResolveDiscussion: mutation, + onSignIn, + onSubmitComment: async () => { + throw new Error('Signed-out mutation must not be reachable.'); + }, + onSubmitGeneralComment: mutation, + onUpdateComment: mutation, + onUpdateGeneralComment: mutation, + }); + + expect(capabilities).toMatchObject({ + authoring: { canCreateInline: false }, + general: {}, + inline: {}, + onSignIn, + }); +}); + +test('supplies the share destination for every shared walkthrough source', async () => { + const sources = [ + { type: 'working-tree' }, + { sha: 'a'.repeat(40) as GitSha, type: 'commit' }, + { + baseSha: 'b'.repeat(40) as GitSha, + headSha: 'c'.repeat(40) as GitSha, + ref: 'main...feature', + type: 'branch-diff', + }, + { base: 'main', head: 'feature', symmetric: true, type: 'range' }, + { + number: 7, + owner: 'cloudflare', + provider: 'github', + repo: 'codiff', + type: 'pull-request', + url: 'https://github.com/cloudflare/codiff/pull/7', + }, + { + number: 8, + projectPath: 'cloudflare/codiff', + provider: 'gitlab', + type: 'pull-request', + url: 'https://gitlab.example.com/cloudflare/codiff/-/merge_requests/8', + }, + ] satisfies ReadonlyArray; + const commenting = { + canComment: true, + onSubmitComment: async () => { + throw new Error('Not submitted by this test.'); + }, + }; + + for (const source of sources) { + const snapshot = { + branch: 'main', + codiffVersion: 'test', + exportedAt: '2026-08-06T00:00:00.000Z', + files: [], + kind: 'codiff-walkthrough-share', + preferences: { + codeFontFamily: 'Fira Code', + codeFontSize: 13, + diffStyle: 'split', + showWhitespace: false, + theme: 'system', + wordWrap: false, + }, + repository: { root: '/repo', source }, + version: 1, + walkthrough: { + agent: 'codex', + chapters: [], + focus: 'Review share comment capabilities.', + generatedAt: '2026-08-06T00:00:00.000Z', + kind: 'narrative', + repo: { branch: 'main', root: '/repo' }, + source, + support: [], + title: 'Shared review', + version: 4, + }, + } satisfies SharedWalkthroughSnapshot; + const container = document.createElement('div'); + document.body.append(container); + const root = createRoot(container); + + await act(async () => { + root.render( + createElement(SharedWalkthroughApp, { + commenting, + providerLabel: 'GitHub', + snapshot, + }), + ); + }); + expect( + Array.from(container.querySelectorAll('[role="tab"]')).map((tab) => tab.textContent?.trim()), + ).toContain('Comments'); + expect(container.querySelector('button[aria-label="Copy Review Notes"]')).toBeNull(); + await act(async () => root.unmount()); + container.remove(); + } +}); diff --git a/service/react.tsx b/service/react.tsx index 3bbba1b0..b5f98354 100644 --- a/service/react.tsx +++ b/service/react.tsx @@ -1,19 +1,30 @@ import type { GitIdentity, - PullRequestExistingReviewComment, - PullRequestReviewComment, + ShareCommentSubmission, SharedWalkthroughSnapshot, + SubmittedReviewComment, } from '@nkzw/codiff-core'; import { PlanReviewSurface, ReviewSurface, type PlanReviewCommenting, - type ReviewCommenting, + type ShareReviewCommentCapabilities, } from '@nkzw/codiff-core/react'; import type { ComponentProps, ReactNode } from 'react'; export type SharedPlanCommenting = PlanReviewCommenting; -export type SharedWalkthroughCommenting = ReviewCommenting; +export type SharedWalkthroughCommenting = { + canComment?: boolean; + onDeleteComment?: (commentId: string) => Promise; + onDeleteGeneralComment?: (commentId: string) => Promise; + onReplyGeneralComment?: (threadId: string, body: string) => Promise; + onResolveDiscussion?: (discussionId: string, resolved: boolean) => Promise; + onSignIn?: () => Promise | void; + onSubmitComment?: (comment: ShareCommentSubmission) => Promise; + onSubmitGeneralComment?: (body: string) => Promise; + onUpdateComment?: (commentId: string, body: string) => Promise; + onUpdateGeneralComment?: (commentId: string, body: string) => Promise; +}; type SubmittedShareCommentMessage = { authorImage: null | string; @@ -55,11 +66,11 @@ const submittedShareCommentMessage = (value: unknown): SubmittedShareCommentMess }; const toSubmittedReviewComment = ( - comment: PullRequestReviewComment, + comment: ShareCommentSubmission, message: SubmittedShareCommentMessage, threadId: string, canResolveThread: boolean, -): PullRequestExistingReviewComment => ({ +): SubmittedReviewComment => ({ ...comment, author: { ...(message.authorImage ? { avatarUrl: message.authorImage } : {}), @@ -69,7 +80,9 @@ const toSubmittedReviewComment = ( body: message.body, ...(message.canEdit ? { canDelete: true, canEdit: true } : {}), ...(canResolveThread ? { canResolveThread: true } : {}), + destination: 'share', id: message.id, + isReadOnly: true, submittedAt: message.createdAt, threadId, }); @@ -80,7 +93,7 @@ export const resolveSubmittedShareReply = ({ result, }: { canResolveThread: boolean; - comment: PullRequestReviewComment & { threadId: string }; + comment: ShareCommentSubmission & { threadId: string }; result: unknown; }) => toSubmittedReviewComment( @@ -96,7 +109,7 @@ export const resolveSubmittedShareThread = ({ result, }: { canResolveThread: boolean; - comment: PullRequestReviewComment; + comment: ShareCommentSubmission; result: unknown; }) => { const thread = asRecord(result); @@ -113,6 +126,38 @@ export const resolveSubmittedShareThread = ({ ); }; +export const createSharedReviewCommentCapabilities = ( + commenting?: SharedWalkthroughCommenting, +): ShareReviewCommentCapabilities | undefined => + commenting + ? { + authoring: { + canCreateInline: commenting.canComment === true && commenting.onSubmitComment != null, + }, + destination: 'share', + general: + commenting.canComment === true + ? { + onCreate: commenting.onSubmitGeneralComment, + onDelete: commenting.onDeleteGeneralComment, + onReply: commenting.onReplyGeneralComment, + onResolve: commenting.onResolveDiscussion, + onUpdate: commenting.onUpdateGeneralComment, + } + : {}, + inline: + commenting.canComment === true + ? { + onDelete: commenting.onDeleteComment, + onResolve: commenting.onResolveDiscussion, + onSubmit: commenting.onSubmitComment, + onUpdate: commenting.onUpdateComment, + } + : {}, + onSignIn: commenting.onSignIn, + } + : undefined; + export function SharedPlanApp({ providerLabel, ...props @@ -137,9 +182,11 @@ export function SharedWalkthroughApp({ settingsBar?: ReactNode; snapshot: SharedWalkthroughSnapshot; }) { + const comments = createSharedReviewCommentCapabilities(commenting); + return ( ({ + label: { kind: 'commit' as const, text: sha }, + sha, +}); + +test('accepts durable commit, index, and working-copy review coordinates', () => { + for (const range of [ + { base: commit('a'.repeat(40)), head: commit('b'.repeat(40)) }, + { base: null, head: commit('b'.repeat(40)) }, + { + base: { kind: 'index' as const, label: { kind: 'review-marker' as const, text: 'Index' } }, + head: { + kind: 'working-copy' as const, + label: { kind: 'review-marker' as const, text: 'Working copy' }, + }, + }, + ]) { + expect(reviewCommentPositionSchema.safeParse({ range }).success).toBe(true); + } +}); + +test('rejects malformed or expanded review coordinates', () => { + expect(reviewCommentPositionSchema.safeParse({ range: { base: {}, head: {} } }).success).toBe( + false, + ); + expect( + reviewCommentPositionSchema.safeParse({ + range: { base: commit('a'), head: commit('b') }, + unexpected: true, + }).success, + ).toBe(false); +}); diff --git a/service/review-position.ts b/service/review-position.ts new file mode 100644 index 00000000..48a38723 --- /dev/null +++ b/service/review-position.ts @@ -0,0 +1,36 @@ +import { z } from 'zod'; + +const nonEmptyString = (maximum: number) => z.string().min(1).max(maximum); + +export const revisionLabelSchema = z.object({ + kind: z.enum(['bookmark', 'branch', 'commit', 'review-marker', 'tag', 'version']), + text: nonEmptyString(4096), + url: z.string().url().max(4096).optional(), +}); + +export const revisionSchema = z.union([ + z.object({ + aliases: z.array(revisionLabelSchema).max(100).readonly().optional(), + kind: z.literal('commit').optional(), + label: revisionLabelSchema, + sha: nonEmptyString(256), + }), + z.object({ + aliases: z.array(revisionLabelSchema).max(100).readonly().optional(), + kind: z.literal('index'), + label: revisionLabelSchema, + }), + z.object({ + aliases: z.array(revisionLabelSchema).max(100).readonly().optional(), + kind: z.literal('working-copy'), + label: revisionLabelSchema, + }), +]); + +export const diffRangeSchema = z.object({ + base: revisionSchema.nullable(), + head: revisionSchema.nullable(), +}); + +/** Durable, range-only coordinate for a shared walkthrough review comment. */ +export const reviewCommentPositionSchema = z.object({ range: diffRangeSchema }).strict(); diff --git a/service/schema.ts b/service/schema.ts index 79cbb912..802aa009 100644 --- a/service/schema.ts +++ b/service/schema.ts @@ -164,6 +164,7 @@ export const shareCommentThread = sqliteTable( kind: text({ enum: shareCommentThreadKind }).notNull(), lineNumber: integer(), planId: text().references(() => plan.id, { onDelete: 'cascade' }), + positionJson: text(), resolvedAt: nullableTimestampColumn(), sectionId: text(), side: text({ enum: ['additions', 'deletions'] }), diff --git a/service/views.ts b/service/views.ts index 96b47673..ab39601e 100644 --- a/service/views.ts +++ b/service/views.ts @@ -138,6 +138,7 @@ export const shareCommentThreadDataView = dataView('Share orderBy: [{ createdAt: 'asc' }, { id: 'asc' }], }), planId: true, + positionJson: true, resolvedAt: true, sectionId: true, side: true, diff --git a/test/fake-provider-transports.ts b/test/fake-provider-transports.ts new file mode 100644 index 00000000..df4d3013 --- /dev/null +++ b/test/fake-provider-transports.ts @@ -0,0 +1,165 @@ +import type { GitHubTransport } from '../github/src/transport.ts'; +import type { GitLabTransport } from '../gitlab/src/transport.ts'; + +type Query = Readonly>; +type FakeTransportRequest = { method: string; path: string; query?: Query }; +type FakeTransportRoute = { + bytes?: Uint8Array | ((request: FakeTransportRequest) => Promise | Uint8Array); + method?: 'DELETE' | 'GET' | 'PATCH' | 'POST' | 'PUT'; + path: string; + query?: Query; + response: unknown | ((request: FakeTransportRequest) => Promise | unknown); + text?: string | ((request: FakeTransportRequest) => Promise | string); +}; +type FakeTransportCall = { + maxBytes?: number; + method: string; + path: string; + query?: Record; +}; +type FakeTransport = GitHubTransport & + GitLabTransport & { + calls: Array; + }; + +const queryKey = (query?: Query) => + query + ? Object.entries(query) + .toSorted(([left], [right]) => left.localeCompare(right)) + .map(([key, value]) => `${key}=${String(value)}`) + .join('&') + : ''; + +const withAbort = async (promise: Promise, signal?: AbortSignal): Promise => { + signal?.throwIfAborted(); + if (!signal) { + return promise; + } + return new Promise((resolve, reject) => { + const abort = () => reject(signal.reason ?? new DOMException('Aborted', 'AbortError')); + signal.addEventListener('abort', abort, { once: true }); + promise.then(resolve, reject).finally(() => signal.removeEventListener('abort', abort)); + }); +}; + +const createFakeTransport = ( + provider: 'GitHub' | 'GitLab', + routes: ReadonlyArray, +): FakeTransport => { + const calls: Array = []; + const match = (method: string, path: string, query?: Query) => + routes.find( + (route) => + route.path === path && + (route.method ?? 'GET') === method && + queryKey(route.query) === queryKey(query), + ) ?? + routes.find( + (route) => route.path === path && (route.method ?? 'GET') === method && route.query == null, + ); + const record = (request: { maxBytes?: number; method?: string; path: string; query?: Query }) => { + const method = request.method ?? 'GET'; + calls.push({ + ...(request.maxBytes == null ? {} : { maxBytes: request.maxBytes }), + method, + path: request.path, + ...(request.query ? { query: { ...request.query } } : {}), + }); + return method; + }; + const value = async ( + routeValue: Value | ((request: FakeTransportRequest) => Promise | Value), + request: FakeTransportRequest, + signal?: AbortSignal, + ) => + withAbort( + Promise.resolve( + typeof routeValue === 'function' + ? (routeValue as (request: FakeTransportRequest) => Promise | Value)(request) + : routeValue, + ), + signal, + ); + + return { + calls, + async request(request) { + request.signal?.throwIfAborted(); + const method = record(request); + const route = match(method, request.path, request.query); + if (!route) { + throw new Error(`No fake ${provider} route for ${method} ${request.path}`); + } + return value( + route.response as never, + { method, path: request.path, query: request.query }, + request.signal, + ); + }, + async requestBuffer(request) { + request.signal?.throwIfAborted(); + record(request); + const route = match('GET', request.path, request.query); + if (!route?.bytes) { + throw new Error(`No fake ${provider} byte route for GET ${request.path}`); + } + const bytes = await value( + route.bytes, + { method: 'GET', path: request.path, query: request.query }, + request.signal, + ); + if (request.maxBytes != null && bytes.byteLength > request.maxBytes) { + const error = new Error( + `${provider} response exceeded the ${request.maxBytes}-byte safety limit.`, + ); + error.name = 'ProviderOutputLimitError'; + throw error; + } + return bytes; + }, + async requestPages(request) { + const values: Array = []; + for (let page = 1; page < 50; page += 1) { + request.signal?.throwIfAborted(); + const query = { ...(request.query ?? {}), page, per_page: 100 }; + const route = + match('GET', request.path, query) ?? + (page === 1 ? match('GET', request.path, request.query) : undefined); + if (!route) { + break; + } + record({ ...request, query }); + const response = await value( + route.response as never, + { method: 'GET', path: request.path, query }, + request.signal, + ); + const pageValues = Array.isArray(response) ? response : []; + values.push(...pageValues); + if (pageValues.length < 100) { + break; + } + } + return values; + }, + async requestText(request) { + request.signal?.throwIfAborted(); + record(request); + const route = match('GET', request.path, request.query); + if (route?.text == null) { + throw new Error(`No fake ${provider} text route for GET ${request.path}`); + } + return value( + route.text, + { method: 'GET', path: request.path, query: request.query }, + request.signal, + ); + }, + }; +}; + +export const createFakeGitHubTransport = (routes: ReadonlyArray) => + createFakeTransport('GitHub', routes); + +export const createFakeGitLabTransport = (routes: ReadonlyArray) => + createFakeTransport('GitLab', routes); diff --git a/vite.config.ts b/vite.config.ts index 2f5e327e..835c4e67 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -26,6 +26,8 @@ export default defineConfig({ 'pnpm-lock.yaml', 'core/__generated__/', 'core/node_modules/', + 'gitlab/node_modules/', + 'gitlab/dist/', 'service/node_modules/', 'service/dist/', 'web/.fate/', @@ -42,6 +44,8 @@ export default defineConfig({ 'dist/', 'electron/', 'core/node_modules/', + 'gitlab/node_modules/', + 'gitlab/dist/', 'service/node_modules/', 'service/dist/', 'web/.fate/', @@ -101,18 +105,27 @@ export default defineConfig({ }, }, }, + server: { + watch: { + // Jujutsu operation files change with local history, not product source. + ignored: ['**/.jj/**'], + }, + }, staged: { '*': 'vp check --fix', }, test: { include: [ 'core/**/*.test.{ts,tsx}', + 'gitlab/**/*.test.ts', + 'github/**/*.test.ts', 'electron/**/*.test.ts', 'service/**/*.test.ts', 'web/**/*.test.{ts,tsx}', ], maxWorkers: testWorkers, setupFiles: ['./core/__tests__/setup.ts'], + testTimeout: 15_000, }, worker: { format: 'es', diff --git a/web/db/migrations/0003_share_comment_position.sql b/web/db/migrations/0003_share_comment_position.sql new file mode 100644 index 00000000..4681e2cc --- /dev/null +++ b/web/db/migrations/0003_share_comment_position.sql @@ -0,0 +1 @@ +ALTER TABLE `ShareCommentThread` ADD COLUMN `positionJson` text; diff --git a/web/src/sharing/ShareComments.tsx b/web/src/sharing/ShareComments.tsx index bf60cbf1..b33f866b 100644 --- a/web/src/sharing/ShareComments.tsx +++ b/web/src/sharing/ShareComments.tsx @@ -28,6 +28,7 @@ export const ShareCommentThreadView = view()({ lineNumber: true, messages: ShareCommentMessageConnectionView, planId: true, + positionJson: true, resolvedAt: true, sectionId: true, side: true, diff --git a/web/src/sharing/WalkthroughPage.test.tsx b/web/src/sharing/WalkthroughPage.test.tsx index 82370173..8ee53f35 100644 --- a/web/src/sharing/WalkthroughPage.test.tsx +++ b/web/src/sharing/WalkthroughPage.test.tsx @@ -28,6 +28,9 @@ const auth = vi.hoisted(() => ({ const rendered = vi.hoisted(() => ({ props: null as null | Record, })); +const shareThreads = vi.hoisted(() => ({ + threads: [] as Array, +})); vi.mock('@nkzw/codiff-service/react', () => ({ resolveSubmittedShareReply: vi.fn(), @@ -46,7 +49,8 @@ vi.mock('react-fate', () => ({ vi.mock('void/client/react', () => ({ auth })); vi.mock('./ShareComments.tsx', () => ({ ShareCommentMessageView: {}, - ShareComments: ({ children }: { children: (threads: []) => unknown }) => children([]), + ShareComments: ({ children }: { children: (threads: Array) => unknown }) => + children(shareThreads.threads), ShareCommentThreadConnectionView: { items: { node: {} } }, ShareCommentThreadView: {}, })); @@ -65,8 +69,8 @@ beforeEach(() => { container = document.createElement('div'); document.body.append(container); root = createRoot(container); - window.localStorage.clear(); rendered.props = null; + shareThreads.threads = []; fate.useFateClient.mockReset().mockReturnValue(fate.client); fate.useRequest.mockReset().mockReturnValue({ walkthroughBySlug: { __typename: 'Walkthrough', id: 'walkthrough-id' }, @@ -263,3 +267,203 @@ test('preserves snapshot defaults for missing device-local preferences', async ( wordWrap: true, }); }); + +test('persists an unpositioned diff comment with its legacy section ID', async () => { + await act(async () => { + root.render( + + + , + ); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + const commenting = rendered.props?.commenting as { + onSubmitComment(comment: { + body: string; + filePath: string; + lineNumber: number; + sectionId: string; + side: 'additions'; + }): Promise; + }; + await commenting.onSubmitComment({ + body: 'Review this line.', + filePath: 'src/review.ts', + lineNumber: 12, + sectionId: 'src/review.ts:commit:second', + side: 'additions', + }); + + expect(fate.client.mutations.shareComment.createThread).toHaveBeenCalledWith( + expect.objectContaining({ + input: { + body: 'Review this line.', + shareId: 'walkthrough-id', + shareType: 'walkthrough', + target: { + filePath: 'src/review.ts', + kind: 'walkthrough-diff', + lineNumber: 12, + sectionId: 'src/review.ts:commit:second', + side: 'additions', + }, + }, + optimistic: expect.objectContaining({ + filePath: 'src/review.ts', + kind: 'walkthrough-diff', + lineNumber: 12, + positionJson: null, + sectionId: 'src/review.ts:commit:second', + side: 'additions', + }), + view: {}, + }), + ); +}); + +test('persists a positioned diff comment without its legacy section ID', async () => { + await act(async () => { + root.render( + + + , + ); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + const position = { + range: { + base: { + kind: 'commit' as const, + label: { kind: 'commit' as const, text: 'a' }, + sha: 'a'.repeat(40), + }, + head: { + kind: 'commit' as const, + label: { kind: 'commit' as const, text: 'b' }, + sha: 'b'.repeat(40), + }, + }, + }; + const commenting = rendered.props?.commenting as { + onSubmitComment(comment: { + body: string; + filePath: string; + lineNumber: number; + position: typeof position; + sectionId: string; + side: 'additions'; + }): Promise; + }; + await commenting.onSubmitComment({ + body: 'Review this durable position.', + filePath: 'src/review.ts', + lineNumber: 12, + position, + sectionId: 'src/review.ts:commit:second', + side: 'additions', + }); + + expect(fate.client.mutations.shareComment.createThread).toHaveBeenCalledWith( + expect.objectContaining({ + input: expect.objectContaining({ + target: { + filePath: 'src/review.ts', + kind: 'walkthrough-diff', + lineNumber: 12, + position, + side: 'additions', + }, + }), + optimistic: expect.objectContaining({ + positionJson: JSON.stringify(position), + sectionId: null, + }), + }), + ); + const request = fate.client.mutations.shareComment.createThread.mock.calls[0]![0]; + expect(request.input.target).not.toHaveProperty('sectionId'); +}); + +test('loads durable positions before falling back to legacy section IDs', async () => { + shareThreads.threads = [ + { + filePath: 'src/repeated.ts', + id: 'legacy-thread', + kind: 'walkthrough-diff', + lineNumber: 8, + messages: [ + { + authorImage: null, + authorName: 'Ada Lovelace', + authorUsername: 'ada', + body: 'Keep this comment on the second repeated patch.', + canEdit: false, + createdAt: new Date('2026-07-22T00:00:00.000Z'), + id: 'legacy-message', + threadId: 'legacy-thread', + updatedAt: new Date('2026-07-22T00:00:00.000Z'), + }, + ], + positionJson: null, + sectionId: 'src/repeated.ts:commit:second', + side: 'additions', + startLineNumber: null, + startSide: null, + status: 'open', + }, + { + filePath: 'src/repeated.ts', + id: 'durable-thread', + kind: 'walkthrough-diff', + lineNumber: 8, + messages: [ + { + authorImage: null, + authorName: 'Ada Lovelace', + authorUsername: 'ada', + body: 'Use the durable range instead.', + canEdit: false, + createdAt: new Date('2026-07-22T00:00:00.000Z'), + id: 'durable-message', + threadId: 'durable-thread', + updatedAt: new Date('2026-07-22T00:00:00.000Z'), + }, + ], + positionJson: JSON.stringify({ + range: { + base: { kind: 'commit', label: { kind: 'commit', text: 'a' }, sha: 'a'.repeat(40) }, + head: { kind: 'commit', label: { kind: 'commit', text: 'b' }, sha: 'b'.repeat(40) }, + }, + }), + sectionId: 'src/repeated.ts:commit:first', + side: 'additions', + startLineNumber: null, + startSide: null, + status: 'open', + }, + ]; + + await act(async () => { + root.render( + + + , + ); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + const reviewComments = ( + rendered.props!.snapshot as { reviewComments: Array> } + ).reviewComments; + expect(reviewComments.find((comment) => comment.id === 'legacy-message')).toMatchObject({ + sectionId: 'src/repeated.ts:commit:second', + }); + expect(reviewComments.find((comment) => comment.id === 'durable-message')).toMatchObject({ + position: expect.any(Object), + }); + expect(reviewComments.find((comment) => comment.id === 'durable-message')).not.toHaveProperty( + 'sectionId', + ); +}); diff --git a/web/src/sharing/WalkthroughPage.tsx b/web/src/sharing/WalkthroughPage.tsx index b4cfc979..91f93b4d 100644 --- a/web/src/sharing/WalkthroughPage.tsx +++ b/web/src/sharing/WalkthroughPage.tsx @@ -2,6 +2,7 @@ import { type PullRequestExistingReviewComment, type PullRequestGeneralCommentThread, type PullRequestReviewComment, + type ReviewCommentPosition, type SharedWalkthroughSnapshot, } from '@nkzw/codiff-core'; import { @@ -64,6 +65,20 @@ const reviewAuthor = (message: ShareCommentMessageValue) => ({ name: message.authorName, }); +const parseCommentPosition = ( + value: string | null | undefined, +): ReviewCommentPosition | undefined => { + if (!value) { + return undefined; + } + try { + const parsed = JSON.parse(value); + return parsed && typeof parsed === 'object' ? (parsed as ReviewCommentPosition) : undefined; + } catch { + return undefined; + } +}; + const reviewComments = ( threads: ReadonlyArray, signedIn: boolean, @@ -77,12 +92,12 @@ const reviewComments = ( if (!fileComment && (thread.lineNumber == null || thread.side == null)) { return []; } + const position = parseCommentPosition(thread.positionJson); return thread.messages.map((message) => ({ ...(fileComment ? { anchor: 'file' as const } : { lineNumber: thread.lineNumber!, - ...(thread.sectionId ? { sectionId: thread.sectionId } : {}), side: thread.side!, ...(thread.startLineNumber ? { startLineNumber: thread.startLineNumber } : {}), ...(thread.startSide ? { startSide: thread.startSide } : {}), @@ -95,6 +110,8 @@ const reviewComments = ( filePath: thread.filePath!, id: message.id, ...(thread.status === 'resolved' ? { isThreadResolved: true } : {}), + ...(position ? { position } : {}), + ...(!position && thread.sectionId ? { sectionId: thread.sectionId } : {}), submittedAt: toISOString(message.createdAt), threadId: thread.id, })); @@ -131,6 +148,7 @@ const commentTarget = (comment: PullRequestReviewComment) => { anchor: 'file' as const, filePath: comment.filePath, kind: 'walkthrough-diff' as const, + ...(comment.position ? { position: comment.position } : {}), }; } if (comment.lineNumber == null || !comment.side) { @@ -141,7 +159,8 @@ const commentTarget = (comment: PullRequestReviewComment) => { filePath: comment.filePath, kind: 'walkthrough-diff' as const, lineNumber: comment.lineNumber, - ...(comment.sectionId ? { sectionId: comment.sectionId } : {}), + ...(comment.position ? { position: comment.position } : {}), + ...(!comment.position && comment.sectionId ? { sectionId: comment.sectionId } : {}), side: comment.side, ...(comment.startLineNumber ? { startLineNumber: comment.startLineNumber } : {}), ...(comment.startSide ? { startSide: comment.startSide } : {}), @@ -152,7 +171,7 @@ const Viewer = ({ walkthrough: walkthroughRef }: { walkthrough: ViewRef<'Walkthr const fate = useFateClient(); const walkthrough = useLiveView(WalkthroughPageView, walkthroughRef); const snapshot = use(getManifest(walkthrough.slug)); - usePageTitle(snapshot.walkthrough.title); + usePageTitle(snapshot.repository.title); const [preferences, setPreferences] = useOnlineCodiffPreferences(snapshot.preferences ?? {}); const { data: session } = auth.useSession(); const username = sessionUsername(session?.user); @@ -289,6 +308,7 @@ const Viewer = ({ walkthrough: walkthroughRef }: { walkthrough: ViewRef<'Walkthr }, ], planId: null, + positionJson: target.position ? JSON.stringify(target.position) : null, resolvedAt: null, sectionId: fileComment ? null : (target.sectionId ?? null), side: fileComment ? null : target.side,