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 0dd9cf7e3..fe33eff4e 100644 --- a/apps/demo/package.json +++ b/apps/demo/package.json @@ -5,6 +5,7 @@ "type": "module", "dependencies": { "@pierre/diffs": "workspace:*", + "@pierre/icons": "catalog:", "react": "catalog:", "react-dom": "catalog:", "shiki": "catalog:" diff --git a/apps/demo/src/codeViewDemo.ts b/apps/demo/src/codeViewDemo.ts index bd8f36fdf..827a55f8a 100644 --- a/apps/demo/src/codeViewDemo.ts +++ b/apps/demo/src/codeViewDemo.ts @@ -14,6 +14,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 @@ -95,6 +98,13 @@ export function renderDemoCodeView( renderAnnotation(annotation) { return renderCodeViewAnnotation(annotation, viewer, items); }, + ...(RENDER_FILENAME_SUFFIX + ? { + renderHeaderFilenameSuffix() { + return createHeaderFilenameSuffixBadge('CodeView slot'); + }, + } + : null), lineHoverHighlight: 'both', expansionLineCount: 10, enableLineSelection: true, diff --git a/apps/demo/src/main.ts b/apps/demo/src/main.ts index f49380c22..6ab979fee 100644 --- a/apps/demo/src/main.ts +++ b/apps/demo/src/main.ts @@ -21,7 +21,15 @@ 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 { createElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; import { cleanupCodeView, @@ -42,6 +50,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 +65,35 @@ 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_OLD_NEW_FILE = false; + +// 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[] = [ { @@ -108,8 +145,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 = @@ -239,15 +286,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 +333,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, @@ -744,15 +844,122 @@ if (renderFileButton != null) { virtualizer?.setup(globalThis.document); const wrap = getWrapped(); + const editor = new Editor({ + clipboard: navigator.clipboard, + 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 +971,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 @@ -885,6 +1115,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 +1190,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 +1230,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..4b18b2906 100644 --- a/apps/demo/src/style.css +++ b/apps/demo/src/style.css @@ -216,6 +216,22 @@ diffs-container { 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 { background-color: rgba(0, 0, 0, 0.1); overflow: hidden; @@ -253,3 +269,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/diffshub/components/usePatchLoader.ts b/apps/diffshub/components/usePatchLoader.ts index 4da2aa0ef..7972bd59f 100644 --- a/apps/diffshub/components/usePatchLoader.ts +++ b/apps/diffshub/components/usePatchLoader.ts @@ -4,7 +4,9 @@ import { areSelectionsEqual, type CodeViewItem, type CodeViewLineSelection, - processFile, + getStreamedPatchMetadata, + processFileBytes, + streamGitPatchFiles, } from '@pierre/diffs'; import { type CodeViewHandle, useStableCallback } from '@pierre/diffs/react'; import { @@ -32,10 +34,6 @@ import { formatDiffsHubLineHash, parseDiffsHubLineHash, } from '@/lib/lineHash'; -import { - getStreamedPatchMetadata, - streamGitPatchFiles, -} from '@/lib/streamGitPatchFiles'; import type { CommentMetadata, DiffsHubCommentFileByItemId, @@ -401,13 +399,13 @@ export function usePatchLoader({ publishTreeSource(); }; - const appendStreamedFile = async (fileText: string) => { + const appendStreamedFile = async (fileBytes: Uint8Array) => { if (!hasReceivedFirstStreamedFile) { hasReceivedFirstStreamedFile = true; console.timeEnd('-- first streamed file'); } - const patchMetadata = getStreamedPatchMetadata(fileText); + const patchMetadata = getStreamedPatchMetadata(fileBytes); if (patchMetadata != null) { streamTreePathPrefix = getPatchTreePathPrefix( patchMetadata, @@ -415,7 +413,7 @@ export function usePatchLoader({ ); } - const fileDiff = processFile(fileText, { + const fileDiff = processFileBytes(fileBytes, { cacheKey: `${cacheKeyPrefix}-0-${accumulator.fileIndex}`, isGitDiff: true, }); diff --git a/apps/diffshub/lib/gitPatchMetadata.ts b/apps/diffshub/lib/gitPatchMetadata.ts index e1190b1bf..9fe9141cf 100644 --- a/apps/diffshub/lib/gitPatchMetadata.ts +++ b/apps/diffshub/lib/gitPatchMetadata.ts @@ -1,4 +1,4 @@ -export const COMMIT_HASH_METADATA_PATTERN = /^From\s+([a-f0-9]+)\s/im; +import { COMMIT_HASH_METADATA_PATTERN } from '@pierre/diffs'; const commitPrefixEncoder = new TextEncoder(); const commitPrefixDecoder = new TextDecoder(); diff --git a/apps/diffshub/lib/streamGitPatchFiles.ts b/apps/diffshub/lib/streamGitPatchFiles.ts deleted file mode 100644 index c5872d46d..000000000 --- a/apps/diffshub/lib/streamGitPatchFiles.ts +++ /dev/null @@ -1,243 +0,0 @@ -import { COMMIT_HASH_METADATA_PATTERN } from './gitPatchMetadata'; - -const GIT_FILE_BOUNDARY = 'diff --git '; -const GIT_FILE_BOUNDARY_WITH_NEWLINE = `\n${GIT_FILE_BOUNDARY}`; -const GIT_FILE_BOUNDARY_SCAN_OVERLAP = - GIT_FILE_BOUNDARY_WITH_NEWLINE.length - 1; -const NON_WHITESPACE_PATTERN = /\S/; - -export async function streamGitPatchFiles( - body: ReadableStream, - onFileText: (fileText: string) => Promise -): Promise { - const reader = body.getReader(); - const decoder = new TextDecoder(); - const parser = createGitPatchFileStreamParser(); - - try { - for (;;) { - const result = await reader.read(); - if (result.done) { - break; - } - if (result.value.byteLength > 0) { - parser.push(decoder.decode(result.value, { stream: true })); - await consumeAvailableStreamedFiles(parser, onFileText); - } - } - - const finalText = decoder.decode(); - if (finalText.length > 0) { - parser.push(finalText); - await consumeAvailableStreamedFiles(parser, onFileText); - } - const result = parser.finish(); - if (result.fileText != null) { - await onFileText(result.fileText); - } - let fileText: string | undefined; - while ((fileText = parser.takeAvailableFile()) != null) { - await onFileText(fileText); - } - return result.fallbackPatchContent; - } finally { - reader.releaseLock(); - } -} - -export function getStreamedPatchMetadata(fileText: string): string | undefined { - const diffBoundaryIndex = findNextGitFileBoundary(fileText, 0); - if (diffBoundaryIndex == null || diffBoundaryIndex <= 0) { - return undefined; - } - - const metadata = fileText.slice(0, diffBoundaryIndex); - return COMMIT_HASH_METADATA_PATTERN.test(metadata) ? metadata : undefined; -} - -interface GitPatchFileStreamFinishResult { - fallbackPatchContent?: string; - fileText?: string; -} - -interface GitPatchFileStreamParser { - finish(): GitPatchFileStreamFinishResult; - push(chunk: string): void; - takeAvailableFile(): string | undefined; -} - -async function consumeAvailableStreamedFiles( - parser: GitPatchFileStreamParser, - onFileText: (fileText: string) => Promise -): Promise { - let fileText: string | undefined; - while ((fileText = parser.takeAvailableFile()) != null) { - await onFileText(fileText); - } -} - -// Buffers the current file until the following `diff --git` header arrives so -// each parsed file is complete before it is appended to the viewer. -function createGitPatchFileStreamParser(): GitPatchFileStreamParser { - let buffer = ''; - let currentFileBoundaryIndex: number | undefined; - let nextBoundarySearchIndex = 0; - let sawFileBoundary = false; - - function takeAvailableFile(): string | undefined { - if (currentFileBoundaryIndex == null) { - currentFileBoundaryIndex = findNextGitFileBoundary( - buffer, - nextBoundarySearchIndex - ); - if (currentFileBoundaryIndex == null) { - nextBoundarySearchIndex = getNextBoundarySearchIndex(buffer, 0); - return undefined; - } - - sawFileBoundary = true; - nextBoundarySearchIndex = currentFileBoundaryIndex + 1; - } - - for (;;) { - const fileBoundaryIndex = currentFileBoundaryIndex; - if (fileBoundaryIndex == null) { - return undefined; - } - - const nextBoundaryIndex = findNextGitFileBoundary( - buffer, - nextBoundarySearchIndex - ); - if (nextBoundaryIndex == null) { - nextBoundarySearchIndex = getNextBoundarySearchIndex( - buffer, - fileBoundaryIndex + 1 - ); - return undefined; - } - - const splitIndex = getStreamedFileSplitIndex( - buffer, - fileBoundaryIndex, - nextBoundaryIndex - ); - const fileText = buffer.slice(0, splitIndex); - - buffer = buffer.slice(splitIndex); - currentFileBoundaryIndex = findNextGitFileBoundary(buffer, 0); - nextBoundarySearchIndex = - currentFileBoundaryIndex == null ? 0 : currentFileBoundaryIndex + 1; - if (NON_WHITESPACE_PATTERN.test(fileText)) { - return fileText; - } - } - } - - return { - push(chunk: string) { - if (chunk.length === 0) { - return; - } - buffer += chunk; - }, - takeAvailableFile, - finish() { - const fileText = takeAvailableFile(); - if (fileText != null) { - return { fileText }; - } - - if (!NON_WHITESPACE_PATTERN.test(buffer)) { - buffer = ''; - return {}; - } - if (!sawFileBoundary) { - const fullPatchText = buffer; - buffer = ''; - return { fallbackPatchContent: fullPatchText }; - } - - const finalFileText = buffer; - buffer = ''; - return { fileText: finalFileText }; - }, - }; -} - -function getNextBoundarySearchIndex( - text: string, - minimumIndex: number -): number { - return Math.max(minimumIndex, text.length - GIT_FILE_BOUNDARY_SCAN_OVERLAP); -} - -function findNextGitFileBoundary( - text: string, - fromIndex: number -): number | undefined { - const startIndex = Math.max(fromIndex, 0); - if (startIndex === 0 && text.startsWith(GIT_FILE_BOUNDARY)) { - return 0; - } - - const boundaryIndex = text.indexOf( - GIT_FILE_BOUNDARY_WITH_NEWLINE, - startIndex - ); - return boundaryIndex === -1 ? undefined : boundaryIndex + 1; -} - -function getStreamedFileSplitIndex( - text: string, - firstBoundaryIndex: number, - nextBoundaryIndex: number -): number { - return ( - findLastCommitMetadataBoundary( - text, - firstBoundaryIndex + 1, - nextBoundaryIndex - ) ?? nextBoundaryIndex - ); -} - -function findLastCommitMetadataBoundary( - text: string, - startIndex: number, - endIndex: number -): number | undefined { - const minimumBoundaryIndex = Math.max(startIndex, 0); - const maximumBoundaryIndex = Math.min(endIndex, text.length); - if (minimumBoundaryIndex >= maximumBoundaryIndex) { - return undefined; - } - - let newlineIndex = text.lastIndexOf('\nFrom ', maximumBoundaryIndex - 1); - for (;;) { - if (newlineIndex === -1) { - return undefined; - } - - const boundaryIndex = newlineIndex + 1; - if (boundaryIndex < minimumBoundaryIndex) { - return undefined; - } - if (boundaryIndex >= maximumBoundaryIndex) { - newlineIndex = text.lastIndexOf('\nFrom ', newlineIndex - 1); - continue; - } - - const lineEndIndex = text.indexOf('\n', boundaryIndex + 1); - const line = text.slice( - boundaryIndex, - lineEndIndex === -1 || lineEndIndex > maximumBoundaryIndex - ? maximumBoundaryIndex - : lineEndIndex - ); - if (COMMIT_HASH_METADATA_PATTERN.test(line)) { - return boundaryIndex; - } - newlineIndex = text.lastIndexOf('\nFrom ', newlineIndex - 1); - } -} 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..e83fd6335 100644 --- a/apps/docs/app/(diffs)/_docs/DocsPage.tsx +++ b/apps/docs/app/(diffs)/_docs/DocsPage.tsx @@ -26,6 +26,24 @@ import { CUSTOM_HUNK_SEPARATORS_EXAMPLE, CUSTOM_HUNK_SEPARATORS_SWITCHER, } from '../docs/CustomHunkSeparators/constants'; +import { + EDITOR_LAZY_FILE_EXAMPLE, + EDITOR_MARKER_EXAMPLE, + EDITOR_MARKER_TYPE, + EDITOR_OPTIONS_TYPE, + EDITOR_PROGRAMMATIC_EXAMPLE, + EDITOR_PUBLIC_API, + 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_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, @@ -163,6 +181,7 @@ export default function DocsPage() { + @@ -379,6 +398,66 @@ async function CodeViewSection() { return {content}; } +async function EditorSection() { + const [ + editorVanillaFileExample, + editorVanillaFileDiffExample, + editorLazyFileExample, + editorOptionsType, + editorPublicApi, + editorSelectionActionContextType, + editorSelectionActionExample, + editorMarkerType, + editorMarkerExample, + editorProgrammaticExample, + editorReactExample, + editorReactFileDiffExample, + editorReactMultiFileDiffExample, + editorUndoRedoExample, + editorWorkerPoolReactExample, + editorWorkerPoolVanillaExample, + ] = await Promise.all([ + preloadFile(EDITOR_VANILLA_FILE_EXAMPLE), + preloadFile(EDITOR_VANILLA_FILE_DIFF_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_PROGRAMMATIC_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: { + editorVanillaFileExample, + editorVanillaFileDiffExample, + editorLazyFileExample, + editorOptionsType, + editorPublicApi, + editorSelectionActionContextType, + editorSelectionActionExample, + editorMarkerType, + editorMarkerExample, + editorProgrammaticExample, + editorReactExample, + editorReactFileDiffExample, + editorReactMultiFileDiffExample, + editorUndoRedoExample, + editorWorkerPoolReactExample, + editorWorkerPoolVanillaExample, + }, + }); + return {content}; +} + async function VirtualizationSection() { const [ reactVirtualizerBasic, diff --git a/apps/docs/app/(diffs)/_edit/EditFeatures.tsx b/apps/docs/app/(diffs)/_edit/EditFeatures.tsx new file mode 100644 index 000000000..e79661e8f --- /dev/null +++ b/apps/docs/app/(diffs)/_edit/EditFeatures.tsx @@ -0,0 +1,121 @@ +import { + IconCiWarning, + IconClockArrow, + IconCursor, + IconDiffUnified, + IconFileCode, + type IconProps, + IconSearch, +} from '@pierre/icons'; +import type { ComponentType, ReactNode } from 'react'; + +interface EditFeature { + icon: ComponentType; + title: string; + description: ReactNode; +} + +// The "mostly there" feature set, mirrored from the editor docs. Each card is +// intentionally terse; the functional demos below the grid show them in action. +const FEATURES: EditFeature[] = [ + { + icon: IconFileCode, + title: 'Edit files', + description: ( + <> + Make any File surface editable with one{' '} + contentEditable prop. The renderer keeps owning syntax + highlighting, layout, and virtualization. + + ), + }, + { + icon: IconDiffUnified, + title: 'Edit diffs', + description: ( + <> + Edit the new-file side of a FileDiff in place. Additions + re-tokenize as you type, in both unified and split styles. + + ), + }, + { + icon: IconCursor, + title: 'Selection management', + description: ( + <> + Native selection with multiple cursors. Cmd/Ctrl-click to add a caret; + edits apply to every non-overlapping selection at once. + + ), + }, + { + icon: IconClockArrow, + title: 'History API', + description: ( + <> + Built-in undo and redo stack that tracks structure-aware edits like + indent, paste, and multi-cursor changes. + + ), + }, + { + icon: IconSearch, + title: 'Find & replace', + description: ( + <> + Find-in-file search and a built-in search panel for jumping between and + replacing matches without leaving the surface. + + ), + }, + { + icon: IconCiWarning, + title: 'Lint markers', + description: ( + <> + Surface diagnostics inline with severity-aware markers and hover popups, + driven by your own linter or language tooling. + + ), + }, +]; + +// Static, server-rendered overview grid of the editor's shipped capabilities. +export function EditFeatures() { + return ( +
+
+

+ Everything you need to edit in place +

+

+ The editor is a pluggable layer on top of the same surfaces you + already render. Attach it when editing is needed, and lazy-load it so + your initial bundle stays small. +

+
+ +
+ {FEATURES.map((feature) => { + const Icon = feature.icon; + return ( +
+ +

{feature.title}

+

+ {feature.description} +

+
+ ); + })} +
+
+ ); +} 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..b97288a3d --- /dev/null +++ b/apps/docs/app/(diffs)/_edit/EditPage.tsx @@ -0,0 +1,148 @@ +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 popups. 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/Ctrl-Shift-F. The example below shows + the search panel pre-filled—press Enter or use + its arrows to jump between matches, 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. 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..c0ce9b2c2 --- /dev/null +++ b/apps/docs/app/(diffs)/_edit/EditReference.tsx @@ -0,0 +1,171 @@ +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: 'International input', + description: + 'Compose CJK and other scripts, dictation, and emoji through the IME.', + }, + ], + }, + { + label: 'Rendering', + icon: IconDiffSplit, + headingClassName: 'border-purple-500/30 text-purple-500', + items: [ + { + term: 'Line wrapping', + description: + 'Carets, selections, and matches render correctly across wrapped visual lines.', + }, + { + 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.', + }, + ], + }, + { + 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. + + ), + }, + ], + }, +]; + +// 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..c8942f4f8 --- /dev/null +++ b/apps/docs/app/(diffs)/_edit/EditShortcuts.tsx @@ -0,0 +1,81 @@ +'use client'; + +import { Editor } from '@pierre/diffs/editor'; +import { EditorProvider, 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..50a786f42 --- /dev/null +++ b/apps/docs/app/(diffs)/_edit/FindDemo.tsx @@ -0,0 +1,143 @@ +'use client'; + +import { Editor } from '@pierre/diffs/editor'; +import { EditorProvider, File } from '@pierre/diffs/react'; +import type { PreloadedFileResult } from '@pierre/diffs/ssr'; +import { useEffect, useMemo, useRef } from 'react'; + +import { FIND_DEMO_SEARCH_QUERY } from './constants'; + +interface FindDemoProps { + // Server-preloaded, highlighted File; hydrating from it avoids a highlight flash on load. + prerenderedFile: PreloadedFileResult; +} + +const SEARCH_QUERY = FIND_DEMO_SEARCH_QUERY; + +// 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 user does: dispatch Cmd/Ctrl-F, then type a query into +// the panel input. We poll for the content element (it attaches async after the +// File hydrates) and only start once the section is on screen so opening the +// panel doesn't yank the page on load. +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. + const openPanel = (shadow: ShadowRoot) => { + const content = shadow.querySelector('[data-content]'); + if (content == null) { + return; + } + content.focus({ preventScroll: true }); + content.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'f', + bubbles: true, + cancelable: true, + composed: true, + metaKey: isMac, + ctrlKey: !isMac, + }) + ); + }; + + // Type the query into the open panel, mirroring a user typing. The editor + // selects and scrolls to the first match on input, so the panel lands on + // "1 of N" with the input focused. Returns true once the input is found. + const fillPanel = (shadow: ShadowRoot): boolean => { + const input = shadow.querySelector('[data-search]'); + if (input == null) { + return false; + } + input.focus(); + input.value = SEARCH_QUERY; + input.dispatchEvent(new Event('input', { bubbles: true })); + return true; + }; + + const tick = () => { + if (cancelled) { + return; + } + attempts += 1; + const shadow = getShadow(); + if (shadow != null) { + if (shadow.querySelector('[data-search-panel]') != null) { + if (fillPanel(shadow)) { + return; + } + } else { + openPanel(shadow); + } + } + if (attempts < 120) { + timer = window.setTimeout(tick, 50); + } + }; + + // Defer until the demo is on screen so focusing the panel input doesn't + // scroll the page on first 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..7646a7887 --- /dev/null +++ b/apps/docs/app/(diffs)/_edit/HistoryDemo.tsx @@ -0,0 +1,476 @@ +'use client'; + +import { + DEFAULT_THEMES, + getFiletypeFromFileName, + getHighlighterIfLoaded, + preloadHighlighter, +} from '@pierre/diffs'; +import { Editor } from '@pierre/diffs/editor'; +import { EditorProvider, 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); + +// Minimum delay after the editor's surface attaches before we seed. The editor +// loads its grammar on a 500ms-debounced pass after attaching, so this clears +// that window even when the language was not already loaded at attach time. +const GRAMMAR_SETTLE_MS = 700; + +// 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); + + // 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); + } + }, + }), + [] + ); + + // 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) => { + 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); + }, + [applyEdit, editor] + ); + + // Build the undo stack on load so the surface arrives already fully + // refactored with history intact. 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; + } + + // Warm the shared highlighter so the grammar is ready as early as possible + // (ideally before the editor attaches, making its first tokenize sync). + void preloadHighlighter({ + themes: [DEFAULT_THEMES.dark, DEFAULT_THEMES.light], + langs: [LANGUAGE], + preferredHighlighter: 'shiki-wasm', + }).catch(() => {}); + + let cancelled = false; + let timer: number | undefined; + let attempts = 0; + let contentSeenAt = 0; + + const waitForReady = () => { + if (cancelled) { + return; + } + attempts += 1; + const content = getContent(); + if (content != null) { + if (contentSeenAt === 0) { + contentSeenAt = Date.now(); + } + if ( + isLanguageReady() && + Date.now() - contentSeenAt >= GRAMMAR_SETTLE_MS + ) { + seedAll(content); + return; + } + } + if (attempts < 240) { + timer = window.setTimeout(waitForReady, 50); + } + }; + + waitForReady(); + + return () => { + cancelled = true; + 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..3d4e7f96c --- /dev/null +++ b/apps/docs/app/(diffs)/_edit/MarkerDemo.tsx @@ -0,0 +1,42 @@ +'use client'; + +import { Editor } from '@pierre/diffs/editor'; +import { EditorProvider, 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..4d85e93ff --- /dev/null +++ b/apps/docs/app/(diffs)/_edit/SelectionDemo.tsx @@ -0,0 +1,211 @@ +'use client'; + +import { DEFAULT_THEMES } from '@pierre/diffs'; +import { Editor } from '@pierre/diffs/editor'; +import { EditorProvider, File } from '@pierre/diffs/react'; +import type { PreloadedFileResult } from '@pierre/diffs/ssr'; +import { + IconArrow, + IconChevronSm, + IconCommentFill, + 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; + +// 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([]); + + // 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) => { + const trimmed = text.trim(); + if (trimmed === '') { + return; + } + setSnippets((prev) => [...prev, trimmed]); + }, []); + const addSnippetRef = useRef(addSnippet); + addSnippetRef.current = addSnippet; + + const editor = useMemo( + () => + new Editor({ + enabledSelectionAction: true, + renderSelectionAction({ close, getSelectionText }) { + 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(getSelectionText()); + 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(getSelectionText()); + 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. */} +
+