diff --git a/.browserslistrc b/.browserslistrc index f3e4b2c5b..b3989fc1b 100644 --- a/.browserslistrc +++ b/.browserslistrc @@ -1,5 +1,6 @@ # Browser support targets for the entire monorepo -# Targets browsers with native light-dark() support (May 2024+) +# Bounded by native Intl.Segmenter support, which the diffs editor relies on: +# Firefox 125+ (Chrome 87+, Edge 87+, Safari 14.1+ — already covered below). # # Used by: # - apps/docs (Next.js + Tailwind CSS v4) @@ -8,4 +9,4 @@ chrome >= 123 edge >= 123 safari >= 17.5 -firefox >= 120 +firefox >= 125 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dac6a912e..dae4b6793 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -83,7 +83,7 @@ jobs: run: >- moon ci --include-relations --summary detailed :build demo:build diffshub:build docs:build docs:build-trees-site path-store:build-demo - :test :typecheck trees:test-e2e root:lint root:lint-css + :test :typecheck trees:test-e2e diffs:test-e2e root:lint root:lint-css actions-pinned: name: Actions pinned to SHA diff --git a/apps/demo/index.html b/apps/demo/index.html index aba77e5e8..2266839ac 100644 --- a/apps/demo/index.html +++ b/apps/demo/index.html @@ -30,10 +30,28 @@ Wrap + -
+
+ +
diff --git a/apps/demo/package.json b/apps/demo/package.json index 4a0527de6..4196bcaf2 100644 --- a/apps/demo/package.json +++ b/apps/demo/package.json @@ -6,6 +6,8 @@ "type": "module", "dependencies": { "@pierre/diffs": "workspace:*", + "@pierre/icons": "catalog:", + "diff": "catalog:", "react": "catalog:", "react-dom": "catalog:", "shiki": "catalog:" diff --git a/apps/demo/src/codeViewDemo.ts b/apps/demo/src/codeViewDemo.ts index bd8f36fdf..27ba2d8e7 100644 --- a/apps/demo/src/codeViewDemo.ts +++ b/apps/demo/src/codeViewDemo.ts @@ -6,6 +6,7 @@ import { type CodeViewOptions, type DiffLineAnnotation, type DiffsThemeNames, + type FileDiffContentsLoader, type LineAnnotation, type ParsedPatch, type SelectedLineRange, @@ -14,6 +15,9 @@ import { import type { WorkerPoolManager } from '@pierre/diffs/worker'; import { FAKE_DIFF_LINE_ANNOTATIONS, type LineCommentMetadata } from './mocks/'; +import { createHeaderFilenameSuffixBadge } from './utils/createHeaderFilenameSuffixBadge'; + +const RENDER_FILENAME_SUFFIX = false; type CodeViewCommentMetadata = | CodeViewSavedCommentMetadata @@ -58,6 +62,7 @@ interface RenderDemoCodeViewOptions { overflow: CodeViewOverflow; theme: DiffsThemeNames | ThemesType; themeType: CodeViewThemeType; + loadDiffFiles?: FileDiffContentsLoader; workerManager?: WorkerPoolManager; } @@ -81,6 +86,7 @@ export function renderDemoCodeView( overflow, theme, themeType, + loadDiffFiles, workerManager, }: RenderDemoCodeViewOptions ) { @@ -88,18 +94,43 @@ export function renderDemoCodeView( const items = createCodeViewItems(parsedPatches); const options: CodeViewOptions = { + // __devOnlyValidateItemHeights: true, theme, themeType, diffStyle, overflow, + loadDiffFiles, renderAnnotation(annotation) { return renderCodeViewAnnotation(annotation, viewer, items); }, + ...(RENDER_FILENAME_SUFFIX + ? { + renderHeaderFilenameSuffix() { + return createHeaderFilenameSuffixBadge('CodeView slot'); + }, + } + : null), lineHoverHighlight: 'both', expansionLineCount: 10, enableLineSelection: true, enableGutterUtility: true, stickyHeaders: true, + // renderCodeViewHeader() { + // const el = document.createElement('div'); + // el.textContent = 'CodeView header — scrolls with the content'; + // el.style.padding = '20px'; + // el.style.textAlign = 'center'; + // el.style.borderBottom = + // '1px solid var(--color-border, rgba(128, 128, 128, 0.3))'; + // return el; + // }, + // renderCodeViewFooter() { + // const el = document.createElement('div'); + // el.textContent = 'CodeView footer 🫶'; + // el.style.padding = '40px'; + // el.style.textAlign = 'center'; + // return el; + // }, layout: { paddingTop: 10, paddingBottom: 24, gap: 12 }, onGutterUtilityClick(range, context) { if (context.item.type !== 'diff') { diff --git a/apps/demo/src/main.ts b/apps/demo/src/main.ts index f49380c22..df8115565 100644 --- a/apps/demo/src/main.ts +++ b/apps/demo/src/main.ts @@ -5,6 +5,7 @@ import { File, type FileContents, FileDiff, + type FileDiffContentsLoader, type FileDiffOptions, type FileOptions, FileStream, @@ -21,7 +22,16 @@ import { VirtualizedFileDiff, Virtualizer, } from '@pierre/diffs'; +import { Editor } from '@pierre/diffs/editor'; import type { WorkerPoolManager } from '@pierre/diffs/worker'; +import { + IconCiFailedOctagonFill, + IconCiWarningFill, + IconInfoFill, +} from '@pierre/icons'; +import { createTwoFilesPatch } from 'diff'; +import { createElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; import { cleanupCodeView, @@ -42,6 +52,7 @@ import './style.css'; import mdContent from './mocks/example_md.txt?raw'; import tsContent from './mocks/example_ts.txt?raw'; import { createFakeContentStream } from './utils/createFakeContentStream'; +import { createHeaderFilenameSuffixBadge } from './utils/createHeaderFilenameSuffixBadge'; import { createHighlighterCleanup } from './utils/createHighlighterCleanup'; import { createWorkerAPI } from './utils/createWorkerAPI'; import { @@ -56,7 +67,36 @@ const WORKER_POOL = true; const VIRTUALIZE = true; const CRAZY_FILE = false; const LARGE_CONFLICT_FILE = false; -const CODE_VIEW_OLD_NEW_FILE = true; +const RENDER_FILENAME_SUFFIX = false; +const CODE_VIEW_TYPE: 'old-new-full' | 'old-new-hydration' | 'patch-file' = + 'old-new-full'; + +// Pre-render the @pierre/icons SVG markup once so it can be embedded into the +// `message.html` strings the editor injects for markers. The icons default to +// `fill: currentcolor`, so each one inherits the surrounding text color. +const MARKER_INFO_ICON = renderToStaticMarkup( + createElement(IconInfoFill, { size: 16 }) +); +const MARKER_WARNING_ICON = renderToStaticMarkup( + createElement(IconCiWarningFill, { size: 16 }) +); +const MARKER_ERROR_ICON = renderToStaticMarkup( + createElement(IconCiFailedOctagonFill, { size: 16 }) +); + +// Builds the HTML for a marker overlay: a leading icon and message with an +// indented description. The popover is severity-colored (see editor.css), so +// the icon and text inherit white instead of painting their own color, which +// would vanish against the fill. +function markerMessage(opts: { + icon: string; + message: string; + description: string; +}): string { + const iconCol = `${opts.icon}`; + const textCol = `
${opts.message}
${opts.description}
`; + return `
${iconCol}${textCol}
`; +} const FileStreamCodeConfigs: FileStreamCodeConfigsItem[] = [ { @@ -93,6 +133,14 @@ interface FileStreamCodeConfigsItem { options: FileStreamOptions; } +interface HydratableRenderOptions { + loadDiffFiles?: FileDiffContentsLoader; +} + +interface CodeViewRenderData extends HydratableRenderOptions { + parsedPatches: ParsedPatch[]; +} + function cleanupInstances(container: HTMLElement) { for (const instances of [ diffInstances, @@ -108,8 +156,18 @@ function cleanupInstances(container: HTMLElement) { cleanupCodeView(container); container.textContent = ''; delete container.dataset.diff; + editShortcutCallback = undefined; } +let editShortcutCallback: (() => boolean | void) | undefined; +document.addEventListener('keydown', (event) => { + if (event.key === 'e') { + if (editShortcutCallback?.() === false) { + event.preventDefault(); + } + } +}); + let loadingPatch: Promise | undefined; async function loadPatchContent() { loadingPatch = @@ -176,39 +234,139 @@ function startStreaming() { } let parsedPatches: ParsedPatch[] | undefined; -let parsedCodeViewFilePatches: ParsedPatch[] | undefined; -function createCodeViewFilePatches(): ParsedPatch[] { +function createCodeViewFiles(): { + oldFile: FileContents; + newFile: FileContents; +} { + return { + oldFile: { + name: 'file_old.ts', + contents: FILE_OLD, + cacheKey: 'code-view-file-old', + }, + newFile: { + name: 'file_new.ts', + contents: FILE_NEW, + cacheKey: 'code-view-file-new', + }, + }; +} + +function createCodeViewNoTrailingExpansionFiles(): { + oldFile: FileContents; + newFile: FileContents; +} { const oldFile: FileContents = { - name: 'file_old.ts', - contents: FILE_OLD, - cacheKey: 'code-view-file-old', + name: 'no_trailing_expansion.ts', + contents: [ + 'const keepOne = "same";\n', + 'const keepTwo = "same";\n', + 'const keepThree = "same";\n', + 'const keepFour = "same";\n', + 'export const noTrailingExpansion = "old";\n', + ].join(''), + cacheKey: 'code-view-no-trailing-expansion-old', }; const newFile: FileContents = { - name: 'file_new.ts', - contents: FILE_NEW, - cacheKey: 'code-view-file-new', + name: oldFile.name, + contents: [ + 'const keepOne = "same";\n', + 'const keepTwo = "same";\n', + 'const keepThree = "same";\n', + 'const keepFour = "same";\n', + 'export const noTrailingExpansion = "new";\n', + ].join(''), + cacheKey: 'code-view-no-trailing-expansion-new', }; + return { oldFile, newFile }; +} +function createCodeViewFullPatches( + oldFile: FileContents, + newFile: FileContents +): ParsedPatch[] { return [{ files: [parseDiffFromFile(oldFile, newFile)] }]; } -async function loadCodeViewPatches(): Promise { - if (CODE_VIEW_OLD_NEW_FILE) { - return (parsedCodeViewFilePatches ??= createCodeViewFilePatches()); +function createCodeViewPartialPatches( + oldFile: FileContents, + newFile: FileContents +): ParsedPatch[] { + return parsePatchFiles( + createTwoFilesPatch( + oldFile.name, + newFile.name, + oldFile.contents, + newFile.contents, + oldFile.header, + newFile.header + ), + 'code-view-partial' + ); +} + +function createCodeViewNoTrailingExpansionPartialPatches( + oldFile: FileContents, + newFile: FileContents +): ParsedPatch[] { + return parsePatchFiles( + createTwoFilesPatch( + oldFile.name, + newFile.name, + oldFile.contents, + newFile.contents, + oldFile.header, + newFile.header, + { context: 0 } + ), + 'code-view-no-trailing-expansion-partial' + ); +} + +async function loadCodeViewPatches(): Promise { + switch (CODE_VIEW_TYPE) { + case 'old-new-full': { + const { oldFile, newFile } = createCodeViewFiles(); + return { parsedPatches: createCodeViewFullPatches(oldFile, newFile) }; + } + case 'old-new-hydration': { + const { oldFile, newFile } = createCodeViewFiles(); + const noTrailingExpansionFiles = createCodeViewNoTrailingExpansionFiles(); + return { + parsedPatches: [ + ...createCodeViewNoTrailingExpansionPartialPatches( + noTrailingExpansionFiles.oldFile, + noTrailingExpansionFiles.newFile + ), + ...createCodeViewPartialPatches(oldFile, newFile), + ], + loadDiffFiles(fileDiff) { + console.log( + 'CodeView partial hydration demo loading full files', + fileDiff + ); + if (fileDiff.name === noTrailingExpansionFiles.newFile.name) { + return Promise.resolve(noTrailingExpansionFiles); + } + return Promise.resolve({ oldFile, newFile }); + }, + }; + } + case 'patch-file': + return { + parsedPatches: (parsedPatches ??= parsePatchFiles( + await loadPatchContent(), + 'parsed-patch' + )), + }; } - return (parsedPatches ??= parsePatchFiles( - await loadPatchContent(), - 'parsed-patch' - )); } function handlePreloadCodeViewDiff() { - if (CODE_VIEW_OLD_NEW_FILE) { - parsedCodeViewFilePatches ??= createCodeViewFilePatches(); - return; + if (CODE_VIEW_TYPE === 'patch-file') { + void handlePreloadDiff(); } - void handlePreloadDiff(); } async function handlePreloadDiff() { @@ -239,15 +397,42 @@ function renderDiff(parsedPatches: ParsedPatch[], manager?: WorkerPoolManager) { const patchAnnotations = FAKE_DIFF_LINE_ANNOTATIONS[patchIndex] ?? []; let hunkIndex = 0; for (const fileDiff of parsedPatch.files) { + const editor = new Editor({ + onAttach: (editor) => { + editor.setSelections([ + { + start: { + line: 3, + character: 1000, // will be normalized to the end of the line(< 1000 chars) + }, + end: { + line: 3, + character: 1000, // will be normalized to the end of the line(< 1000 chars) + }, + direction: 'none', + }, + ]); + }, + __debug: true, + }); const fileAnnotations = patchAnnotations[hunkIndex]; + let isEditing = false; const options: FileDiffOptions = { theme: DEMO_THEME, themeType, diffStyle: unified ? 'unified' : 'split', overflow: wrap ? 'wrap' : 'scroll', renderAnnotation: renderDiffAnnotation, + ...(RENDER_FILENAME_SUFFIX + ? { + renderHeaderFilenameSuffix() { + return createHeaderFilenameSuffixBadge('Diff slot'); + }, + } + : null), renderHeaderMetadata() { - return createCollapsedToggle( + const collapseToggle = createToggle( + 'Collapse', instance?.options.collapsed ?? false, (checked) => { instance?.setOptions({ @@ -259,6 +444,32 @@ function renderDiff(parsedPatches: ParsedPatch[], manager?: WorkerPoolManager) { } } ); + const editableToggle = createToggle( + 'Editable', + isEditing, + (checked) => { + isEditing = checked; + if (isEditing) { + editor.edit(instance); + } else { + editor.cleanUp(); + } + } + ); + editShortcutCallback = (): boolean | void => { + if (!isEditing) { + editableToggle.querySelector('input')?.click(); + return false; + } + }; + const div = document.createElement('div'); + div.style.display = 'flex'; + div.style.gap = '8px'; + div.append(collapseToggle); + if (!fileDiff.isPartial) { + div.append(editableToggle); + } + return div; }, lineHoverHighlight: 'both', expansionLineCount: 10, @@ -444,7 +655,10 @@ function renderDiff(parsedPatches: ParsedPatch[], manager?: WorkerPoolManager) { // window.scrollTo({ top: 70747 }); } -function renderCodeView(parsedPatches: ParsedPatch[]) { +function renderCodeView( + parsedPatches: ParsedPatch[], + { loadDiffFiles }: HydratableRenderOptions = {} +) { const wrapper = document.getElementById('wrapper'); if (wrapper == null) return; window.scrollTo({ top: 0 }); @@ -454,6 +668,7 @@ function renderCodeView(parsedPatches: ParsedPatch[]) { themeType: getThemeType(), diffStyle: getUnified() ? 'unified' : 'split', overflow: getWrapped() ? 'wrap' : 'scroll', + loadDiffFiles, workerManager: poolManager, }); } @@ -547,7 +762,8 @@ const renderCodeViewButton = document.getElementById('render-code-view'); if (renderCodeViewButton != null) { renderCodeViewButton.addEventListener('click', () => { void (async () => { - renderCodeView(await loadCodeViewPatches()); + const { parsedPatches, loadDiffFiles } = await loadCodeViewPatches(); + renderCodeView(parsedPatches, { loadDiffFiles }); })(); }); renderCodeViewButton.addEventListener( @@ -735,7 +951,7 @@ const fileConflict: FileContents = { const renderFileButton = document.getElementById('render-file'); if (renderFileButton != null) { - // oxlint-disable-next-line @typescript-oxlint/no-misused-promises + // oxlint-disable-next-line typescript/no-misused-promises renderFileButton.addEventListener('click', async () => { const file = await fileExample; const wrapper = document.getElementById('wrapper'); @@ -744,15 +960,121 @@ if (renderFileButton != null) { virtualizer?.setup(globalThis.document); const wrap = getWrapped(); + const editor = new Editor({ + enabledSelectionAction: true, + renderSelectionAction: (ctx) => { + const div = document.createElement('div'); + const button = document.createElement('button'); + button.innerText = `Comment the selection`; + button.addEventListener('click', () => { + const lines = ctx.getSelectionText().split('\n'); + const comment = lines + .map((line) => (line.startsWith('//') ? line : `// ${line}`)) + .join('\n'); + ctx.replaceSelectionText(comment); + ctx.close(); + }); + div.appendChild(button); + return div; + }, + onChange: (file, lineAnnotations) => { + console.log('change', file, lineAnnotations); + }, + onAttach: (editor) => { + const { selections } = editor.getState(); + if (selections === undefined || selections.length === 0) { + editor.setSelections([ + { + start: { + line: 0, + character: 1000, // will be normalized to the end of the line(< 1000 chars) + }, + end: { + line: 0, + character: 1000, // will be normalized to the end of the line(< 1000 chars) + }, + direction: 'none', + }, + ]); + editor.setMarkers([ + { + start: { + line: 1, + character: 2, + }, + end: { + line: 1, + character: 1000, // will be normalized to the end of the line(< 1000 chars) + }, + severity: 'info', + message: { + html: markerMessage({ + icon: MARKER_INFO_ICON, + message: 'CodeOptionsMultipleThemes', + description: 'Code options of multiple themes.', + }), + }, + }, + { + start: { + line: 2, + character: 2, + }, + end: { + line: 2, + character: 1000, // will be normalized to the end of the line(< 1000 chars) + }, + severity: 'warning', + message: { + html: markerMessage({ + icon: MARKER_WARNING_ICON, + message: 'CodeToHastOptions', + description: 'Code to Hast Options is deprecated.', + }), + }, + }, + { + start: { + line: 3, + character: 2, + }, + end: { + line: 3, + character: 1000, // will be normalized to the end of the line(< 1000 chars) + }, + severity: 'error', + message: { + html: markerMessage({ + icon: MARKER_ERROR_ICON, + message: 'DecorationItem', + description: 'Type not defined.', + }), + }, + }, + ]); + } + }, + __debug: true, + }); + Object.assign(window, { editor }); const fileContainer = document.createElement(DIFFS_TAG_NAME); wrapper.appendChild(fileContainer); + let isEditing = false; const options: FileOptions = { overflow: wrap ? 'wrap' : 'scroll', theme: DEMO_THEME, themeType: getThemeType(), renderAnnotation, + ...(RENDER_FILENAME_SUFFIX + ? { + renderHeaderFilenameSuffix() { + return createHeaderFilenameSuffixBadge('File slot'); + }, + } + : null), renderHeaderMetadata() { - return createCollapsedToggle( + const collapsedToggle = createToggle( + 'Collapse', instance?.options.collapsed ?? false, (checked) => { instance?.setOptions({ @@ -764,6 +1086,29 @@ if (renderFileButton != null) { } } ); + const editableToggle = createToggle( + 'Editable', + isEditing, + (checked) => { + isEditing = checked; + if (isEditing) { + editor.edit(instance); + } else { + editor.cleanUp(); + } + } + ); + editShortcutCallback = (): boolean | void => { + if (!isEditing) { + editableToggle.querySelector('input')?.click(); + return false; + } + }; + const div = document.createElement('div'); + div.style.display = 'flex'; + div.style.gap = '8px'; + div.append(collapsedToggle, editableToggle); + return div; }, // Line selection stuff @@ -869,7 +1214,7 @@ if (renderFileButton != null) { const renderFileConflictButton = document.getElementById('render-conflict'); if (renderFileConflictButton != null) { - // oxlint-disable-next-line @typescript-oxlint/no-misused-promises + // oxlint-disable-next-line typescript/no-misused-promises renderFileConflictButton.addEventListener('click', async () => { const wrapper = document.getElementById('wrapper'); if (wrapper == null) { @@ -885,6 +1230,13 @@ if (renderFileConflictButton != null) { themeType: getThemeType(), overflow: wrap ? 'wrap' : 'scroll', renderAnnotation, + ...(RENDER_FILENAME_SUFFIX + ? { + renderHeaderFilenameSuffix() { + return createHeaderFilenameSuffixBadge('Conflict slot'); + }, + } + : null), enableLineSelection: true, enableGutterUtility: true, maxContextLines: 4, @@ -953,7 +1305,34 @@ cleanButton?.addEventListener('click', () => { cleanupInstances(container); }); -function createCollapsedToggle( +const lagRadarCheckbox = document.getElementById('lag-radar'); +const radar = document.getElementById('radar'); +if (lagRadarCheckbox != null && radar != null) { + const { default: lagRadar } = + // @ts-expect-error dynamic import + await import('https://mobz.github.io/lag-radar/lag-radar.js'); + let dispose: (() => void) | undefined; + lagRadarCheckbox.addEventListener('change', () => { + if ( + lagRadarCheckbox instanceof HTMLInputElement && + lagRadarCheckbox.checked + ) { + dispose = lagRadar({ + parent: radar, + size: 100, + frames: 60, + }); + radar.style.display = 'block'; + } else { + dispose?.(); + dispose = undefined; + radar.style.display = 'none'; + } + }); +} + +function createToggle( + labelText: string, checked: boolean, onChange: (checked: boolean) => void ): HTMLElement { @@ -966,7 +1345,7 @@ function createCollapsedToggle( }); label.dataset.collapser = ''; label.appendChild(input); - label.append(' Collapse'); + label.appendChild(document.createTextNode(` ${labelText}`)); return label; } diff --git a/apps/demo/src/style.css b/apps/demo/src/style.css index 9e9d57c60..862edfa3d 100644 --- a/apps/demo/src/style.css +++ b/apps/demo/src/style.css @@ -211,9 +211,20 @@ code { flex-direction: row-reverse; } -diffs-container { - /* content-visibility: auto; */ - margin-top: 2px; +.header-filename-suffix-badge { + display: inline-flex; + align-items: center; + flex: none; + min-height: 18px; + padding-inline: 6px; + border: 1px solid color-mix(in srgb, #7c3aed 45%, transparent); + border-radius: 999px; + background: light-dark(#f5f3ff, color-mix(in srgb, #7c3aed 22%, transparent)); + color: light-dark(#5b21b6, #ddd6fe); + font-size: 11px; + font-weight: 700; + line-height: 16px; + white-space: nowrap; } .annotation-slot-wrapper { @@ -253,3 +264,11 @@ diffs-container { align-items: center; gap: 4px; } + +[data-selection-action-popover] button { + border-color: rgba(128, 128, 128, 0.4); + + &:hover { + border-color: #646cff; + } +} diff --git a/apps/demo/src/utils/createHeaderFilenameSuffixBadge.ts b/apps/demo/src/utils/createHeaderFilenameSuffixBadge.ts new file mode 100644 index 000000000..f138fac0b --- /dev/null +++ b/apps/demo/src/utils/createHeaderFilenameSuffixBadge.ts @@ -0,0 +1,6 @@ +export function createHeaderFilenameSuffixBadge(label: string): HTMLElement { + const badge = document.createElement('span'); + badge.className = 'header-filename-suffix-badge'; + badge.textContent = label; + return badge; +} diff --git a/apps/demo/src/utils/createWorkerAPI.ts b/apps/demo/src/utils/createWorkerAPI.ts index a6e06d5b7..c2f4f84f0 100644 --- a/apps/demo/src/utils/createWorkerAPI.ts +++ b/apps/demo/src/utils/createWorkerAPI.ts @@ -6,12 +6,18 @@ import { // oxlint-disable-next-line import/default -- Vite worker URL provides a default export import WorkerUrl from '@pierre/diffs/worker/worker.js?worker&url'; +// HACK(debug): Quick way to test worker initialization failure cases if needed +const FORCE_WORKER_POOL_FAILURE = false; + export function createWorkerAPI( highlighterOptions: WorkerInitializationRenderOptions ): WorkerPoolManager { return getOrCreateWorkerPoolSingleton({ poolOptions: { workerFactory() { + if (FORCE_WORKER_POOL_FAILURE) { + throw new Error('HACK: forced worker pool initialization failure'); + } return new Worker(WorkerUrl, { type: 'module' }); }, poolSize: 3, diff --git a/apps/diffshub/app/_home/HomeFetchForm.tsx b/apps/diffshub/app/_home/HomeFetchForm.tsx index b1e716d13..79272aab4 100644 --- a/apps/diffshub/app/_home/HomeFetchForm.tsx +++ b/apps/diffshub/app/_home/HomeFetchForm.tsx @@ -10,7 +10,7 @@ import { DiffUrlForm } from '@/components/DiffUrlForm'; // viewer route owns fetching and renders its own loading state there. export const HomeFetchForm = memo(function HomeFetchForm() { return ( -
+
+ ); +}); diff --git a/apps/diffshub/app/_home/HomePage.tsx b/apps/diffshub/app/_home/HomePage.tsx index 6bd97e4cd..e8a0c5b24 100644 --- a/apps/diffshub/app/_home/HomePage.tsx +++ b/apps/diffshub/app/_home/HomePage.tsx @@ -6,13 +6,14 @@ import { } from '@pierre/icons'; import Link from 'next/link'; +import { HomeFetchForm } from './HomeFetchForm'; +import { HomeGitHubTokenForm } from './HomeGitHubTokenForm'; import { DiffsHubLogo } from '@/components/DiffsHubLogo'; import { getGitHubPath } from '@/lib/getGitHubPath'; const DIFF_LINE_BADGE = 'inline-flex rounded-r py-0.25 pr-1.5 pl-1.5'; const DIFF_LINE_DELETED_BADGE = `${DIFF_LINE_BADGE} bg-[#ff6762]/15 text-[#ff2e3f] dark:bg-[#ff6762]/10 dark:text-[#ff6762]`; const DIFF_LINE_ADDED_BADGE = `${DIFF_LINE_BADGE} bg-[#07c480]/15 text-[#18a46c] dark:bg-[#07c480]/10 dark:text-[#07c480]`; -import { HomeFetchForm } from './HomeFetchForm'; function Divider() { return
; @@ -68,7 +69,10 @@ export function HomePage() { .com/org/repo/pull/number
- +
+ + +

Enter a URL above, or use one of these: diff --git a/apps/diffshub/app/api/diff/route.ts b/apps/diffshub/app/api/diff/route.ts index 93d606b83..5c3b1da90 100644 --- a/apps/diffshub/app/api/diff/route.ts +++ b/apps/diffshub/app/api/diff/route.ts @@ -1,7 +1,18 @@ import { type NextRequest } from 'next/server'; +import { + encodeURLSegment, + type GitHubDiffSource, + type GitHubRepo, + parseGitHubDiffSource, +} from '@/lib/githubDiffSource'; + const CACHE_CONTROL = 'no-store'; const EMPTY_PATCH_MESSAGE = 'GitHub returned an empty diff.'; +const GITHUB_API_ROOT = 'https://api.github.com'; +const GITHUB_API_VERSION = '2022-11-28'; +const GITHUB_DIFF_MEDIA_TYPE = 'application/vnd.github.diff'; +const GITHUB_JSON_MEDIA_TYPE = 'application/vnd.github+json'; const GITHUB_HOST = 'github.com'; const GITHUB_RAW_DIFF_HOST = 'patch-diff.githubusercontent.com'; const NON_DIFF_RESPONSE_MESSAGE = 'GitHub did not return a diff for this URL.'; @@ -38,11 +49,35 @@ const HIDDEN_PATCH_DOMAIN_RULES = [ { domainRoot: 'tangled.org', defaultExtension: '.patch' }, ] as const; -interface ResolvedPatchRequest { +interface DirectPatchFetchTarget { + kind?: 'direct'; + label?: string; patchURL: string; + requestHeaders?: Record; sourceURL?: string; } +interface GitHubPullPatchFetchTarget { + kind: 'github-pull'; + label?: string; + pullURL: string; + repo: GitHubRepo; + requestHeaders: Record; + sourceURL: string; + token: string; +} + +type PatchFetchTarget = DirectPatchFetchTarget | GitHubPullPatchFetchTarget; + +interface ResolvedPatchRequest extends DirectPatchFetchTarget { + fallbacks?: PatchFetchTarget[]; +} + +interface PatchFetchResult { + response: Response; + target: DirectPatchFetchTarget; +} + // Validates the accepted path or URL, normalizes it to a raw diff URL, and // returns a streaming proxy response so the client can render files as they // arrive instead of waiting for the full patch text. @@ -51,6 +86,7 @@ export async function GET(request: NextRequest) { const path = searchParams.get('path'); const domain = searchParams.get('domain'); const url = searchParams.get('url'); + const token = parseBearerToken(request.headers.get('authorization')); if (path == null && url == null) { return createTextResponse('Path or URL parameter is required', { @@ -63,20 +99,14 @@ export async function GET(request: NextRequest) { // exposes raw PR diffs through patch-diff.githubusercontent.com. Tangled // paths use an explicit domain query parameter and are normalized to their // patch endpoint. - const patchRequest = resolvePatchRequest(path, domain, url); + const patchRequest = resolvePatchRequest(path, domain, url, token); if (patchRequest == null) { return createTextResponse('Invalid GitHub patch URL format', { status: 400, }); } - return await createPatchStreamResponse( - patchRequest.patchURL, - request.signal, - { - sourceURL: patchRequest.sourceURL ?? patchRequest.patchURL, - } - ); + return await createPatchStreamResponse(patchRequest, request.signal); } catch (error) { return createTextResponse( error instanceof Error ? error.message : 'Unknown error', @@ -91,10 +121,11 @@ export async function GET(request: NextRequest) { function resolvePatchRequest( path: string | null, domain: string | null, - url: string | null + url: string | null, + token: string | undefined ): ResolvedPatchRequest | undefined { if (url != null) { - return resolvePatchURLInput(url); + return resolvePatchURLInput(url, token); } if (path == null) { @@ -106,12 +137,15 @@ function resolvePatchRequest( return patchURL == null ? undefined : { patchURL }; } - return resolvePatchURLInput(path); + return resolvePatchURLInput(path, token); } -function resolvePatchURLInput(input: string): ResolvedPatchRequest | undefined { +function resolvePatchURLInput( + input: string, + token: string | undefined +): ResolvedPatchRequest | undefined { if (input.startsWith('/')) { - return resolveGitHubPatchRequest(input); + return resolveGitHubPatchRequest(input, token); } let parsedURL: URL; @@ -126,14 +160,35 @@ function resolvePatchURLInput(input: string): ResolvedPatchRequest | undefined { } if (parsedURL.hostname === GITHUB_HOST) { - return resolveGitHubPatchRequest(parsedURL.pathname); + return resolveGitHubPatchRequest(parsedURL.pathname, token); } if ( parsedURL.hostname === GITHUB_RAW_DIFF_HOST && RAW_GITHUB_DIFF_PATH_PATTERN.test(parsedURL.pathname) ) { - return { patchURL: parsedURL.href }; + const publicRequest: ResolvedPatchRequest = { + label: 'public patch-diff URL', + patchURL: parsedURL.href, + }; + if (token != null) { + const gitHubPath = parsedURL.pathname.slice('/raw'.length); + const authenticatedWebRequest = resolveAuthenticatedGitHubWebPatchRequest( + gitHubPath, + token + ); + const authenticatedAPIRequest = resolveAuthenticatedGitHubPatchRequest( + gitHubPath, + token + ); + return { + ...publicRequest, + fallbacks: [authenticatedWebRequest, authenticatedAPIRequest].filter( + isPatchFetchTarget + ), + }; + } + return publicRequest; } const domainPatchURL = resolveDomainPatchURL( @@ -144,10 +199,87 @@ function resolvePatchURLInput(input: string): ResolvedPatchRequest | undefined { } function resolveGitHubPatchRequest( - path: string + path: string, + token: string | undefined ): ResolvedPatchRequest | undefined { const patchURL = resolveGitHubPath(path); - return patchURL == null ? undefined : { patchURL }; + const publicRequest = + patchURL == null + ? undefined + : ({ + label: 'public github.com diff URL', + patchURL, + } satisfies ResolvedPatchRequest); + if (token != null) { + const authenticatedWebRequest = resolveAuthenticatedGitHubWebPatchRequest( + path, + token + ); + const authenticatedAPIRequest = resolveAuthenticatedGitHubPatchRequest( + path, + token + ); + if (publicRequest != null) { + return { + ...publicRequest, + fallbacks: [authenticatedWebRequest, authenticatedAPIRequest].filter( + isPatchFetchTarget + ), + }; + } + if (authenticatedAPIRequest?.kind !== 'github-pull') { + return authenticatedAPIRequest; + } + } + + return publicRequest; +} + +function resolveAuthenticatedGitHubWebPatchRequest( + path: string, + token: string +): DirectPatchFetchTarget | undefined { + const patchURL = resolveGitHubPath(path); + if (patchURL == null) { + return undefined; + } + return { + label: 'authenticated github.com diff URL', + patchURL, + requestHeaders: createGitHubAuthHeaders(token), + }; +} + +function resolveAuthenticatedGitHubPatchRequest( + path: string, + token: string +): PatchFetchTarget | undefined { + const normalizedPath = normalizeGitHubPath(path); + const source = parseGitHubDiffSource(normalizedPath); + if (source == null) { + return undefined; + } + + const sourceURL = `https://${GITHUB_HOST}${removeDiffExtension(normalizedPath)}`; + if (source.kind === 'pull') { + return { + kind: 'github-pull', + label: 'authenticated pull metadata', + pullURL: createGitHubDiffAPIURL(source), + repo: source.repo, + requestHeaders: createGitHubJSONAPIHeaders(token), + sourceURL, + token, + }; + } + + return createGitHubDiffTarget(source, token, sourceURL); +} + +function isPatchFetchTarget( + target: PatchFetchTarget | undefined +): target is PatchFetchTarget { + return target != null; } function resolveDomainPatchURL( @@ -245,6 +377,78 @@ function isAllowedHTTPSURL(url: URL): boolean { ); } +function createGitHubDiffAPIURL(source: GitHubDiffSource): string { + switch (source.kind) { + case 'pull': + return createGitHubAPIURL( + `/repos/${encodeURLSegment(source.repo.owner)}/${encodeURLSegment(source.repo.repo)}/pulls/${encodeURLSegment(source.number)}` + ); + case 'commit': + return createGitHubAPIURL( + `/repos/${encodeURLSegment(source.repo.owner)}/${encodeURLSegment(source.repo.repo)}/commits/${encodeURLSegment(source.sha)}` + ); + case 'compare': + return createGitHubAPIURL( + `/repos/${encodeURLSegment(source.repo.owner)}/${encodeURLSegment(source.repo.repo)}/compare/${encodeURLSegment(source.range)}` + ); + } +} + +function createGitHubDiffTarget( + source: Exclude, + token: string, + sourceURL: string +): DirectPatchFetchTarget { + return { + label: `authenticated ${source.kind} diff API`, + patchURL: createGitHubDiffAPIURL(source), + requestHeaders: createGitHubDiffAPIHeaders(token), + sourceURL, + }; +} + +function createGitHubAuthHeaders(token: string): Record { + return { + Authorization: `Bearer ${token}`, + }; +} + +function createGitHubAPIURL(path: string): string { + return new URL(path, GITHUB_API_ROOT).href; +} + +function createGitHubDiffAPIHeaders(token: string): Record { + return { + Accept: GITHUB_DIFF_MEDIA_TYPE, + Authorization: `Bearer ${token}`, + 'X-GitHub-Api-Version': GITHUB_API_VERSION, + }; +} + +function createGitHubJSONAPIHeaders(token: string): Record { + return { + Accept: GITHUB_JSON_MEDIA_TYPE, + Authorization: `Bearer ${token}`, + 'X-GitHub-Api-Version': GITHUB_API_VERSION, + }; +} + +function parseBearerToken(value: string | null): string | undefined { + if (value == null) { + return undefined; + } + + const match = /^Bearer\s+(.+)$/i.exec(value.trim()); + const token = match?.[1]?.trim(); + return token == null || token === '' ? undefined : token; +} + +function getAuthorizationToken( + requestHeaders: Record | undefined +): string | undefined { + return parseBearerToken(requestHeaders?.Authorization ?? null); +} + interface TextResponseOptions { status?: number; sourceURL?: string; @@ -268,46 +472,64 @@ function createPatchTextResponse( // GitHub HTML pages and redirects become small text errors instead of Next.js // error documents. async function createPatchStreamResponse( - patchURL: string, - requestSignal: AbortSignal, - options: Omit + patchRequest: ResolvedPatchRequest, + requestSignal: AbortSignal ): Promise { const upstreamController = new AbortController(); const abortUpstream = () => upstreamController.abort(); requestSignal.addEventListener('abort', abortUpstream, { once: true }); - let response: Response; - try { - response = await fetch(patchURL, { - cache: 'no-store', - headers: { 'User-Agent': 'pierre-diffshub' }, - signal: upstreamController.signal, - }); - } catch { - requestSignal.removeEventListener('abort', abortUpstream); - return createTextResponse('Failed to fetch patch.', { status: 502 }); - } + let activeRequest: PatchFetchTarget = patchRequest; + const fallbackRequests = [...(patchRequest.fallbacks ?? [])]; + let response: Response | undefined; + let responseTarget: DirectPatchFetchTarget | undefined; + for (;;) { + try { + const fetchResult = await fetchPatchTarget( + activeRequest, + upstreamController.signal + ); + response = fetchResult.response; + responseTarget = fetchResult.target; + } catch { + const fallbackRequest = fallbackRequests.shift(); + if (fallbackRequest != null) { + activeRequest = fallbackRequest; + continue; + } - if (!response.ok) { - const status = response.status >= 400 ? response.status : 502; - requestSignal.removeEventListener('abort', abortUpstream); - return createTextResponse( - `Failed to fetch patch: ${response.status} ${response.statusText}`, - { status } - ); - } + requestSignal.removeEventListener('abort', abortUpstream); + return createTextResponse('Failed to fetch patch.', { status: 502 }); + } + + const failure = await getPatchResponseFailure(response, responseTarget); + if (failure == null) { + break; + } + + const fallbackRequest = fallbackRequests.shift(); + if (fallbackRequest != null) { + await response.body?.cancel().catch(() => undefined); + activeRequest = fallbackRequest; + continue; + } - const contentType = response.headers.get('Content-Type'); - if (contentType == null || !contentType.startsWith('text/plain')) { requestSignal.removeEventListener('abort', abortUpstream); - return createTextResponse(NON_DIFF_RESPONSE_MESSAGE, { status: 415 }); + return createTextResponse(failure.message, { + status: failure.status, + sourceURL: responseTarget.sourceURL ?? responseTarget.patchURL, + }); } - if (response.headers.get('Content-Length') === '0') { + if (response == null || responseTarget == null) { requestSignal.removeEventListener('abort', abortUpstream); - return createTextResponse(EMPTY_PATCH_MESSAGE, { status: 422 }); + return createTextResponse('Failed to fetch patch.', { status: 502 }); } + const options = { + sourceURL: responseTarget.sourceURL ?? responseTarget.patchURL, + } satisfies Omit; + const responseBody = response.body; if (responseBody == null) { try { @@ -333,6 +555,247 @@ async function createPatchStreamResponse( return createTextResponse(stream, options); } +function fetchPatchTarget( + target: PatchFetchTarget, + signal: AbortSignal +): Promise { + if (target.kind === 'github-pull') { + return fetchGitHubPullPatchTarget(target, signal); + } + + return fetchDirectPatchTarget(target, signal); +} + +async function fetchDirectPatchTarget( + target: DirectPatchFetchTarget, + signal: AbortSignal +): Promise { + const response = await fetch(target.patchURL, { + cache: 'no-store', + headers: { 'User-Agent': 'pierre-diffshub', ...target.requestHeaders }, + signal, + }); + return { response, target }; +} + +async function fetchGitHubPullPatchTarget( + target: GitHubPullPatchFetchTarget, + signal: AbortSignal +): Promise { + const pullResponse = await fetch(target.pullURL, { + cache: 'no-store', + headers: { 'User-Agent': 'pierre-diffshub', ...target.requestHeaders }, + signal, + }); + + const pullTarget: DirectPatchFetchTarget = { + label: target.label, + patchURL: target.pullURL, + requestHeaders: target.requestHeaders, + sourceURL: target.sourceURL, + }; + if (!pullResponse.ok) { + return { response: pullResponse, target: pullTarget }; + } + + const pullData = await pullResponse.json(); + const baseSha = readStringPath(pullData, ['base', 'sha']); + const headSha = readStringPath(pullData, ['head', 'sha']); + const baseRepo = readRepoFullName(pullData, ['base', 'repo', 'full_name']); + const headRepo = readRepoFullName(pullData, ['head', 'repo', 'full_name']); + if (baseSha == null || headSha == null) { + return { + response: new Response('GitHub pull response did not include refs.', { + status: 502, + }), + target: pullTarget, + }; + } + + const compareBaseRepo = baseRepo ?? target.repo; + const compareHeadRepo = headRepo ?? compareBaseRepo; + const compareRange = isSameGitHubRepo(compareBaseRepo, compareHeadRepo) + ? `${baseSha}...${headSha}` + : `${compareBaseRepo.owner}:${baseSha}...${compareHeadRepo.owner}:${headSha}`; + + return fetchDirectPatchTarget( + { + patchURL: createGitHubAPIURL( + `/repos/${encodeURLSegment(compareBaseRepo.owner)}/${encodeURLSegment(compareBaseRepo.repo)}/compare/${encodeURLSegment(compareRange)}` + ), + label: 'authenticated pull compare diff API', + requestHeaders: createGitHubDiffAPIHeaders(target.token), + sourceURL: target.sourceURL, + }, + signal + ); +} + +async function getPatchResponseFailure( + response: Response, + target: DirectPatchFetchTarget +): Promise<{ message: string; status: number } | undefined> { + if (!response.ok) { + const status = response.status >= 400 ? response.status : 502; + const authHint = await getGitHubAuthFailureHint(response, target); + return { + status, + message: `Failed to fetch patch from ${target.label ?? 'upstream'}: ${response.status} ${response.statusText}.${authHint}`, + }; + } + + const contentType = response.headers.get('Content-Type'); + if (contentType == null || !isDiffContentType(contentType)) { + return { status: 415, message: NON_DIFF_RESPONSE_MESSAGE }; + } + + if (response.headers.get('Content-Length') === '0') { + return { status: 422, message: EMPTY_PATCH_MESSAGE }; + } + + return undefined; +} + +async function getGitHubAuthFailureHint( + response: Response, + target: DirectPatchFetchTarget +): Promise { + const token = getAuthorizationToken(target.requestHeaders); + if ( + token == null || + (response.status !== 401 && + response.status !== 403 && + response.status !== 404) + ) { + return ''; + } + + const tokenStatus = await fetchGitHubDiagnosticStatus('/user', token); + if (tokenStatus === 401) { + return ' GitHub rejected the token as invalid or expired.'; + } + if (tokenStatus === 403) { + return ' GitHub accepted the token but blocked it. Check SSO authorization, rate limits, or token policy.'; + } + if (tokenStatus !== 200) { + return ' GitHub token validation failed; check that the token is still valid.'; + } + + const source = readGitHubSourceFromURL(target.sourceURL); + if (source == null) { + return ' GitHub accepted the token, but the patch endpoint was not accessible.'; + } + + const repoStatus = await fetchGitHubDiagnosticStatus( + `/repos/${encodeURLSegment(source.repo.owner)}/${encodeURLSegment(source.repo.repo)}`, + token + ); + if (repoStatus === 401) { + return ' GitHub rejected the token as invalid or expired.'; + } + if (repoStatus === 403) { + return ` GitHub accepted the token but blocked access to ${source.repo.owner}/${source.repo.repo}. Check SSO authorization, rate limits, or token policy.`; + } + if (repoStatus === 404) { + return ` GitHub accepted the token, but it cannot access ${source.repo.owner}/${source.repo.repo}. For a fine-grained token, select this repository and grant Contents: read and Pull requests: read.`; + } + + if (source.kind === 'pull') { + return ` GitHub accepted the token and repository access, but pull request #${source.number} was not readable. Grant Pull requests: read or confirm the PR exists.`; + } + + return ' GitHub accepted the token, but the requested diff was not readable.'; +} + +async function fetchGitHubDiagnosticStatus( + path: string, + token: string +): Promise { + try { + const response = await fetch(createGitHubAPIURL(path), { + cache: 'no-store', + headers: { + 'User-Agent': 'pierre-diffshub', + ...createGitHubJSONAPIHeaders(token), + }, + }); + return response.status; + } catch { + return 0; + } +} + +function readGitHubSourceFromURL( + sourceURL: string | undefined +): GitHubDiffSource | undefined { + if (sourceURL == null) { + return undefined; + } + + try { + const url = new URL(sourceURL); + if (url.hostname !== GITHUB_HOST) { + return undefined; + } + return parseGitHubDiffSource(url.pathname); + } catch { + return undefined; + } +} + +function readRepoFullName( + data: unknown, + path: readonly string[] +): GitHubRepo | undefined { + const fullName = readStringPath(data, path); + if (fullName == null) { + return undefined; + } + + const separatorIndex = fullName.indexOf('/'); + if (separatorIndex <= 0 || separatorIndex === fullName.length - 1) { + return undefined; + } + return { + owner: fullName.slice(0, separatorIndex), + repo: fullName.slice(separatorIndex + 1), + }; +} + +function isSameGitHubRepo(a: GitHubRepo, b: GitHubRepo): boolean { + return ( + a.owner.toLowerCase() === b.owner.toLowerCase() && + a.repo.toLowerCase() === b.repo.toLowerCase() + ); +} + +function readStringPath( + data: unknown, + path: readonly string[] +): string | undefined { + let current = data; + for (const key of path) { + if (!isRecord(current)) { + return undefined; + } + current = current[key]; + } + return typeof current === 'string' ? current : undefined; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function isDiffContentType(contentType: string): boolean { + const normalizedContentType = contentType.toLowerCase(); + return ( + normalizedContentType.startsWith('text/plain') || + (normalizedContentType.includes('application/vnd.github') && + normalizedContentType.includes('diff')) + ); +} + // Forwards each validated upstream diff chunk into the client stream. async function pumpPatchBody( body: ReadableStream, @@ -377,6 +840,7 @@ function createTextResponse( const headers = new Headers({ 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': CACHE_CONTROL, + Vary: 'Authorization', }); if (sourceURL != null) { headers.set('X-Patch-Source', sourceURL); diff --git a/apps/diffshub/app/api/github-diff-file/route.ts b/apps/diffshub/app/api/github-diff-file/route.ts new file mode 100644 index 000000000..6b5d63e13 --- /dev/null +++ b/apps/diffshub/app/api/github-diff-file/route.ts @@ -0,0 +1,82 @@ +import type { ChangeTypes } from '@pierre/diffs'; +import { type NextRequest } from 'next/server'; + +import { loadGitHubDiffFiles } from '@/lib/githubDiffFileServer'; + +const CACHE_CONTROL = 'no-store'; +const CHANGE_TYPES = new Set([ + 'change', + 'deleted', + 'new', + 'rename-changed', + 'rename-pure', +]); + +export async function GET(request: NextRequest) { + const params = request.nextUrl.searchParams; + const path = params.get('path'); + const name = params.get('name'); + const type = parseChangeType(params.get('type')); + const prevName = params.get('prevName') ?? undefined; + const token = parseBearerToken(request.headers.get('authorization')); + + if (path == null || name == null || type == null) { + return createJSONResponse( + { error: 'path, name, and supported type parameters are required.' }, + { status: 400 } + ); + } + + if (token == null) { + return createJSONResponse( + { error: 'GitHub file expansion requires a configured token.' }, + { status: 401 } + ); + } + + try { + return createJSONResponse( + await loadGitHubDiffFiles( + { name, path, prevName, type }, + { token, tokenSource: 'request' } + ) + ); + } catch (error) { + return createJSONResponse( + { error: error instanceof Error ? error.message : 'Unknown error' }, + { status: 502 } + ); + } +} + +function parseBearerToken(value: string | null): string | undefined { + if (value == null) { + return undefined; + } + + const match = /^Bearer\s+(.+)$/i.exec(value.trim()); + const token = match?.[1]?.trim(); + return token == null || token === '' ? undefined : token; +} + +function parseChangeType(value: string | null): ChangeTypes | undefined { + if (value == null) { + return undefined; + } + return CHANGE_TYPES.has(value as ChangeTypes) + ? (value as ChangeTypes) + : undefined; +} + +function createJSONResponse( + body: unknown, + options: { status?: number } = {} +): Response { + return Response.json(body, { + status: options.status ?? 200, + headers: { + 'Cache-Control': CACHE_CONTROL, + Vary: 'Authorization', + }, + }); +} diff --git a/apps/diffshub/components/DiffsHubHeader.tsx b/apps/diffshub/components/DiffsHubHeader.tsx index 6b7445b37..24317949f 100644 --- a/apps/diffshub/components/DiffsHubHeader.tsx +++ b/apps/diffshub/components/DiffsHubHeader.tsx @@ -41,6 +41,7 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from '@/components/DropdownMenu'; +import { GitHubTokenControl } from '@/components/GitHubTokenControl'; import { Switch } from '@/components/Switch'; import { docsThemeCatalog } from '@/components/themeCatalog'; import { cn } from '@/lib/cn'; @@ -62,10 +63,13 @@ interface HeaderProps { diffStyle: 'split' | 'unified'; fileTreeAvailable: boolean; fileTreeOverlayOpen: boolean; + githubTokenActive: boolean; initialUrl: string; lightThemeName: LightThemeName; lineNumbers: boolean; overflow: 'wrap' | 'scroll'; + onClearGitHubToken(): void; + onSaveGitHubToken(token: string): void; onToggleCollapseMode(): void; onToggleFileTreeOverlay(): void; setColorMode(mode: ColorMode): void; @@ -88,10 +92,13 @@ export const DiffsHubHeader = memo(function DiffsHubHeader({ diffStyle, fileTreeAvailable, fileTreeOverlayOpen, + githubTokenActive, initialUrl, lightThemeName, lineNumbers, overflow, + onClearGitHubToken, + onSaveGitHubToken, onToggleCollapseMode, onToggleFileTreeOverlay, setColorMode, @@ -238,9 +245,15 @@ export const DiffsHubHeader = memo(function DiffsHubHeader({ + +
e.preventDefault()} diff --git a/apps/diffshub/components/DiffsHubSidebar.tsx b/apps/diffshub/components/DiffsHubSidebar.tsx index f6cf0051b..7b490e079 100644 --- a/apps/diffshub/components/DiffsHubSidebar.tsx +++ b/apps/diffshub/components/DiffsHubSidebar.tsx @@ -1,5 +1,6 @@ 'use client'; +import type { CodeViewHandle } from '@pierre/diffs/react'; import { IconComment, IconFileTree, @@ -46,6 +47,7 @@ import { getDiffsHubFileTreeAvailableStatuses } from '@/lib/getDiffsHubFileTreeA import { diffshubChromeMapping } from '@/lib/theme/diffshubChromeMapping'; import { getDropdownThemeStyle } from '@/lib/theme/dropdownChromeStyle'; import type { + CommentMetadata, DiffsHubDiffStats as DiffsHubDiffStatsData, DiffsHubFileTreeSource, DiffsHubSavedCommentEntry, @@ -69,6 +71,7 @@ interface DiffsHubSidebarProps { source: DiffsHubFileTreeSource; streaming: boolean; themeCycle: ThemeCycleControls; + viewerRef: RefObject | null>; } export const DiffsHubSidebar = memo(function DiffsHubSidebar({ @@ -83,6 +86,7 @@ export const DiffsHubSidebar = memo(function DiffsHubSidebar({ source, streaming, themeCycle, + viewerRef, }: DiffsHubSidebarProps) { const [activeTab, setActiveTab] = useState('files'); let totalCommentCount = 0; @@ -314,7 +318,7 @@ export const DiffsHubSidebar = memo(function DiffsHubSidebar({ toggleStatusPanel('systemMonitor')} - scrollRef={scrollRef} + viewerRef={viewerRef} themeCycle={themeCycle} /> diff --git a/apps/diffshub/components/DiffsHubViewer.tsx b/apps/diffshub/components/DiffsHubViewer.tsx index 218673f81..91b4791e2 100644 --- a/apps/diffshub/components/DiffsHubViewer.tsx +++ b/apps/diffshub/components/DiffsHubViewer.tsx @@ -6,6 +6,7 @@ import { type CodeViewOptions, type DiffIndicators, type DiffLineAnnotation, + type FileDiffContentsLoader, type LineAnnotation, type SelectedLineRange, type ThemeTypes, @@ -74,6 +75,7 @@ interface DiffsHubViewerProps { themeType: ThemeTypes; viewerRef: RefObject | null>; initialItems: CodeViewItem[]; + loadDiffFiles?: FileDiffContentsLoader; onLineLinkChange(selection: CodeViewLineSelection | null): void; onViewerReady(): void; } @@ -91,6 +93,7 @@ export const DiffsHubViewer = memo(function DiffsHubViewer({ themeType, viewerRef, initialItems, + loadDiffFiles, onLineLinkChange, onViewerReady, }: DiffsHubViewerProps) { @@ -431,6 +434,7 @@ export const DiffsHubViewer = memo(function DiffsHubViewer({ diffStyle, diffIndicators, overflow, + loadDiffFiles, disableBackground: !showBackgrounds, disableLineNumbers: !lineNumbers, lineHoverHighlight: 'number', @@ -456,6 +460,7 @@ export const DiffsHubViewer = memo(function DiffsHubViewer({ handleCreateDraftComment, handleLineSelectionEnd, lineNumbers, + loadDiffFiles, overflow, showBackgrounds, themeType, diff --git a/apps/diffshub/components/GitHubTokenControl.tsx b/apps/diffshub/components/GitHubTokenControl.tsx new file mode 100644 index 000000000..f6c0ba070 --- /dev/null +++ b/apps/diffshub/components/GitHubTokenControl.tsx @@ -0,0 +1,121 @@ +'use client'; + +import { IconBrandGithub } from '@pierre/icons'; +import { type FormEvent, memo, useState } from 'react'; + +import { Button } from '@/components/Button'; +import { Input } from '@/components/Input'; +import { cn } from '@/lib/cn'; + +export const CREATE_TOKEN_URL = + 'https://github.com/settings/personal-access-tokens/new?name=DiffsHub%20Private%20Repo%20Read%20Access&description=Read+private+PRs+and+expand+collapsed+hunks&expires_in=90&contents=read&pull_requests=write&issues=write'; + +export const CLASSIC_TOKEN_URL = + 'https://github.com/settings/tokens/new?description=DiffsHub%20Private%20Repo%20Read%20Access&scopes=repo&default_expires_at=90'; + +interface GitHubTokenControlProps { + active: boolean; + className?: string; + onClear(): void; + onSave(token: string): void; + title?: string; +} + +export const GitHubTokenControl = memo(function GitHubTokenControl({ + active, + className, + onClear, + onSave, + title = 'GitHub Token', +}: GitHubTokenControlProps) { + const [draftToken, setDraftToken] = useState(''); + const canSave = draftToken.trim() !== ''; + const handleSubmit = (event: FormEvent) => { + event.preventDefault(); + if (!canSave) { + return; + } + onSave(draftToken); + setDraftToken(''); + }; + + return ( +
+
+ + {title} + + {active ? 'Active' : 'Optional'} + +
+ {active ? ( + <> +

+ Using your PAT from localStorage. Clear it to create a new one. +

+
+ +
+ + ) : ( + <> +

+ + Create a fine-grained PAT + {' '} + on GitHub to view private diffs, or{' '} + + a classic token + {' '} + with repo scope. Saved only in localStorage. +

+
+ + setDraftToken(currentTarget.value) + } + /> + +
+ + )} +
+ ); +}); diff --git a/apps/diffshub/components/Input.tsx b/apps/diffshub/components/Input.tsx new file mode 100644 index 000000000..41938781c --- /dev/null +++ b/apps/diffshub/components/Input.tsx @@ -0,0 +1,42 @@ +import { cva, type VariantProps } from 'class-variance-authority'; +import * as React from 'react'; + +import { cn } from '@/lib/cn'; + +// Sizes are kept in lockstep with `Button` so an Input + Button paired in the +// same row line up at the same height/radius/padding rhythm. +const inputVariants = cva( + 'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input w-full min-w-0 border bg-transparent shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:border-0 file:bg-transparent file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive', + { + variants: { + inputSize: { + default: + 'h-9 rounded-md px-3 py-1 text-base file:h-7 file:text-sm md:text-sm', + lg: 'h-10 rounded-md px-4 text-base file:h-8 file:text-sm', + sm: 'h-8 rounded-md px-2 py-1 text-xs', + }, + }, + defaultVariants: { + inputSize: 'default', + }, + } +); + +// `inputSize` is used instead of `size` because `size` is a native HTML input +// attribute (character-width hint) and we don't want our variant prop to +// shadow it. +export type InputProps = Omit, 'size'> & + VariantProps; + +function Input({ className, type, inputSize, ...props }: InputProps) { + return ( + + ); +} + +export { Input, inputVariants }; diff --git a/apps/diffshub/components/ReviewUI.tsx b/apps/diffshub/components/ReviewUI.tsx index 49d586e2e..3a656f098 100644 --- a/apps/diffshub/components/ReviewUI.tsx +++ b/apps/diffshub/components/ReviewUI.tsx @@ -8,6 +8,7 @@ import { type ReactNode, useCallback, useEffect, + useMemo, useRef, useState, } from 'react'; @@ -17,6 +18,7 @@ import { DiffsHubSidebar } from './DiffsHubSidebar'; import { DiffsHubStatusPanel } from './DiffsHubStatusPanel'; import { DiffsHubViewer } from './DiffsHubViewer'; import { ThemeSourceProvider } from './ThemeSourceProvider'; +import { useGitHubToken } from './useGitHubToken'; import { usePatchLoader } from './usePatchLoader'; import { useThemeCycle } from './useThemeCycle'; import { @@ -24,6 +26,7 @@ import { themeController, } from '@/components/themeController'; import { preloadAvatars } from '@/lib/annotation'; +import { createGitHubDiffFileLoader } from '@/lib/githubDiffFileLoader'; import { removeSavedCommentSidebarEntry } from '@/lib/removeSavedCommentSidebarEntry'; import type { DarkThemeName, LightThemeName } from '@/lib/themeNames'; import type { @@ -63,6 +66,22 @@ function ReviewUIInner({ domain, initialUrl, path }: ReviewUIProps) { const [showBackgrounds, setShowBackgrounds] = useState(true); const [diffIndicators, setDiffIndicators] = useState('bars'); const [lineNumbers, setLineNumbers] = useState(true); + const { + clearToken: clearGitHubToken, + hasToken: hasGitHubToken, + setToken: setGitHubToken, + token: githubToken, + tokenVersion: githubTokenVersion, + } = useGitHubToken(); + const githubTokenRef = useRef(githubToken); + const githubTokenVersionRef = useRef(githubTokenVersion); + useEffect(() => { + githubTokenRef.current = githubToken; + }, [githubToken]); + useEffect(() => { + githubTokenVersionRef.current = githubTokenVersion; + }, [githubTokenVersion]); + const getGitHubToken = useCallback(() => githubTokenRef.current, []); // All theming state — color mode and the light/dark theme-name picks — lives // in the single @pierre/theming controller (the same instance the app-wide // ThemeProvider is bound to). Reading it here means picking Auto/Light/Dark @@ -117,6 +136,16 @@ function ReviewUIInner({ domain, initialUrl, path }: ReviewUIProps) { const scrollRef = useRef(null); const viewerRef = useRef | null>(null); + const loadDiffFiles = useMemo( + () => + domain == null && hasGitHubToken + ? createGitHubDiffFileLoader(path, { + getAuthVersion: () => githubTokenVersionRef.current, + getToken: () => githubTokenRef.current, + }) + : undefined, + [domain, hasGitHubToken, path] + ); const handlePatchLoadStart = useCallback(() => { setFileTreeOverlayOpen(false); }, []); @@ -137,6 +166,8 @@ function ReviewUIInner({ domain, initialUrl, path }: ReviewUIProps) { } = usePatchLoader({ collapseMode, domain, + getGitHubToken, + githubTokenVersion, onLoadStart: handlePatchLoadStart, path, viewerRef, @@ -246,6 +277,9 @@ function ReviewUIInner({ domain, initialUrl, path }: ReviewUIProps) { overflow={overflow} fileTreeOverlayOpen={fileTreeOverlayOpen} fileTreeAvailable={treeSource != null} + githubTokenActive={hasGitHubToken} + onClearGitHubToken={clearGitHubToken} + onSaveGitHubToken={setGitHubToken} onToggleCollapseMode={handleToggleCollapseMode} onToggleFileTreeOverlay={handleToggleFileTreeOverlay} setColorMode={setColorMode} @@ -271,6 +305,7 @@ function ReviewUIInner({ domain, initialUrl, path }: ReviewUIProps) { source={treeSource} streaming={loadState === 'streaming'} themeCycle={themeCycle} + viewerRef={viewerRef} onSelectItem={handleSelectTreeItem} /> { private running: 0 | 1 | 2 = 0; private direction = 1; constructor( - private scrollRef: RefObject, + private viewerRef: RefObject | null>, private onStateChange?: (running: boolean) => unknown ) {} @@ -47,10 +48,17 @@ class AutoScrollTester { } render = () => { - if (this.running === 0 || this.scrollRef.current == null) { + const { current: viewerHandle } = this.viewerRef; + if (this.running === 0 || viewerHandle == null) { return; } - const { scrollHeight, scrollTop, clientHeight } = this.scrollRef.current; + const viewer = viewerHandle.getInstance(); + if (viewer == null) { + return; + } + const scrollHeight = viewer.getScrollHeight(); + const scrollTop = viewer.getScrollTop(); + const clientHeight = viewer.getHeight(); // The first scroll tick should always attempt to scroll if (this.running === 1) { @@ -66,8 +74,9 @@ class AutoScrollTester { this.stop(); return; } - this.scrollRef.current.scrollTo({ - top: + viewerHandle.scrollTo({ + type: 'position', + position: scrollTop + clientHeight * 2 * this.direction + Math.random() * DEFAULT_CODE_VIEW_FILE_METRICS.lineHeight, @@ -92,15 +101,15 @@ class AutoScrollTester { interface WorkerPoolStatusProps { expanded: boolean; onToggle(): void; - scrollRef: RefObject; themeCycle: ThemeCycleControls; + viewerRef: RefObject | null>; } export const WorkerPoolStatus = memo(function WorkerPoolStatus({ expanded, onToggle, - scrollRef, themeCycle, + viewerRef, }: WorkerPoolStatusProps) { const pool = useWorkerPool(); const [stats, setStats] = useState(undefined); @@ -125,8 +134,8 @@ export const WorkerPoolStatus = memo(function WorkerPoolStatus({ expanded={expanded} onToggle={onToggle} stats={stats} - scrollRef={scrollRef} themeCycle={themeCycle} + viewerRef={viewerRef} /> ) ); @@ -136,8 +145,8 @@ interface StatsDisplayProps { expanded: boolean; onToggle(): void; stats: WorkerStats; - scrollRef: RefObject; themeCycle: ThemeCycleControls; + viewerRef: RefObject | null>; } // Map worker pool status to a single icon component + color so the legend row @@ -159,12 +168,12 @@ function StatsDisplay({ expanded, onToggle, stats, - scrollRef, themeCycle, + viewerRef, }: StatsDisplayProps) { const [isBrrt, setIsBrrt] = useState(false); const [scrollTester] = useState( - () => new AutoScrollTester(scrollRef, setIsBrrt) + () => new AutoScrollTester(viewerRef, setIsBrrt) ); // Mirror the inline (F3) hint with an actual keybinding so the label diff --git a/apps/diffshub/components/useGitHubToken.ts b/apps/diffshub/components/useGitHubToken.ts new file mode 100644 index 000000000..7dc1a4ec2 --- /dev/null +++ b/apps/diffshub/components/useGitHubToken.ts @@ -0,0 +1,68 @@ +'use client'; + +import { useCallback, useEffect, useState } from 'react'; + +const GITHUB_TOKEN_STORAGE_KEY = 'diffshub.github.token'; + +export interface GitHubTokenState { + clearToken(): void; + hasToken: boolean; + setToken(token: string): void; + token: string; + tokenVersion: number; +} + +// Owns the optional user-provided GitHub token. The token is persisted only in +// localStorage for this browser and is not sent anywhere until the loader +// explicitly reads it. +export function useGitHubToken(): GitHubTokenState { + const [token, setTokenState] = useState(''); + const [tokenVersion, setTokenVersion] = useState(0); + + useEffect(() => { + const storedToken = readStoredToken(); + if (storedToken !== '') { + setTokenState(storedToken); + setTokenVersion((version) => version + 1); + } + }, []); + + const setToken = useCallback((nextToken: string) => { + const normalizedToken = nextToken.trim(); + setTokenState(normalizedToken); + setTokenVersion((version) => version + 1); + writeStoredToken(normalizedToken); + }, []); + + const clearToken = useCallback(() => { + setToken(''); + }, [setToken]); + + return { + clearToken, + hasToken: token !== '', + setToken, + token, + tokenVersion, + }; +} + +function readStoredToken(): string { + try { + return globalThis.localStorage?.getItem(GITHUB_TOKEN_STORAGE_KEY) ?? ''; + } catch { + return ''; + } +} + +function writeStoredToken(token: string): void { + try { + if (token === '') { + globalThis.localStorage?.removeItem(GITHUB_TOKEN_STORAGE_KEY); + } else { + globalThis.localStorage?.setItem(GITHUB_TOKEN_STORAGE_KEY, token); + } + } catch { + // Browsers can disable storage; in-memory state still works for the page. + } +} diff --git a/apps/diffshub/components/usePatchLoader.ts b/apps/diffshub/components/usePatchLoader.ts index 4da2aa0ef..87537c975 100644 --- a/apps/diffshub/components/usePatchLoader.ts +++ b/apps/diffshub/components/usePatchLoader.ts @@ -56,6 +56,8 @@ const GENERIC_PATCH_LOAD_ERROR_MESSAGE = interface UsePatchLoaderOptions { collapseMode: 'expanded' | 'collapsed'; domain?: string; + getGitHubToken?(): string | undefined; + githubTokenVersion?: number | string; onLoadStart(): void; path: string; viewerRef: RefObject | null>; @@ -80,6 +82,8 @@ interface UsePatchLoaderResult { export function usePatchLoader({ collapseMode, domain, + getGitHubToken, + githubTokenVersion = 0, onLoadStart, path, viewerRef, @@ -268,12 +272,13 @@ export function usePatchLoader({ } } - console.time('-- request time'); - const response = await fetch(`/api/diff?${patchSearchParams}`, { - cache: 'no-store', - signal: controller.signal, - }); - console.timeEnd('-- request time'); + const response = await fetch( + `/api/diff?${patchSearchParams}`, + createPatchRequestInit( + controller.signal, + domain == null || domain === '' ? getGitHubToken?.() : undefined + ) + ); // This only catches route setup errors. GitHub fetch failures are // delivered while consuming the stream so the UI can enter the @@ -286,9 +291,7 @@ export function usePatchLoader({ } if (response.body == null) { - console.time('-- reading patch'); const patchContent = await response.text(); - console.timeEnd('-- reading patch'); await commitFullPatch(patchContent); return; } @@ -404,7 +407,6 @@ export function usePatchLoader({ const appendStreamedFile = async (fileText: string) => { if (!hasReceivedFirstStreamedFile) { hasReceivedFirstStreamedFile = true; - console.timeEnd('-- first streamed file'); } const patchMetadata = getStreamedPatchMetadata(fileText); @@ -450,13 +452,10 @@ export function usePatchLoader({ publishTreeSourceIfNeeded(); }; - console.time('-- first streamed file'); - console.time('-- reading patch stream'); const fallbackPatchContent = await streamGitPatchFiles( response.body, appendStreamedFile ); - console.timeEnd('-- reading patch stream'); if (!isCurrentRequest()) { return; } @@ -475,8 +474,7 @@ export function usePatchLoader({ if (!isCurrentRequest()) { return; } - console.warn('Failed to load diff', error); - setErrorMessage(GENERIC_PATCH_LOAD_ERROR_MESSAGE); + setErrorMessage(getPatchLoadErrorMessage(error)); setLoadState('error'); } } @@ -488,6 +486,8 @@ export function usePatchLoader({ }; }, [ domain, + getGitHubToken, + githubTokenVersion, loadAttempt, onLoadStart, path, @@ -576,6 +576,30 @@ function getNextItemVersion(item: { version?: string | number }): number { return typeof item.version === 'number' ? item.version + 1 : 1; } +function createPatchRequestInit( + signal: AbortSignal, + token: string | undefined +): RequestInit { + const normalizedToken = token?.trim(); + if (normalizedToken == null || normalizedToken === '') { + return { cache: 'no-store', signal }; + } + return { + cache: 'no-store', + headers: { + Authorization: `Bearer ${normalizedToken}`, + }, + signal, + }; +} + +function getPatchLoadErrorMessage(error: unknown): string { + if (error instanceof Error && error.message.trim() !== '') { + return error.message; + } + return GENERIC_PATCH_LOAD_ERROR_MESSAGE; +} + function replaceLocationHash(hash: string | null): void { const { pathname, search } = window.location; const nextHash = hash ?? ''; diff --git a/apps/diffshub/lib/diffsHubDataAccumulator.ts b/apps/diffshub/lib/diffsHubDataAccumulator.ts index a2c2d8111..85b6c0325 100644 --- a/apps/diffshub/lib/diffsHubDataAccumulator.ts +++ b/apps/diffshub/lib/diffsHubDataAccumulator.ts @@ -340,15 +340,12 @@ export function buildDiffsHubData( patchContent: string, githubPath: string ): LoadedDiffsHubData { - console.time('-- parsing patches'); const parsedPatches = parsePatchFiles( patchContent, // Use the url as a cache key encodeURIComponent(githubPath) ); - console.timeEnd('-- parsing patches'); - console.time('-- computing layout'); const accumulator = createDiffsHubDataAccumulator(); const shouldPrefixTreePaths = parsedPatches.length > 1; for (const [patchIndex, patch] of parsedPatches.entries()) { @@ -359,7 +356,6 @@ export function buildDiffsHubData( appendFileDiffToDiffsHubData(accumulator, fileDiff, treePathPrefix); } } - console.timeEnd('-- computing layout'); return snapshotDiffsHubData(accumulator); } diff --git a/apps/diffshub/lib/githubDiffFileLoader.ts b/apps/diffshub/lib/githubDiffFileLoader.ts new file mode 100644 index 000000000..3ea829d32 --- /dev/null +++ b/apps/diffshub/lib/githubDiffFileLoader.ts @@ -0,0 +1,212 @@ +import type { + FileContents, + FileDiffContentsLoader, + FileDiffLoadedFiles, + FileDiffMetadata, +} from '@pierre/diffs'; + +import { parseGitHubDiffSource } from './githubDiffSource'; + +type GitHubFileLoaderFetch = ( + input: Parameters[0], + init?: Parameters[1] +) => ReturnType; + +interface GitHubDiffFileLoaderOptions { + endpoint?: string; + fetch?: GitHubFileLoaderFetch; + getAuthVersion?(): number | string; + getToken?(): string | undefined; +} + +interface LoadedDiffFilesResponse { + oldFile: FileContents | null; + newFile: FileContents | null; +} + +// Creates a Diffs `loadDiffFiles` callback for GitHub routes supported by +// DiffsHub. Browser code only talks to DiffsHub's same-origin API route so the +// server can attach optional GitHub auth and share caches across viewers. +export function createGitHubDiffFileLoader( + path: string, + options: GitHubDiffFileLoaderOptions = {} +): FileDiffContentsLoader | undefined { + if (parseGitHubDiffSource(path) == null) { + return undefined; + } + + const endpoint = options.endpoint ?? '/api/github-diff-file'; + const fetcher = options.fetch ?? fetch; + const getAuthVersion = options.getAuthVersion ?? (() => 0); + const getToken = options.getToken ?? (() => undefined); + const loadedFilesCache = new Map>(); + + return (fileDiff) => { + switch (fileDiff.type) { + case 'new': + case 'deleted': + return Promise.reject( + new Error( + `DiffsHub GitHub file loader cannot hydrate ${fileDiff.type} diffs.` + ) + ); + case 'change': + case 'rename-changed': + case 'rename-pure': { + const cacheKey = `${getAuthVersion()}\0${getFileDiffVersion(fileDiff)}\0${fileDiff.type}\0${fileDiff.prevName ?? ''}\0${fileDiff.name}`; + const cached = loadedFilesCache.get(cacheKey); + if (cached != null) { + return cached; + } + + const promise = fetchLoadedDiffFiles( + endpoint, + path, + fileDiff.type, + fileDiff.name, + fileDiff.prevName, + getToken(), + fetcher + ).catch((error: unknown) => { + loadedFilesCache.delete(cacheKey); + throw error; + }); + loadedFilesCache.set(cacheKey, promise); + return promise; + } + } + }; +} + +function getFileDiffVersion(fileDiff: FileDiffMetadata): string { + return [ + fileDiff.cacheKey ?? '', + fileDiff.prevObjectId ?? '', + fileDiff.newObjectId ?? '', + ].join('\0'); +} + +async function fetchLoadedDiffFiles( + endpoint: string, + sourcePath: string, + type: string, + name: string, + prevName: string | undefined, + token: string | undefined, + fetcher: GitHubFileLoaderFetch +): Promise { + const response = await fetcher( + createEndpointURL(endpoint, sourcePath, type, name, prevName), + createEndpointRequestInit(token) + ); + if (!response.ok) { + const detail = await readLoaderErrorDetail(response); + throw new Error( + detail.length > 0 + ? `DiffsHub GitHub file loader failed (${response.status}): ${detail}` + : `DiffsHub GitHub file loader failed (${response.status}).` + ); + } + + return normalizeLoadedDiffFiles(await response.json(), type); +} + +function createEndpointURL( + endpoint: string, + sourcePath: string, + type: string, + name: string, + prevName: string | undefined +): string { + const searchParams = new URLSearchParams({ path: sourcePath, type, name }); + if (prevName != null) { + searchParams.set('prevName', prevName); + } + return `${endpoint}?${searchParams}`; +} + +function createEndpointRequestInit(token: string | undefined): RequestInit { + const normalizedToken = token?.trim(); + if (normalizedToken == null || normalizedToken === '') { + return { cache: 'no-store' }; + } + return { + cache: 'no-store', + headers: { + Authorization: `Bearer ${normalizedToken}`, + }, + }; +} + +async function readLoaderErrorDetail(response: Response): Promise { + const text = (await response.text()).trim(); + if (text === '') { + return ''; + } + + try { + const data = JSON.parse(text) as unknown; + if (isRecord(data) && typeof data.error === 'string') { + return data.error; + } + } catch { + // Fall back to the original body when the proxy returns plain text. + } + return text; +} + +function normalizeLoadedDiffFiles( + data: unknown, + type: string +): FileDiffLoadedFiles { + if (!isRecord(data)) { + throw new Error( + 'DiffsHub GitHub file loader returned an invalid response.' + ); + } + + const files: LoadedDiffFilesResponse = { + oldFile: normalizeFileContents(data.oldFile), + newFile: normalizeFileContents(data.newFile), + }; + + if (type === 'rename-pure') { + if (files.oldFile !== null || files.newFile === null) { + throw new Error( + 'DiffsHub GitHub file loader returned an invalid pure rename response.' + ); + } + return { oldFile: null, newFile: files.newFile }; + } + + if (files.oldFile === null || files.newFile === null) { + throw new Error( + 'DiffsHub GitHub file loader returned an invalid changed-file response.' + ); + } + return { oldFile: files.oldFile, newFile: files.newFile }; +} + +function normalizeFileContents(value: unknown): FileContents | null { + if (value == null) { + return null; + } + if (!isRecord(value)) { + throw new Error('DiffsHub GitHub file loader returned an invalid file.'); + } + + const { cacheKey, contents, name } = value; + if (typeof name !== 'string' || typeof contents !== 'string') { + throw new Error('DiffsHub GitHub file loader returned an invalid file.'); + } + + return { + name, + contents, + cacheKey: typeof cacheKey === 'string' ? cacheKey : undefined, + }; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} diff --git a/apps/diffshub/lib/githubDiffFileServer.ts b/apps/diffshub/lib/githubDiffFileServer.ts new file mode 100644 index 000000000..af24fb74a --- /dev/null +++ b/apps/diffshub/lib/githubDiffFileServer.ts @@ -0,0 +1,641 @@ +import type { ChangeTypes, FileContents } from '@pierre/diffs'; + +import { + encodePath, + encodeURLSegment, + type GitHubDiffSource, + type GitHubRepo, + parseGitHubDiffSource, +} from './githubDiffSource'; + +const GITHUB_API_ROOT = 'https://api.github.com'; +const GITHUB_RAW_ROOT = 'https://raw.githubusercontent.com'; +const GITHUB_API_VERSION = '2022-11-28'; +const GITHUB_RAW_MEDIA_TYPE = 'application/vnd.github.raw'; +const REF_CACHE_TTL_MS = 5 * 60 * 1000; +const FILE_CACHE_TTL_MS = 30 * 60 * 1000; + +type GitHubServerFetch = ( + input: Parameters[0], + init?: Parameters[1] +) => ReturnType; + +interface GitHubRepoRef extends GitHubRepo { + ref: string; +} + +interface GitHubDiffRefs { + oldRef?: GitHubRepoRef; + newRef: GitHubRepoRef; +} + +export interface GitHubDiffFileRequest { + name: string; + path: string; + prevName?: string; + type: ChangeTypes; +} + +interface GitHubDiffFileServerOptions { + fetch?: GitHubServerFetch; + token?: string; + tokenSource?: 'request'; +} + +interface CacheEntry { + expiresAt: number; + promise: Promise; +} + +const refsCache = new Map>(); +const fileCache = new Map>(); + +export async function loadGitHubDiffFiles( + request: GitHubDiffFileRequest, + options: GitHubDiffFileServerOptions = {} +): Promise<{ oldFile: FileContents | null; newFile: FileContents | null }> { + const source = parseGitHubDiffSource(request.path); + if (source == null) { + throw new Error('Unsupported GitHub diff path.'); + } + + const fetcher = options.fetch ?? fetch; + const useSharedCache = options.tokenSource !== 'request'; + switch (request.type) { + case 'new': + return { + oldFile: null, + newFile: createEmptyFallbackFile(request.name, 'new'), + }; + case 'deleted': + return { + oldFile: createEmptyFallbackFile(request.name, 'deleted'), + newFile: null, + }; + case 'change': + case 'rename-changed': { + const refs = await resolveGitHubDiffRefsForRequest( + source, + fetcher, + options, + useSharedCache + ); + const oldRef = requireOldRef(request.name, refs); + const oldPath = request.prevName ?? request.name; + const [oldFile, newFile] = await Promise.all([ + loadGitHubFileForRequest( + oldRef, + oldPath, + fetcher, + options, + useSharedCache + ), + loadGitHubFileForRequest( + refs.newRef, + request.name, + fetcher, + options, + useSharedCache + ), + ]); + return { oldFile, newFile }; + } + case 'rename-pure': { + const refs = await resolveGitHubDiffRefsForRequest( + source, + fetcher, + options, + useSharedCache + ); + const newFile = await loadGitHubFileForRequest( + refs.newRef, + request.name, + fetcher, + options, + useSharedCache + ); + return { oldFile: null, newFile }; + } + } +} + +function resolveGitHubDiffRefsForRequest( + source: GitHubDiffSource, + fetcher: GitHubServerFetch, + options: GitHubDiffFileServerOptions, + useSharedCache: boolean +): Promise { + if (!useSharedCache) { + return resolveGitHubDiffRefs(source, fetcher, options); + } + return resolveCachedGitHubDiffRefs(source, fetcher, options); +} + +export function clearGitHubDiffFileServerCache(): void { + refsCache.clear(); + fileCache.clear(); +} + +function resolveCachedGitHubDiffRefs( + source: GitHubDiffSource, + fetcher: GitHubServerFetch, + options: GitHubDiffFileServerOptions +): Promise { + const cacheKey = getSourceCacheKey(source); + return getCachedPromise(refsCache, cacheKey, REF_CACHE_TTL_MS, () => + resolveGitHubDiffRefs(source, fetcher, options) + ); +} + +function loadCachedGitHubFile( + repoRef: GitHubRepoRef, + path: string, + fetcher: GitHubServerFetch, + options: GitHubDiffFileServerOptions +): Promise { + const normalizedPath = path.replace(/^\/+/, ''); + const cacheKey = `${repoRef.owner}/${repoRef.repo}\0${repoRef.ref}\0${normalizedPath}`; + return getCachedPromise(fileCache, cacheKey, FILE_CACHE_TTL_MS, () => + fetchGitHubFile(repoRef, normalizedPath, fetcher, options) + ); +} + +function loadGitHubFileForRequest( + repoRef: GitHubRepoRef, + path: string, + fetcher: GitHubServerFetch, + options: GitHubDiffFileServerOptions, + useSharedCache: boolean +): Promise { + const normalizedPath = path.replace(/^\/+/, ''); + if (!useSharedCache) { + return fetchGitHubFile(repoRef, normalizedPath, fetcher, options); + } + return loadCachedGitHubFile(repoRef, normalizedPath, fetcher, options); +} + +function getCachedPromise( + cache: Map>, + cacheKey: string, + ttlMs: number, + create: () => Promise +): Promise { + const now = Date.now(); + const cached = cache.get(cacheKey); + if (cached != null && cached.expiresAt > now) { + return cached.promise; + } + + const promise = create().catch((error: unknown) => { + const current = cache.get(cacheKey); + if (current?.promise === promise) { + cache.delete(cacheKey); + } + throw error; + }); + cache.set(cacheKey, { expiresAt: now + ttlMs, promise }); + return promise; +} + +async function resolveGitHubDiffRefs( + source: GitHubDiffSource, + fetcher: GitHubServerFetch, + options: GitHubDiffFileServerOptions +): Promise { + switch (source.kind) { + case 'pull': + return resolveGitHubPullRefs( + source.repo, + source.number, + fetcher, + options + ); + case 'commit': + return resolveGitHubCommitRefs(source.repo, source.sha, fetcher, options); + case 'compare': + return resolveGitHubCompareRefs( + source.repo, + source.range, + fetcher, + options + ); + } +} + +async function resolveGitHubPullRefs( + repo: GitHubRepo, + number: string, + fetcher: GitHubServerFetch, + options: GitHubDiffFileServerOptions +): Promise { + const data = await fetchGitHubJSON( + createGitHubAPIURL( + `/repos/${encodeURLSegment(repo.owner)}/${encodeURLSegment(repo.repo)}/pulls/${encodeURLSegment(number)}` + ), + fetcher, + options + ); + const baseSha = readStringPath(data, ['base', 'sha']); + const headSha = readStringPath(data, ['head', 'sha']); + const baseRepo = readRepoFullName(data, ['base', 'repo', 'full_name']); + const headRepo = readRepoFullName(data, ['head', 'repo', 'full_name']); + + if (baseSha == null || headSha == null) { + throw new Error( + `GitHub pull ${repo.owner}/${repo.repo}#${number} did not include refs.` + ); + } + + const oldRepo = baseRepo ?? repo; + const newRepo = headRepo ?? repo; + const mergeBaseSha = await resolveGitHubPullMergeBaseSha( + oldRepo, + newRepo, + baseSha, + headSha, + fetcher, + options + ); + + return { + oldRef: { ...oldRepo, ref: mergeBaseSha }, + newRef: { ...newRepo, ref: headSha }, + }; +} + +async function resolveGitHubPullMergeBaseSha( + baseRepo: GitHubRepo, + headRepo: GitHubRepo, + baseSha: string, + headSha: string, + fetcher: GitHubServerFetch, + options: GitHubDiffFileServerOptions +): Promise { + const compareRange = createGitHubCompareRange( + baseRepo, + headRepo, + baseSha, + headSha + ); + const data = await fetchGitHubJSON( + createGitHubAPIURL( + `/repos/${encodeURLSegment(baseRepo.owner)}/${encodeURLSegment(baseRepo.repo)}/compare/${encodeURLSegment(compareRange)}` + ), + fetcher, + options + ); + const mergeBaseSha = readStringPath(data, ['merge_base_commit', 'sha']); + if (mergeBaseSha == null) { + throw new Error( + `GitHub compare ${baseRepo.owner}/${baseRepo.repo}@${compareRange} did not include a merge base.` + ); + } + return mergeBaseSha; +} + +function createGitHubCompareRange( + baseRepo: GitHubRepo, + headRepo: GitHubRepo, + baseSha: string, + headSha: string +): string { + if (isSameGitHubRepo(baseRepo, headRepo)) { + return `${baseSha}...${headSha}`; + } + return `${baseRepo.owner}:${baseSha}...${headRepo.owner}:${headSha}`; +} + +async function resolveGitHubCommitRefs( + repo: GitHubRepo, + sha: string, + fetcher: GitHubServerFetch, + options: GitHubDiffFileServerOptions +): Promise { + const data = await fetchGitHubJSON( + createGitHubAPIURL( + `/repos/${encodeURLSegment(repo.owner)}/${encodeURLSegment(repo.repo)}/commits/${encodeURLSegment(sha)}` + ), + fetcher, + options + ); + const resolvedSha = readStringPath(data, ['sha']); + const parentSha = readFirstParentSha(data); + if (resolvedSha == null) { + throw new Error( + `GitHub commit ${repo.owner}/${repo.repo}@${sha} did not include a SHA.` + ); + } + + return { + oldRef: parentSha == null ? undefined : { ...repo, ref: parentSha }, + newRef: { ...repo, ref: resolvedSha }, + }; +} + +async function resolveGitHubCompareRefs( + repo: GitHubRepo, + range: string, + fetcher: GitHubServerFetch, + options: GitHubDiffFileServerOptions +): Promise { + const data = await fetchGitHubJSON( + createGitHubAPIURL( + `/repos/${encodeURLSegment(repo.owner)}/${encodeURLSegment(repo.repo)}/compare/${encodeURLSegment(range)}` + ), + fetcher, + options + ); + const baseSha = readStringPath(data, ['base_commit', 'sha']); + const headSha = await readCompareHeadSha(repo, range, data, fetcher, options); + + if (baseSha == null || headSha == null) { + throw new Error( + `GitHub compare ${repo.owner}/${repo.repo}@${range} did not include refs.` + ); + } + + return { + oldRef: { ...repo, ref: baseSha }, + newRef: { ...repo, ref: headSha }, + }; +} + +async function readCompareHeadSha( + repo: GitHubRepo, + range: string, + data: unknown, + fetcher: GitHubServerFetch, + options: GitHubDiffFileServerOptions +): Promise { + const commits = readArrayPath(data, ['commits']); + const totalCommits = readNumberPath(data, ['total_commits']); + if (commits == null || commits.length === 0) { + return undefined; + } + + if (totalCommits == null || commits.length >= totalCommits) { + return readStringPath(commits[commits.length - 1], ['sha']); + } + + const lastPageData = await fetchGitHubJSON( + createGitHubAPIURL( + `/repos/${encodeURLSegment(repo.owner)}/${encodeURLSegment(repo.repo)}/compare/${encodeURLSegment(range)}`, + { page: String(totalCommits), per_page: '1' } + ), + fetcher, + options + ); + const lastPageCommits = readArrayPath(lastPageData, ['commits']); + const lastCommit = lastPageCommits?.[0]; + return lastCommit == null ? undefined : readStringPath(lastCommit, ['sha']); +} + +async function fetchGitHubFile( + repoRef: GitHubRepoRef, + path: string, + fetcher: GitHubServerFetch, + options: GitHubDiffFileServerOptions +): Promise { + const response = await fetchGitHubFileContents( + repoRef, + path, + fetcher, + options + ); + return { + name: path, + contents: await response.text(), + cacheKey: `github:${repoRef.owner}/${repoRef.repo}:${repoRef.ref}:${path}`, + }; +} + +async function fetchGitHubFileContents( + repoRef: GitHubRepoRef, + path: string, + fetcher: GitHubServerFetch, + options: GitHubDiffFileServerOptions +): Promise { + if (options.tokenSource === 'request' && options.token != null) { + const url = createGitHubAPIURL( + `/repos/${encodeURLSegment(repoRef.owner)}/${encodeURLSegment(repoRef.repo)}/contents/${encodePath(path)}`, + { ref: repoRef.ref } + ); + const response = await fetcher(url, { + headers: createGitHubRawAPIHeaders(options.token), + }); + await assertGitHubResponseOK( + response, + `GitHub contents file ${repoRef.owner}/${repoRef.repo}/${path}@${repoRef.ref}` + ); + return response; + } + + const url = `${GITHUB_RAW_ROOT}/${encodeURLSegment(repoRef.owner)}/${encodeURLSegment(repoRef.repo)}/${encodeURLSegment(repoRef.ref)}/${encodePath(path)}`; + const response = await fetcher(url, { + headers: createGitHubRawHeaders(options.token ?? getGitHubToken()), + }); + await assertGitHubResponseOK( + response, + `GitHub raw file ${repoRef.owner}/${repoRef.repo}/${path}@${repoRef.ref}` + ); + return response; +} + +async function fetchGitHubJSON( + url: string, + fetcher: GitHubServerFetch, + options: GitHubDiffFileServerOptions +): Promise { + const response = await fetcher(url, { + headers: createGitHubJSONHeaders(options.token ?? getGitHubToken()), + }); + await assertGitHubResponseOK(response, `GitHub API ${url}`); + return response.json(); +} + +function createGitHubJSONHeaders(token: string | undefined): HeadersInit { + const headers: Record = { + Accept: 'application/vnd.github+json', + 'User-Agent': 'pierre-diffshub', + 'X-GitHub-Api-Version': GITHUB_API_VERSION, + }; + if (token != null && token !== '') { + headers.Authorization = `Bearer ${token}`; + } + return headers; +} + +function createGitHubRawHeaders(token: string | undefined): HeadersInit { + const headers: Record = { + 'User-Agent': 'pierre-diffshub', + }; + if (token != null && token !== '') { + headers.Authorization = `Bearer ${token}`; + } + return headers; +} + +function createGitHubRawAPIHeaders(token: string): HeadersInit { + return { + Accept: GITHUB_RAW_MEDIA_TYPE, + Authorization: `Bearer ${token}`, + 'User-Agent': 'pierre-diffshub', + 'X-GitHub-Api-Version': GITHUB_API_VERSION, + }; +} + +async function assertGitHubResponseOK( + response: Response, + label: string +): Promise { + if (response.ok) { + return; + } + + const detail = (await response.text()).trim(); + if (isGitHubRateLimitResponse(response, detail)) { + throw new Error( + 'GitHub rate limit exceeded. Add a GitHub token in DiffsHub settings to raise the limit.' + ); + } + + throw new Error( + detail.length > 0 + ? `${label} failed (${response.status}): ${detail}` + : `${label} failed (${response.status}).` + ); +} + +function isGitHubRateLimitResponse( + response: Response, + detail: string +): boolean { + if (response.status !== 403) { + return false; + } + return ( + response.headers.get('x-ratelimit-remaining') === '0' || + /rate limit/i.test(detail) + ); +} + +function requireOldRef(name: string, refs: GitHubDiffRefs): GitHubRepoRef { + if (refs.oldRef == null) { + throw new Error(`GitHub loader cannot hydrate old file for ${name}.`); + } + return refs.oldRef; +} + +function createEmptyFallbackFile( + name: string, + side: 'deleted' | 'new' +): FileContents { + return { + name, + contents: '', + cacheKey: `github-empty:${side}:${name}`, + }; +} + +function createGitHubAPIURL( + path: string, + searchParams?: Record +): string { + const url = new URL(path, GITHUB_API_ROOT); + if (searchParams != null) { + for (const [key, value] of Object.entries(searchParams)) { + url.searchParams.set(key, value); + } + } + return url.href; +} + +function getSourceCacheKey(source: GitHubDiffSource): string { + switch (source.kind) { + case 'pull': + return `pull:${source.repo.owner}/${source.repo.repo}#${source.number}`; + case 'commit': + return `commit:${source.repo.owner}/${source.repo.repo}@${source.sha}`; + case 'compare': + return `compare:${source.repo.owner}/${source.repo.repo}@${source.range}`; + } +} + +function readRepoFullName( + data: unknown, + path: readonly string[] +): GitHubRepo | undefined { + const fullName = readStringPath(data, path); + if (fullName == null) { + return undefined; + } + + const separatorIndex = fullName.indexOf('/'); + if (separatorIndex <= 0 || separatorIndex === fullName.length - 1) { + return undefined; + } + return { + owner: fullName.slice(0, separatorIndex), + repo: fullName.slice(separatorIndex + 1), + }; +} + +function isSameGitHubRepo(a: GitHubRepo, b: GitHubRepo): boolean { + return ( + a.owner.toLowerCase() === b.owner.toLowerCase() && + a.repo.toLowerCase() === b.repo.toLowerCase() + ); +} + +function readFirstParentSha(data: unknown): string | undefined { + const parents = readArrayPath(data, ['parents']); + const firstParent = parents?.[0]; + return firstParent == null ? undefined : readStringPath(firstParent, ['sha']); +} + +function readStringPath( + data: unknown, + path: readonly string[] +): string | undefined { + const value = readUnknownPath(data, path); + return typeof value === 'string' ? value : undefined; +} + +function readNumberPath( + data: unknown, + path: readonly string[] +): number | undefined { + const value = readUnknownPath(data, path); + return typeof value === 'number' ? value : undefined; +} + +function readArrayPath( + data: unknown, + path: readonly string[] +): unknown[] | undefined { + const value = readUnknownPath(data, path); + return Array.isArray(value) ? value : undefined; +} + +function readUnknownPath(data: unknown, path: readonly string[]): unknown { + let current = data; + for (const key of path) { + if (!isRecord(current)) { + return undefined; + } + current = current[key]; + } + return current; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function getGitHubToken(): string | undefined { + return ( + process.env.DIFFSHUB_GITHUB_TOKEN ?? + process.env.GITHUB_TOKEN ?? + process.env.GH_TOKEN + ); +} diff --git a/apps/diffshub/lib/githubDiffSource.ts b/apps/diffshub/lib/githubDiffSource.ts new file mode 100644 index 000000000..eccbe5bf5 --- /dev/null +++ b/apps/diffshub/lib/githubDiffSource.ts @@ -0,0 +1,60 @@ +export interface GitHubRepo { + owner: string; + repo: string; +} + +export type GitHubDiffSource = + | { kind: 'pull'; number: string; repo: GitHubRepo } + | { kind: 'commit'; repo: GitHubRepo; sha: string } + | { kind: 'compare'; range: string; repo: GitHubRepo }; + +export function parseGitHubDiffSource( + path: string +): GitHubDiffSource | undefined { + const normalizedPath = path.replace(/\/+$/, ''); + const pullMatch = + /^\/([^/]+)\/([^/]+)\/pull\/(\d+)(?:\.(?:diff|patch))?$/i.exec( + normalizedPath + ); + if (pullMatch != null) { + return { + kind: 'pull', + number: pullMatch[3], + repo: { owner: pullMatch[1], repo: pullMatch[2] }, + }; + } + + const commitMatch = + /^\/([^/]+)\/([^/]+)\/commit\/([0-9a-f]{4,40})(?:\.(?:diff|patch))?$/i.exec( + normalizedPath + ); + if (commitMatch != null) { + return { + kind: 'commit', + repo: { owner: commitMatch[1], repo: commitMatch[2] }, + sha: commitMatch[3], + }; + } + + const compareMatch = + /^\/([^/]+)\/([^/]+)\/compare\/(.+?)(?:\.(?:diff|patch))?$/i.exec( + normalizedPath + ); + if (compareMatch != null) { + return { + kind: 'compare', + range: decodeURIComponent(compareMatch[3]), + repo: { owner: compareMatch[1], repo: compareMatch[2] }, + }; + } + + return undefined; +} + +export function encodeURLSegment(value: string): string { + return encodeURIComponent(value); +} + +export function encodePath(path: string): string { + return path.split('/').map(encodeURLSegment).join('/'); +} diff --git a/apps/docs/app/(diffs)/_components/WorkerPoolContext.tsx b/apps/docs/app/(diffs)/_components/WorkerPoolContext.tsx index 3aa4868ef..16bb6445f 100644 --- a/apps/docs/app/(diffs)/_components/WorkerPoolContext.tsx +++ b/apps/docs/app/(diffs)/_components/WorkerPoolContext.tsx @@ -60,6 +60,7 @@ const HighlighterOptions: WorkerInitializationRenderOptions = { 'cpp', 'css', 'go', + 'markdown', 'python', 'rust', 'sh', @@ -69,6 +70,7 @@ const HighlighterOptions: WorkerInitializationRenderOptions = { 'zig', ], preferredHighlighter: 'shiki-wasm', + useTokenTransformer: true, }; interface WorkerPoolProps { diff --git a/apps/docs/app/(diffs)/_docs/DocsPage.tsx b/apps/docs/app/(diffs)/_docs/DocsPage.tsx index 6c4019913..95e6c4183 100644 --- a/apps/docs/app/(diffs)/_docs/DocsPage.tsx +++ b/apps/docs/app/(diffs)/_docs/DocsPage.tsx @@ -9,6 +9,8 @@ import type { Metadata } from 'next'; import { MERGE_CONFLICT_EXAMPLE } from '../_examples/MergeConflict/constants'; import { MergeConflict } from '../_examples/MergeConflict/MergeConflict'; import { + CODE_VIEW_HEADER_FOOTER_REACT_EXAMPLE, + CODE_VIEW_HEADER_FOOTER_VANILLA_EXAMPLE, CODE_VIEW_ITEM_METRICS_OPTIONS_EXAMPLE, CODE_VIEW_ITEM_TYPE_EXAMPLE, CODE_VIEW_LAYOUT_OPTIONS_EXAMPLE, @@ -26,6 +28,26 @@ import { CUSTOM_HUNK_SEPARATORS_EXAMPLE, CUSTOM_HUNK_SEPARATORS_SWITCHER, } from '../docs/CustomHunkSeparators/constants'; +import { + EDITOR_DEMO_FILE_EXAMPLE, + EDITOR_LAZY_FILE_EXAMPLE, + EDITOR_MARKER_EXAMPLE, + EDITOR_MARKER_TYPE, + EDITOR_OPTIONS_TYPE, + EDITOR_PUBLIC_API, + EDITOR_REACT_CODE_VIEW_EXAMPLE, + EDITOR_REACT_EXAMPLE, + EDITOR_REACT_FILE_DIFF_EXAMPLE, + EDITOR_REACT_MULTI_FILE_DIFF_EXAMPLE, + EDITOR_SELECTION_ACTION_CONTEXT_TYPE, + EDITOR_SELECTION_ACTION_EXAMPLE, + EDITOR_UNDO_REDO_EXAMPLE, + EDITOR_VANILLA_CODE_VIEW_EXAMPLE, + EDITOR_VANILLA_FILE_DIFF_EXAMPLE, + EDITOR_VANILLA_FILE_EXAMPLE, + EDITOR_WORKER_POOL_REACT_EXAMPLE, + EDITOR_WORKER_POOL_VANILLA_EXAMPLE, +} from '../docs/Editor/constants'; import { INSTALLATION_EXAMPLES, PACKAGE_MANAGERS, @@ -41,6 +63,7 @@ import { REACT_API_CODE_VIEW, REACT_API_FILE, REACT_API_FILE_DIFF, + REACT_API_LOAD_DIFF_FILES, REACT_API_MULTI_FILE_DIFF, REACT_API_PATCH_DIFF, REACT_API_POST_RENDER_LIFECYCLE, @@ -93,6 +116,7 @@ import { VANILLA_API_FILE_RENDERER, VANILLA_API_HUNKS_RENDERER_FILE, VANILLA_API_HUNKS_RENDERER_PATCH_FILE, + VANILLA_API_LOAD_DIFF_FILES, VANILLA_API_POST_RENDER_LIFECYCLE, VANILLA_API_UNRESOLVED_FILE_EXAMPLE, } from '../docs/VanillaAPI/constants'; @@ -163,6 +187,7 @@ export default function DocsPage() { + @@ -268,6 +293,7 @@ async function ReactAPISection() { reactAPIFileDiff, reactAPIUnresolvedFile, postRenderLifecycleExample, + loadDiffFilesExample, sharedDiffOptions, sharedDiffRenderProps, sharedFileOptions, @@ -280,6 +306,7 @@ async function ReactAPISection() { preloadFile(REACT_API_FILE_DIFF), preloadFile(REACT_API_UNRESOLVED_FILE), preloadFile(REACT_API_POST_RENDER_LIFECYCLE), + preloadFile(REACT_API_LOAD_DIFF_FILES), preloadFile(REACT_API_SHARED_DIFF_OPTIONS), preloadFile(REACT_API_SHARED_DIFF_RENDER_PROPS), preloadFile(REACT_API_SHARED_FILE_OPTIONS), @@ -295,6 +322,7 @@ async function ReactAPISection() { reactAPIFile, reactAPIUnresolvedFile, postRenderLifecycleExample, + loadDiffFilesExample, sharedDiffOptions, sharedDiffRenderProps, sharedFileOptions, @@ -312,6 +340,7 @@ async function VanillaAPISection() { fileDiffProps, fileProps, unresolvedFileExample, + loadDiffFilesExample, postRenderLifecycleExample, customHunk, diffHunksRenderer, @@ -324,6 +353,7 @@ async function VanillaAPISection() { preloadFile(VANILLA_API_FILE_DIFF_PROPS), preloadFile(VANILLA_API_FILE_PROPS), preloadFile(VANILLA_API_UNRESOLVED_FILE_EXAMPLE), + preloadFile(VANILLA_API_LOAD_DIFF_FILES), preloadFile(VANILLA_API_POST_RENDER_LIFECYCLE), preloadFile(VANILLA_API_CUSTOM_HUNK_FILE), preloadFile(VANILLA_API_HUNKS_RENDERER_FILE), @@ -339,6 +369,7 @@ async function VanillaAPISection() { fileDiffProps, fileProps, unresolvedFileExample, + loadDiffFilesExample, postRenderLifecycleExample, customHunk, diffHunksRenderer, @@ -357,6 +388,8 @@ async function CodeViewSection() { codeViewReactExample, codeViewScrollTargetsExample, codeViewVanillaExample, + codeViewHeaderFooterReactExample, + codeViewHeaderFooterVanillaExample, ] = await Promise.all([ preloadFile(CODE_VIEW_ITEM_TYPE_EXAMPLE), preloadFile(CODE_VIEW_LAYOUT_OPTIONS_EXAMPLE), @@ -364,6 +397,8 @@ async function CodeViewSection() { preloadFile(CODE_VIEW_REACT_EXAMPLE), preloadFile(CODE_VIEW_SCROLL_TARGETS_EXAMPLE), preloadFile(CODE_VIEW_VANILLA_EXAMPLE), + preloadFile(CODE_VIEW_HEADER_FOOTER_REACT_EXAMPLE), + preloadFile(CODE_VIEW_HEADER_FOOTER_VANILLA_EXAMPLE), ]); const content = await renderMDX({ filePath: '(diffs)/docs/CodeView/content.mdx', @@ -374,6 +409,74 @@ async function CodeViewSection() { codeViewReactExample, codeViewScrollTargetsExample, codeViewVanillaExample, + codeViewHeaderFooterReactExample, + codeViewHeaderFooterVanillaExample, + }, + }); + return {content}; +} + +async function EditorSection() { + const [ + editorDemoFile, + editorVanillaFileExample, + editorVanillaFileDiffExample, + editorVanillaCodeViewExample, + editorLazyFileExample, + editorOptionsType, + editorPublicApi, + editorSelectionActionContextType, + editorSelectionActionExample, + editorMarkerType, + editorMarkerExample, + editorReactCodeViewExample, + editorReactExample, + editorReactFileDiffExample, + editorReactMultiFileDiffExample, + editorUndoRedoExample, + editorWorkerPoolReactExample, + editorWorkerPoolVanillaExample, + ] = await Promise.all([ + preloadFile(EDITOR_DEMO_FILE_EXAMPLE), + preloadFile(EDITOR_VANILLA_FILE_EXAMPLE), + preloadFile(EDITOR_VANILLA_FILE_DIFF_EXAMPLE), + preloadFile(EDITOR_VANILLA_CODE_VIEW_EXAMPLE), + preloadFile(EDITOR_LAZY_FILE_EXAMPLE), + preloadFile(EDITOR_OPTIONS_TYPE), + preloadFile(EDITOR_PUBLIC_API), + preloadFile(EDITOR_SELECTION_ACTION_CONTEXT_TYPE), + preloadFile(EDITOR_SELECTION_ACTION_EXAMPLE), + preloadFile(EDITOR_MARKER_TYPE), + preloadFile(EDITOR_MARKER_EXAMPLE), + preloadFile(EDITOR_REACT_CODE_VIEW_EXAMPLE), + preloadFile(EDITOR_REACT_EXAMPLE), + preloadFile(EDITOR_REACT_FILE_DIFF_EXAMPLE), + preloadFile(EDITOR_REACT_MULTI_FILE_DIFF_EXAMPLE), + preloadFile(EDITOR_UNDO_REDO_EXAMPLE), + preloadFile(EDITOR_WORKER_POOL_REACT_EXAMPLE), + preloadFile(EDITOR_WORKER_POOL_VANILLA_EXAMPLE), + ]); + const content = await renderMDX({ + filePath: '(diffs)/docs/Editor/content.mdx', + scope: { + editorDemoFile, + editorVanillaFileExample, + editorVanillaFileDiffExample, + editorVanillaCodeViewExample, + editorLazyFileExample, + editorOptionsType, + editorPublicApi, + editorSelectionActionContextType, + editorSelectionActionExample, + editorMarkerType, + editorMarkerExample, + editorReactCodeViewExample, + editorReactExample, + editorReactFileDiffExample, + editorReactMultiFileDiffExample, + editorUndoRedoExample, + editorWorkerPoolReactExample, + editorWorkerPoolVanillaExample, }, }); return {content}; diff --git a/apps/docs/app/(diffs)/_edit/EditHero.tsx b/apps/docs/app/(diffs)/_edit/EditHero.tsx new file mode 100644 index 000000000..c047ec9bf --- /dev/null +++ b/apps/docs/app/(diffs)/_edit/EditHero.tsx @@ -0,0 +1,39 @@ +'use client'; + +import { IconArrowRight, IconBook } from '@pierre/icons'; +import Link from 'next/link'; + +import { BetaBadge } from '@/components/BetaBadge'; +import { Button } from '@/components/ui/button'; + +export function EditHero() { + return ( +
+
+ +

+ Edit files and diffs +

+

+ Enable a full-featured yet lightweight editor that lazy-loads when + needed on top of any File or FileDiff. All + the ergonomics and customization of @pierre/diffs, with + everything you need to edit in place. +

+
+ + +
+ ); +} diff --git a/apps/docs/app/(diffs)/_edit/EditPage.tsx b/apps/docs/app/(diffs)/_edit/EditPage.tsx new file mode 100644 index 000000000..b69f2bd30 --- /dev/null +++ b/apps/docs/app/(diffs)/_edit/EditPage.tsx @@ -0,0 +1,152 @@ +import type { + PreloadedFileResult, + PreloadFileDiffResult, +} from '@pierre/diffs/ssr'; + +import { WorkerPoolContext } from '../_components/WorkerPoolContext'; +import { LiveEditing } from '../_examples/LiveEditing/LiveEditing'; +import { EditHero } from './EditHero'; +import { EditReference } from './EditReference'; +import { EditShortcuts } from './EditShortcuts'; +import { FindDemo } from './FindDemo'; +import { HistoryDemo } from './HistoryDemo'; +import { MarkerDemo } from './MarkerDemo'; +import { SelectionDemo } from './SelectionDemo'; +import { HeadingAnchors } from '@/components/docs/HeadingAnchors'; +import { FeatureHeader } from '@/components/FeatureHeader'; +import Footer from '@/components/Footer'; +import { Header } from '@/components/Header'; +import { PierreCompanySection } from '@/components/PierreCompanySection'; + +interface EditPageProps { + liveEditorFile: PreloadedFileResult; + liveDiffEditorDiff: PreloadFileDiffResult; + markerFile: PreloadedFileResult; + findFile: PreloadedFileResult; + historyFile: PreloadedFileResult; + shortcutsFile: PreloadedFileResult; + selectionFile: PreloadedFileResult; +} + +export function EditPage({ + liveEditorFile, + liveDiffEditorDiff, + markerFile, + findFile, + historyFile, + shortcutsFile, + selectionFile, +}: EditPageProps) { + return ( + +
+
+ + + +
+ + +
+ + Select any text to reveal a floating popover, anchored to the + selection and rendered with{' '} + renderSelectionAction(). Place any number of + actions inside—here, an editor-style Add to chat{' '} + sends the selected snippet to the panel on the right, while a + secondary action copies it. + + } + /> + +
+ +
+ + Use editor.setMarkers() to inject inline context + into your code for linter, formatting, and more. Includes + support for severity-aware underlines and hover popovers. + Hover over markers (shown with wavy, colored underlines) in + the example below. + + } + /> + +
+ +
+ + Find strings across files with Cmd/Ctrl-F on any{' '} + File or FileDiff. Find and replace + with Cmd-Opt-F(Mac) or Ctrl-Alt-F + (Linux/Windows). The search panel below is open—type a query + to highlight matches, jump between them with{' '} + Enter or its arrows, and toggle case, whole-word, + or regex as you go. + + } + /> + +
+ +
+ + Edits land on a structure-aware undo stack out of the box. + Walk it with keyboard shortcuts and the toolbar below, or + drive it in code with editor.undo(),{' '} + editor.redo(), and{' '} + editor.applyEdits(). The example loads with a + short refactor already applied across several commits. + + } + /> + +
+ +
+ + Edit mode ships with all the additional shortcuts your users + will need out of the box. Use the example File{' '} + below to try the shortcuts you see in the table, including + moving selected lines with Alt-↑ /{' '} + Alt-↓ or Alt-Ctrl-P /{' '} + Alt-Ctrl-N on macOS and Linux. Editing the + example File will not update the table. + + } + /> + +
+ + +
+ + +
+
+
+ ); +} diff --git a/apps/docs/app/(diffs)/_edit/EditReference.tsx b/apps/docs/app/(diffs)/_edit/EditReference.tsx new file mode 100644 index 000000000..6954d8324 --- /dev/null +++ b/apps/docs/app/(diffs)/_edit/EditReference.tsx @@ -0,0 +1,222 @@ +import { + IconBoxTape, + IconCodeBlock, + IconDiffSplit, + type IconProps, +} from '@pierre/icons'; +import type { ComponentType, ReactNode } from 'react'; + +import { FeatureHeader } from '@/components/FeatureHeader'; + +interface ReferenceItem { + term: ReactNode; + description: ReactNode; +} + +interface ReferenceGroup { + label: string; + icon: ComponentType; + headingClassName: string; + items: ReferenceItem[]; +} + +// The demos above each focus on one headline feature. This list rounds out the +// page with the behaviors edit mode gives you for free—most never get their own +// demo. Each group renders as one column of the grid below. +const CAPABILITY_GROUPS: ReferenceGroup[] = [ + { + label: 'Editing', + icon: IconCodeBlock, + headingClassName: 'border-blue-500/30 text-blue-500', + items: [ + { + term: 'Files & diffs', + description: ( + <> + Edit a File, FileDiff,{' '} + MultiFileDiff, or PatchDiff; the new-file + side of a diff re-tokenizes as you type. + + ), + }, + { + term: 'Multiple cursors', + description: + 'Cmd/Ctrl-click adds carets; one edit applies to every selection and overlapping ranges merge.', + }, + { + term: 'Smart indentation', + description: + "Indent or outdent whole selections, with tab vs. space inferred from each line's existing indentation.", + }, + { + term: 'Bracket matching', + description: ( + <> + Highlight matching bracket pairs across code as you type with the{' '} + matchBrackets option. + + ), + }, + { + term: 'Auto-surround', + description: ( + <> + Wrap a selection in quotes or brackets by typing the opening + character, tunable with the autoSurround option. + + ), + }, + { + term: 'Move lines', + description: ( + <> + Shift lines or selections up and down with Alt-↑ /{' '} + . + + ), + }, + ], + }, + { + label: 'Rendering', + icon: IconDiffSplit, + headingClassName: 'border-purple-500/30 text-purple-500', + items: [ + { + term: 'Works with CodeView', + description: ( + <> + Edit virtualized CodeView instances with{' '} + edit: true. Your createEditor function + creates the editor instance on demand, and editors persist as files + scroll in and out. + + ), + }, + { + term: 'Virtualized files', + description: ( + <> + Use VirtualizedFile and{' '} + VirtualizedFileDiff to edit massive files; off-screen + lines render on demand. + + ), + }, + { + term: 'Themes & color modes', + description: + 'Tokens and editor chrome follow the surface theme, re-tokenizing live when you switch themes or toggle light and dark.', + }, + { + term: 'UI adapts to container', + description: + 'Container queries reflow find & replace panel and marker popovers at narrow widths for a smoother experience, no matter the layout.', + }, + { + term: 'Change & focus events', + description: ( + <> + React to edits and focus changes with the onChange,{' '} + onFocus, and onBlur callbacks. + + ), + }, + { + term: 'Line wrapping', + description: + 'Carets, selections, and matches render correctly across wrapped visual lines.', + }, + ], + }, + { + label: 'Integration & delivery', + icon: IconBoxTape, + headingClassName: 'border-rose-500/30 text-rose-500', + items: [ + { + term: 'Diff annotations', + description: + 'Line annotations shift and survive edits and undo—the basis for agent/AUI surfaces.', + }, + { + term: 'SSR & hydration', + description: + 'Hydrate from prerendered, already-highlighted HTML with no flash.', + }, + { + term: 'Mobile & a11y', + description: ( + <> + Native contentEditable with{' '} + role="textbox"; autocorrect, spellcheck, and + capitalization off. + + ), + }, + { + term: 'Lazy-loadable', + description: ( + <> + Standalone @pierre/diffs/editor entry point—import it + only when editing begins. + + ), + }, + { + term: 'Custom clipboard', + description: ( + <> + Provide your own clipboard reader—handy for native + copy/paste in Electron apps. + + ), + }, + ], + }, +]; + +// Static, server-rendered reference closing out the edit page: a dense, +// columned list of the built-in behaviors the demos above don't spell out +// individually. +export function EditReference() { + return ( +
+ + The demos above cover the headline features. Here's the rest of what + edit mode gives you for free. + + } + /> +
+ {CAPABILITY_GROUPS.map((group) => ( +
+

+ + {group.label} +

+
+ {group.items.map((item, index) => ( +
+
+ {item.term} +
+
+ {item.description} +
+
+ ))} +
+
+ ))} +
+
+ ); +} diff --git a/apps/docs/app/(diffs)/_edit/EditShortcuts.tsx b/apps/docs/app/(diffs)/_edit/EditShortcuts.tsx new file mode 100644 index 000000000..497211f30 --- /dev/null +++ b/apps/docs/app/(diffs)/_edit/EditShortcuts.tsx @@ -0,0 +1,81 @@ +'use client'; + +import { Editor } from '@pierre/diffs/editor'; +import { EditProvider, File } from '@pierre/diffs/react'; +import type { PreloadedFileResult } from '@pierre/diffs/ssr'; +import { useMemo } from 'react'; + +import { EDITOR_SHORTCUT_GROUPS, type EditorShortcutGroup } from './constants'; +import { ShortcutKeys } from '@/components/Shortcut'; + +interface EditShortcutsProps { + // Server-preloaded, highlighted File holding the serialized shortcut data—the + // "code that built the table" shown beside the rendered table. + prerenderedFile: PreloadedFileResult; +} + +// The keyboard-shortcut reference: a live editor showing the data that drives +// the table (left) next to the table itself (right). Both read from +// EDITOR_SHORTCUT_GROUPS, so the snippet and the rendered keys stay in lockstep. +// The platform modifier (Cmd on macOS/iOS, Ctrl elsewhere) is resolved +// client-side by `ShortcutKeys`. +export function EditShortcuts({ prerenderedFile }: EditShortcutsProps) { + const editor = useMemo(() => new Editor({}), []); + + return ( +
+
+ + + +
+ +
+ + + + + + + + + {EDITOR_SHORTCUT_GROUPS.map((group) => ( + + ))} + +
KeyAction
+
+
+ ); +} + +function GroupRows({ group }: { group: EditorShortcutGroup }) { + return ( + <> + + + {group.label} + + + {group.shortcuts.map((shortcut, index) => ( + + + + + {shortcut.action} + + ))} + + ); +} diff --git a/apps/docs/app/(diffs)/_edit/FindDemo.tsx b/apps/docs/app/(diffs)/_edit/FindDemo.tsx new file mode 100644 index 000000000..fba6ed6be --- /dev/null +++ b/apps/docs/app/(diffs)/_edit/FindDemo.tsx @@ -0,0 +1,134 @@ +'use client'; + +import { Editor } from '@pierre/diffs/editor'; +import { EditProvider, File } from '@pierre/diffs/react'; +import type { PreloadedFileResult } from '@pierre/diffs/ssr'; +import { useEffect, useMemo, useRef } from 'react'; + +interface FindDemoProps { + // Server-preloaded, highlighted File; hydrating from it avoids a highlight flash on load. + prerenderedFile: PreloadedFileResult; +} + +// Custom element the File renders into; its shadow DOM is open, so we can reach in. +const DIFFS_TAG_NAME = 'diffs-container'; + +function detectMac(): boolean { + if (typeof navigator === 'undefined') { + return false; + } + const platform = + (navigator as Navigator & { userAgentData?: { platform?: string } }) + .userAgentData?.platform ?? + navigator.platform ?? + ''; + return /mac|iphone|ipad|ipod/i.test(platform); +} + +// Demo of the editor's find overlay. With no public API to open the search +// panel, we do what a reader would: dispatch Cmd/Ctrl-F on the content element +// (polling for it, since it attaches asynchronously after the File hydrates). +// We deliberately leave the input blank and never seed a query — seeding makes +// the editor scroll its first match into view, and that scroll bubbles up to +// the page, yanking the reader down to this (below-the-fold) demo on load. +// Opening an empty panel scrolls nothing. +export function FindDemo({ prerenderedFile }: FindDemoProps) { + const wrapperRef = useRef(null); + const editor = useMemo(() => new Editor({}), []); + + useEffect(() => { + const wrapper = wrapperRef.current; + if (wrapper == null) { + return; + } + + const isMac = detectMac(); + let cancelled = false; + let timer: number | undefined; + let attempts = 0; + + const getShadow = (): ShadowRoot | null => { + const host = wrapper.querySelector(DIFFS_TAG_NAME); + return host?.shadowRoot ?? null; + }; + + // Open the find panel via the real keyboard shortcut on the content element. + // preventScroll keeps focusing the content from scrolling the page. + const openPanel = (shadow: ShadowRoot) => { + const content = shadow.querySelector('[data-content]'); + if (content == null) { + return; + } + editor.setSelections([ + { + start: { line: 0, character: 5 }, + end: { line: 0, character: 5 }, + direction: 'none', + }, + ]); + content.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'f', + bubbles: true, + cancelable: true, + composed: true, + metaKey: isMac, + ctrlKey: !isMac, + }) + ); + }; + + // Poll until the content element has attached, open the panel once, then + // stop. We leave the input blank, so nothing scrolls: opening an empty panel + // reveals no match for the editor to scroll into view. + const tick = () => { + if (cancelled) { + return; + } + attempts += 1; + const shadow = getShadow(); + if (shadow != null) { + if (shadow.querySelector('[data-search-panel]') != null) { + return; + } + openPanel(shadow); + } + if (attempts < 120) { + timer = window.setTimeout(tick, 50); + } + }; + + // Defer until the demo nears the viewport so we only open the panel (and + // focus its editor) as the reader approaches it, rather than on load. + const observer = new IntersectionObserver( + (entries) => { + if (entries.some((entry) => entry.isIntersecting)) { + observer.disconnect(); + tick(); + } + }, + { threshold: 0.4 } + ); + observer.observe(wrapper); + + return () => { + cancelled = true; + observer.disconnect(); + if (timer != null) { + window.clearTimeout(timer); + } + }; + }, [editor]); + + return ( +
+ + + +
+ ); +} diff --git a/apps/docs/app/(diffs)/_edit/HistoryDemo.tsx b/apps/docs/app/(diffs)/_edit/HistoryDemo.tsx new file mode 100644 index 000000000..db11cbcf9 --- /dev/null +++ b/apps/docs/app/(diffs)/_edit/HistoryDemo.tsx @@ -0,0 +1,512 @@ +'use client'; + +import { + DEFAULT_THEMES, + getFiletypeFromFileName, + getHighlighterIfLoaded, + preloadHighlighter, +} from '@pierre/diffs'; +import { Editor } from '@pierre/diffs/editor'; +import { EditProvider, File } from '@pierre/diffs/react'; +import type { PreloadedFileResult } from '@pierre/diffs/ssr'; +import { + IconApproved, + IconArrowLeftBar, + IconArrowRightBar, + IconArrowRightShort, + IconArrowShort, + IconCommit, + IconRefresh, +} from '@pierre/icons'; +import type { CSSProperties } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +import { HISTORY_DEMO_EDITS, HISTORY_DEMO_FILE } from './constants'; +import { Button } from '@/components/ui/button'; + +interface HistoryDemoProps { + // Server-preloaded, highlighted File; hydrating from it avoids a highlight flash on load. + prerenderedFile: PreloadedFileResult; +} + +// Custom element the File renders into; its shadow DOM is open, so we can reach in. +const DIFFS_TAG_NAME = 'diffs-container'; + +const TOTAL_EDITS = HISTORY_DEMO_EDITS.length; + +// SNAPSHOTS[i] is the document with the first `i` edits applied (0 = original, +// TOTAL_EDITS = fully refactored), built with the same find/replace the replay +// uses. Mapping the editor's live text back to its index drives the step count +// from real content rather than a click tally, so it can't drift on undo/redo. +const SNAPSHOTS: readonly string[] = (() => { + const result = [HISTORY_DEMO_FILE.contents]; + let text = HISTORY_DEMO_FILE.contents; + for (const edit of HISTORY_DEMO_EDITS) { + const index = text.indexOf(edit.find); + if (index >= 0) { + text = + text.slice(0, index) + + edit.replace + + text.slice(index + edit.find.length); + } + result.push(text); + } + return result; +})(); + +// Normalize line endings and trailing newlines so the editor's serialized text +// matches a snapshot regardless of EOL handling. +function normalize(text: string): string { + return text.replace(/\r\n/g, '\n').replace(/\n+$/, ''); +} + +const NORMALIZED_SNAPSHOTS = SNAPSHOTS.map(normalize); + +// Index of the snapshot matching the editor's current text, or -1 if the text +// has been changed in a way the demo doesn't track (e.g. free-form typing). +function snapshotIndexFor(text: string): number { + const exact = SNAPSHOTS.indexOf(text); + if (exact >= 0) { + return exact; + } + return NORMALIZED_SNAPSHOTS.indexOf(normalize(text)); +} + +// Language the editor tokenizes this file as. Editing before its grammar has +// loaded throws ("Grammar not loaded"), so we gate the seeded replay on it. +const LANGUAGE = getFiletypeFromFileName(HISTORY_DEMO_FILE.name); + +// True once the shared main-thread highlighter has this file's grammar, which +// is what the editor tokenizes edits with. +function isLanguageReady(): boolean { + return ( + getHighlighterIfLoaded()?.getLoadedLanguages().includes(LANGUAGE) ?? false + ); +} + +function detectMac(): boolean { + if (typeof navigator === 'undefined') { + return false; + } + const platform = + (navigator as Navigator & { userAgentData?: { platform?: string } }) + .userAgentData?.platform ?? + navigator.platform ?? + ''; + return /mac|iphone|ipad|ipod/i.test(platform); +} + +// Convert a string offset into the {line, character} position the editor's +// selection API expects, by counting the newlines that precede it. +function offsetToPosition( + text: string, + offset: number +): { line: number; character: number } { + let line = 0; + let lineStart = 0; + for (let i = 0; i < offset; i++) { + if (text.charCodeAt(i) === 10) { + line += 1; + lineStart = i + 1; + } + } + return { line, character: offset - lineStart }; +} + +export function HistoryDemo({ prerenderedFile }: HistoryDemoProps) { + const wrapperRef = useRef(null); + const isMacRef = useRef(false); + + // True while `seedAll` is replaying edits programmatically, so its intermediate + // `onChange` callbacks are not mistaken for user input. + const isAutoSeedingRef = useRef(false); + // Set when the visitor edits before the deferred auto-seed finishes; blocks + // `waitForReady` from calling `seedAll` on a document that is no longer the + // original snapshot. + const userEditedBeforeSeedRef = useRef(false); + + // Number of edits currently applied, derived from the editor's live text on + // every `onChange` (undo, redo, controls, or keyboard) so it always matches. + const [applied, setApplied] = useState(0); + // True once the document no longer matches any seeded snapshot, i.e. the user + // typed their own edit. The guided step controls and the right-hand list stop + // mapping to the undo stack in that state, so we surface an off-track UI and a + // Reset rather than letting the step count freeze at a stale value. + const [diverged, setDiverged] = useState(false); + const editor = useMemo( + () => + new Editor({ + onChange: (file) => { + const index = snapshotIndexFor(file.contents); + if (index >= 0) { + setApplied(index); + setDiverged(false); + } else { + setDiverged(true); + } + if (!isAutoSeedingRef.current && index !== 0) { + userEditedBeforeSeedRef.current = true; + } + }, + }), + [] + ); + + // Resolve the editor's editable content element inside the open shadow root, + // or null until the File has highlighted and attached. + const getContent = useCallback((): HTMLElement | null => { + const host = wrapperRef.current?.querySelector(DIFFS_TAG_NAME); + return ( + host?.shadowRoot?.querySelector('[data-content]') ?? null + ); + }, []); + + // The horizontally scrollable code panel (`[data-code]`) wrapping the content. + const getScroller = useCallback( + (): HTMLElement | null => getContent()?.closest('[data-code]') ?? null, + [getContent] + ); + + // Run history dispatches and restore the scroll position afterward. Applying + // a change reveals the caret via `scrollIntoView`, which would otherwise + // scroll the code panel sideways and nudge the page. + const preserveScroll = useCallback( + (fn: () => void) => { + const scroller = getScroller(); + const scrollLeft = scroller?.scrollLeft ?? 0; + const { scrollX, scrollY } = window; + fn(); + if (scroller != null) { + scroller.scrollLeft = scrollLeft; + } + window.scrollTo(scrollX, scrollY); + }, + [getScroller] + ); + + // Fire the editor's real undo (Cmd/Ctrl-Z) or redo (adds Shift) shortcut on + // the content element. The editor applies the change and its `onChange` + // updates the step count, so the controls and the keyboard share one path. + const dispatchHistoryKey = useCallback( + (content: HTMLElement, redo: boolean) => { + const isMac = isMacRef.current; + content.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'z', + bubbles: true, + cancelable: true, + composed: true, + metaKey: isMac, + ctrlKey: !isMac, + shiftKey: redo, + }) + ); + }, + [] + ); + + useEffect(() => { + isMacRef.current = detectMac(); + }, []); + + // Apply a single edit by locating its `find` anchor in that step's snapshot + // (the document text right before the edit) and replacing it. The editor's + // `onChange` advances the step count once the edit lands. + const applyEdit = useCallback( + (content: HTMLElement, index: number) => { + const edit = HISTORY_DEMO_EDITS[index]; + const text = SNAPSHOTS[index]; + const at = text.indexOf(edit.find); + if (at < 0) { + return; + } + editor.setSelections([ + { + start: offsetToPosition(text, at), + end: offsetToPosition(text, at + edit.find.length), + direction: 'forward', + }, + ]); + content.dispatchEvent( + new InputEvent('beforeinput', { + inputType: 'insertText', + data: edit.replace, + bubbles: true, + cancelable: true, + composed: true, + }) + ); + }, + [editor] + ); + + // Apply every edit in one synchronous pass to build the full undo stack. Each + // `setSelections` scrolls the caret into view, which would yank the page down + // to this (below-the-fold) demo, so we capture and restore the window scroll + // position around the burst. Collapsing the selection afterward leaves a clean + // final state. Reused by both the load seed and Reset, so it assumes the + // document is at the original snapshot when called. + const seedAll = useCallback( + (content: HTMLElement) => { + isAutoSeedingRef.current = true; + try { + const { scrollX, scrollY } = window; + for (let index = 0; index < TOTAL_EDITS; index++) { + applyEdit(content, index); + } + editor.setSelections([ + { + start: { line: 0, character: 0 }, + end: { line: 0, character: 0 }, + direction: 'none', + }, + ]); + window.scrollTo(scrollX, scrollY); + } finally { + isAutoSeedingRef.current = false; + } + }, + [applyEdit, editor] + ); + + // Build the undo stack once the demo is on screen so the surface arrives + // already fully refactored with history intact. We defer until visible + // because seeding scrolls the caret into view and would yank the page down + // to this below-the-fold demo on first load. We poll until the content + // element has attached AND the editor's grammar is ready, because seeding an + // edit before the editor can tokenize throws ("Grammar not loaded") inside + // the editor. + useEffect(() => { + const wrapper = wrapperRef.current; + if (wrapper == null) { + return; + } + + let cancelled = false; + let timer: number | undefined; + let attempts = 0; + + const waitForReady = () => { + if (cancelled || userEditedBeforeSeedRef.current) { + return; + } + attempts += 1; + const content = getContent(); + if (content != null && isLanguageReady()) { + if (userEditedBeforeSeedRef.current) { + return; + } + seedAll(content); + return; + } + if (attempts < 240 && !userEditedBeforeSeedRef.current) { + timer = window.setTimeout(waitForReady, 50); + } + }; + + const startSeeding = () => { + // Warm the shared highlighter before polling so the editor tokenizer can + // pick up the grammar synchronously once the surface attaches. + void preloadHighlighter({ + themes: [DEFAULT_THEMES.dark, DEFAULT_THEMES.light], + langs: [LANGUAGE], + preferredHighlighter: 'shiki-wasm', + }) + .catch(() => {}) + .finally(() => { + if (!cancelled && !userEditedBeforeSeedRef.current) { + waitForReady(); + } + }); + }; + + const observer = new IntersectionObserver( + (entries) => { + if (entries.some((entry) => entry.isIntersecting)) { + observer.disconnect(); + startSeeding(); + } + }, + { threshold: 0.4 } + ); + observer.observe(wrapper); + + return () => { + cancelled = true; + observer.disconnect(); + if (timer != null) { + window.clearTimeout(timer); + } + }; + }, [getContent, seedAll]); + + const undo = useCallback(() => { + const content = getContent(); + if (content != null) { + preserveScroll(() => dispatchHistoryKey(content, false)); + } + }, [dispatchHistoryKey, getContent, preserveScroll]); + + const redo = useCallback(() => { + const content = getContent(); + if (content != null) { + preserveScroll(() => dispatchHistoryKey(content, true)); + } + }, [dispatchHistoryKey, getContent, preserveScroll]); + + const undoAll = useCallback(() => { + const content = getContent(); + if (content == null) { + return; + } + preserveScroll(() => { + for (let i = applied; i > 0; i--) { + dispatchHistoryKey(content, false); + } + }); + }, [applied, dispatchHistoryKey, getContent, preserveScroll]); + + const redoAll = useCallback(() => { + const content = getContent(); + if (content == null) { + return; + } + preserveScroll(() => { + for (let i = applied; i < TOTAL_EDITS; i++) { + dispatchHistoryKey(content, true); + } + }); + }, [applied, dispatchHistoryKey, getContent, preserveScroll]); + + // Recover the guided demo after the user typed their own edit. We unwind the + // whole undo stack (the stray edit plus the seeded steps) back to the original + // document via the editor's programmatic `undo()`, which is reliable + // regardless of where focus sits, then replay all seeded edits so the surface + // lands back at the fully-refactored 7/7 state with its history intact. + const reset = useCallback(() => { + const content = getContent(); + if (content == null) { + return; + } + preserveScroll(() => { + let guard = 0; + while (editor.canUndo && guard < 1000) { + editor.undo(); + guard += 1; + } + seedAll(content); + }); + }, [editor, getContent, preserveScroll, seedAll]); + + const canUndo = !diverged && applied > 0; + const canRedo = !diverged && applied < TOTAL_EDITS; + + return ( +
+
+ + + + + {diverged ? ( +
+ +
+ ) : ( + + {applied}/{TOTAL_EDITS} steps + + )} +
+ +
+
+ + + +
+ +
+ {diverged ? ( +

+ Feel free to continue making edits. When ready, reset to restore + the guided demo. +

+ ) : null} + {/* + The applied steps are always the first `applied` rows, so instead of + faking a box with per-item borders we draw one card behind them: a + `::before` pinned to the top whose height is `applied` rows tall + (each row is `h-11` = 2.75rem) and that carries the single + box-shadow outline. It collapses to nothing when no steps applied. + While diverged we collapse the card and mute the whole list because + the step count no longer reflects the undo stack. + */} +
    + {HISTORY_DEMO_EDITS.map((edit, index) => { + const isApplied = !diverged && index < applied; + const className = [ + 'relative z-10 flex h-11 items-center gap-2 px-3 text-[15px]', + isApplied ? 'text-foreground' : 'text-muted-foreground', + ].join(' '); + return ( +
  1. + {isApplied ? ( + + ) : ( + + )} + {edit.label} +
  2. + ); + })} +
+
+
+
+ ); +} diff --git a/apps/docs/app/(diffs)/_edit/MarkerDemo.tsx b/apps/docs/app/(diffs)/_edit/MarkerDemo.tsx new file mode 100644 index 000000000..d5616bb49 --- /dev/null +++ b/apps/docs/app/(diffs)/_edit/MarkerDemo.tsx @@ -0,0 +1,42 @@ +'use client'; + +import { Editor } from '@pierre/diffs/editor'; +import { EditProvider, File } from '@pierre/diffs/react'; +import type { PreloadedFileResult } from '@pierre/diffs/ssr'; +import { useEffect, useMemo } from 'react'; + +import { MARKER_DEMO_MARKERS } from './constants'; + +interface MarkerDemoProps { + // Server-preloaded, highlighted File; hydrating from it avoids a highlight flash on load. + prerenderedFile: PreloadedFileResult; +} + +// Demo of the editor's lint markers, applied imperatively via `editor.setMarkers` +// (the same call a real linter integration would make) and shown by default. +export function MarkerDemo({ prerenderedFile }: MarkerDemoProps) { + const editor = useMemo(() => new Editor({}), []); + + // `setMarkers` throws until the editor attaches to its surface (async), so + // retry each frame until the call sticks. + useEffect(() => { + let frame = 0; + const apply = () => { + try { + editor.setMarkers(MARKER_DEMO_MARKERS); + } catch { + frame = requestAnimationFrame(apply); + } + }; + apply(); + return () => cancelAnimationFrame(frame); + }, [editor]); + + return ( +
+ + + +
+ ); +} diff --git a/apps/docs/app/(diffs)/_edit/SelectionDemo.tsx b/apps/docs/app/(diffs)/_edit/SelectionDemo.tsx new file mode 100644 index 000000000..6f28dd8bf --- /dev/null +++ b/apps/docs/app/(diffs)/_edit/SelectionDemo.tsx @@ -0,0 +1,281 @@ +'use client'; + +import { DEFAULT_THEMES } from '@pierre/diffs'; +import { Editor } from '@pierre/diffs/editor'; +import { EditProvider, File } from '@pierre/diffs/react'; +import type { PreloadedFileResult } from '@pierre/diffs/ssr'; +import { + IconArrow, + IconChevronSm, + IconCommentFill, + IconFileCode, + IconSparkle, + IconX, +} from '@pierre/icons'; +import { + type CSSProperties, + useCallback, + useMemo, + useRef, + useState, +} from 'react'; + +import { Button } from '@/components/ui/button'; + +interface SelectionDemoProps { + // Server-preloaded, highlighted File; hydrating from it avoids a highlight flash on load. + prerenderedFile: PreloadedFileResult; +} + +// `renderSelectionAction` returns a plain DOM node, not React, so IconCommentFill +// is inlined as markup and painted with `currentColor`. +const ICON_COMMENT_FILL_SVG = ``; + +// The actions render into the editor's shadow DOM, where the page's Tailwind +// classes don't reach, so they're styled inline. +const PRIMARY_BUTTON_STYLE = + 'display: inline-flex; align-items: center; gap: 2px; font-size: 12px; font-weight: 500; padding: 4px 10px 4px 8px; border-radius: 6px; border: 0; background-color: #6366f1; color: #fff; cursor: pointer;'; +const SECONDARY_BUTTON_STYLE = + 'display: inline-flex; align-items: center; font-size: 12px; padding: 4px 8px; border-radius: 6px; border: 0; background-color: color-mix(in lab, currentColor 25%, transparent); color: inherit; cursor: pointer;'; + +// Shared Tailwind classes for the inert composer's model/agent picker pills. +const COMPOSER_PILL_CLASS = + 'inline-flex h-7 items-center gap-1.5 rounded-md bg-neutral-800 px-2 text-xs text-neutral-400'; + +// Tighter type scale so more snippet code fits in the narrow chat panel. +const CHAT_SNIPPET_STYLE = { + '--diffs-font-size': '12px', + '--diffs-line-height': '18px', +} as CSSProperties; + +interface ChatSnippet { + fileName: string; + id: number; + lineEnd: number; + lineStart: number; + text: string; +} + +interface ChatSnippetSource { + selection: { + end: { + character: number; + line: number; + }; + start: { + line: number; + }; + }; +} + +function getSelectionLineRange(selection: ChatSnippetSource['selection']): { + lineEnd: number; + lineStart: number; +} { + const endLine = + selection.end.character === 0 && selection.end.line > selection.start.line + ? selection.end.line - 1 + : selection.end.line; + const lineStart = Math.min(selection.start.line, endLine) + 1; + const lineEnd = Math.max(selection.start.line, endLine) + 1; + return { lineEnd, lineStart }; +} + +function formatSelectionLineLabel( + snippet: Pick +): string { + return snippet.lineStart === snippet.lineEnd + ? `(${String(snippet.lineStart)})` + : `(${String(snippet.lineStart)}-${String(snippet.lineEnd)})`; +} + +// Demo of the editor's opt-in Selection Action: with `enabledSelectionAction`, +// selecting text immediately reveals a floating popover (anchored below the +// selection) whose contents come from `renderSelectionAction`. Here it mimics an +// editor's "Add to chat": the primary action sends the selected snippet to a +// mock chat panel beside the surface, and a secondary action copies it. +export function SelectionDemo({ prerenderedFile }: SelectionDemoProps) { + const [snippets, setSnippets] = useState([]); + const snippetIdRef = useRef(0); + + // The popover lives inside the editor instance, which is created once. Route + // its "Add to chat" click through a ref so it always calls the latest setter + // without recreating the editor. + const addSnippet = useCallback( + (text: string, source: ChatSnippetSource) => { + const trimmed = text.trim(); + if (trimmed === '') { + return; + } + snippetIdRef.current += 1; + const id = snippetIdRef.current; + setSnippets((prev) => [ + ...prev, + { + id, + fileName: prerenderedFile.file.name, + ...getSelectionLineRange(source.selection), + text: trimmed, + }, + ]); + }, + [prerenderedFile.file.name] + ); + const addSnippetRef = useRef(addSnippet); + addSnippetRef.current = addSnippet; + + const editor = useMemo( + () => + new Editor({ + enabledSelectionAction: true, + renderSelectionAction(selectionAction) { + const container = document.createElement('div'); + container.style.cssText = 'display: flex; gap: 4px;'; + + const addToChat = document.createElement('button'); + addToChat.type = 'button'; + addToChat.style.cssText = PRIMARY_BUTTON_STYLE; + addToChat.innerHTML = `${ICON_COMMENT_FILL_SVG} Add to chat`; + // Suppress the default mousedown so clicking the action doesn't blur + // the editor and collapse the selection we're about to read. + addToChat.addEventListener('mousedown', (event) => + event.preventDefault() + ); + addToChat.addEventListener('click', () => { + addSnippetRef.current(selectionAction.getSelectionText(), { + selection: selectionAction.selection, + }); + selectionAction.close(); + }); + + const copy = document.createElement('button'); + copy.type = 'button'; + copy.textContent = 'Copy'; + copy.style.cssText = SECONDARY_BUTTON_STYLE; + copy.addEventListener('mousedown', (event) => event.preventDefault()); + copy.addEventListener('click', () => { + void navigator.clipboard?.writeText( + selectionAction.getSelectionText() + ); + selectionAction.close(); + }); + + container.append(addToChat, copy); + return container; + }, + }), + [] + ); + + const clearChat = useCallback(() => setSnippets([]), []); + + return ( +
+ + + + + {/* The wrapper takes its height from the editor column (its only in-flow + sibling); the aside fills it absolutely at md+ so a long snippet list + scrolls inside instead of stretching the panel. On mobile it falls back + to normal flow. The min-height keeps the chat panel from collapsing + when the editor column is short. */} +
+