From efb4baacdfb39d453203fe13f36fbbc884078abf Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Sun, 20 Feb 2022 23:12:15 -0800 Subject: [bun dev] Implement copy as markdown --- packages/bun-error/index.tsx | 948 ++++++++++++++++++++++++++++++++++++++----- 1 file changed, 844 insertions(+), 104 deletions(-) (limited to 'packages/bun-error/index.tsx') diff --git a/packages/bun-error/index.tsx b/packages/bun-error/index.tsx index 4be467d9e..d0c2c54b9 100644 --- a/packages/bun-error/index.tsx +++ b/packages/bun-error/index.tsx @@ -7,6 +7,7 @@ import type { JSException as JSExceptionType, Location, Message, + Problems, SourceLine, StackFrame, WebsocketMessageBuildFailure, @@ -90,7 +91,7 @@ const errorTags = [ ]; const normalizedFilename = (filename: string, cwd: string): string => { - if (filename.startsWith(cwd)) { + if (cwd && filename.startsWith(cwd)) { return filename.substring(cwd.length); } @@ -115,19 +116,38 @@ const blobFileURL = ( }; const openWithoutFlashOfNewTab = (event: MouseEvent) => { - const href = event.currentTarget.getAttribute("href"); + const target = event.currentTarget as HTMLAnchorElement; + const href = target.getAttribute("href"); if (!href || event.button !== 0) { return true; } - event.target.removeAttribute("target"); + event.preventDefault(); event.nativeEvent.preventDefault(); event.nativeEvent.stopPropagation(); event.nativeEvent.stopImmediatePropagation(); - globalThis.fetch(href + "?editor=1").then( - () => {}, - (er) => {} - ); + + const headers = new Headers(); + headers.set("Accept", "text/plain"); + + if (target.dataset.line) { + headers.set("Editor-Line", target.dataset.line); + } + + if (target.dataset.column) { + headers.set("Editor-Column", target.dataset.line); + } + + headers.set("Open-In-Editor", "1"); + + globalThis + .fetch(href.split("?")[0], { + headers: headers, + }) + .then( + () => {}, + (er) => {} + ); return false; }; @@ -220,13 +240,262 @@ class FancyTypeError { var onClose = dismissError; +const clientURL = (filename) => { + return `/${filename.replace(/^(\/)?/g, "")}`; +}; + +function bunInfoToMarkdown(_info) { + if (!_info) return; + const info = { ..._info, platform: { ..._info.platform } }; + + var operatingSystemVersion = info.platform.version; + + if (info.platform.os.toLowerCase() === "macos") { + const [major, minor, patch] = operatingSystemVersion.split("."); + switch (major) { + case "22": { + operatingSystemVersion = `13.${minor}.${patch}`; + break; + } + case "21": { + operatingSystemVersion = `12.${minor}.${patch}`; + break; + } + case "20": { + operatingSystemVersion = `11.${minor}.${patch}`; + break; + } + + case "19": { + operatingSystemVersion = `10.15.${patch}`; + break; + } + + case "18": { + operatingSystemVersion = `10.14.${patch}`; + break; + } + + case "17": { + operatingSystemVersion = `10.13.${patch}`; + break; + } + + case "16": { + operatingSystemVersion = `10.12.${patch}`; + break; + } + + case "15": { + operatingSystemVersion = `10.11.${patch}`; + break; + } + } + info.platform.os = "macOS"; + } + + if (info.platform.arch === "arm" && info.platform.os === "macOS") { + info.platform.arch = "Apple Silicon"; + } else if (info.platform.arch === "arm") { + info.platform.arch = "aarch64"; + } + + var base = `Info: +> bun v${info.bun_version} +`; + + if (info.framework && info.framework_version) { + base += `> framework: ${info.framework}@${info.framework_version}`; + } else if (info.framework) { + base += `> framework: ${info.framework}`; + } + + base = + base.trim() + + ` +> ${info.platform.os} ${operatingSystemVersion} (${info.platform.arch}) +> User-Agent: ${navigator.userAgent} +> Pathname: ${location.pathname} +`; + + return base; +} + +var bunInfoMemoized; +function getBunInfo() { + if (bunInfoMemoized) return bunInfoMemoized; + if ("sessionStorage" in globalThis) { + try { + const bunInfoMemoizedString = sessionStorage.getItem("__bunInfo"); + if (bunInfoMemoizedString) { + bunInfoMemoized = JSON.parse(bunInfoMemoized); + return bunInfoMemoized; + } + } catch (exception) {} + } + const controller = new AbortController(); + const timeout = 1000; + const id = setTimeout(() => controller.abort(), timeout); + return fetch("/bun:info", { + signal: controller.signal, + headers: { + Accept: "application/json", + }, + }) + .then((resp) => resp.json()) + .then((bunInfo) => { + clearTimeout(id); + bunInfoMemoized = bunInfo; + if ("sessionStorage" in globalThis) { + try { + sessionStorage.setItem("__bunInfo", JSON.stringify(bunInfo)); + } catch (exception) {} + } + + return bunInfo; + }); +} + const IndentationContext = createContext(0); + +enum LoadState { + pending, + loaded, + failed, +} + +const AsyncSourceLines = ({ + highlight = -1, + highlightColumnStart = 0, + highlightColumnEnd = Infinity, + children, + buildURL, + isClient, + sourceLines, + setSourceLines, +}: { + highlightColumnStart: number; + highlightColumnEnd: number; + highlight: number; + buildURL: (line?: number, column?: number) => string; + sourceLines: string[]; + setSourceLines: (lines: SourceLine[]) => void; +}) => { + const [loadState, setLoadState] = React.useState(LoadState.pending); + + const controller = React.useRef(null); + const url = React.useRef(buildURL(0, 0)); + + React.useEffect(() => { + controller.current = new AbortController(); + var cancelled = false; + fetch(url.current, { + signal: controller.current.signal, + headers: { + Accept: "text/plain", + }, + }) + .then((resp) => { + return resp.text(); + }) + .then((text) => { + if (cancelled) return; + + // TODO: make this faster + const lines = text.split("\n"); + const startLineNumber = Math.max( + Math.min(Math.max(highlight - 4, 0), lines.length - 1), + 0 + ); + const endLineNumber = Math.min(startLineNumber + 8, lines.length); + const sourceLines: SourceLine[] = new Array( + endLineNumber - startLineNumber + ); + var index = 0; + for (let i = startLineNumber; i < endLineNumber; i++) { + const currentLine = lines[i - 1]; + if (typeof currentLine === "undefined") break; + sourceLines[index++] = { + line: i, + text: currentLine, + }; + } + + setSourceLines( + index !== sourceLines.length + ? sourceLines.slice(0, index) + : sourceLines + ); + setLoadState(LoadState.loaded); + }) + .catch((err) => { + if (!cancelled) { + console.error(err); + setLoadState(LoadState.failed); + } + }); + return () => { + cancelled = true; + if (controller.current) { + controller.current.abort(); + controller.current = null; + } + }; + }, [controller, setLoadState, setSourceLines, url, highlight]); + + switch (loadState) { + case LoadState.pending: { + return ( +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ); + } + + case LoadState.failed: { + return null; + } + + case LoadState.loaded: { + return ( + + {children} + + ); + } + default: { + throw new Error("Invalid state"); + } + } +}; + const SourceLines = ({ sourceLines, highlight = -1, highlightColumnStart = 0, highlightColumnEnd = Infinity, children, + isClient = false, buildURL, }: { sourceLines: SourceLine[]; @@ -238,14 +507,17 @@ const SourceLines = ({ let start = sourceLines.length; let end = 0; let dedent = Infinity; - let originalLines = new Array(sourceLines.length); let _i = 0; + var minLineNumber = sourceLines.length + highlight + 1; + var maxLineNumber = 0; for (let i = 0; i < sourceLines.length; i++) { // bun only prints \n, no \r\n, so this should work fine sourceLines[i].text = sourceLines[i].text.replaceAll("\n", ""); // This will now only trim spaces (and vertical tab character which never prints) - const left = sourceLines[i].text.trimLeft(); + const left = sourceLines[i].text.trimStart(); + minLineNumber = Math.min(sourceLines[i].line, minLineNumber); + maxLineNumber = Math.max(sourceLines[i].line, maxLineNumber); if (left.length > 0) { start = Math.min(start, i); @@ -255,9 +527,11 @@ const SourceLines = ({ } } + const leftPad = + maxLineNumber.toString(10).length - minLineNumber.toString(10).length; + const _sourceLines = sourceLines.slice(start, end); const childrenArray = children || []; - const numbers = new Array(_sourceLines.length + childrenArray.length); const lines = new Array(_sourceLines.length + childrenArray.length); let highlightI = 0; @@ -265,73 +539,53 @@ const SourceLines = ({ const { line, text } = _sourceLines[i]; const classes = { empty: text.trim().length === 0, - highlight: highlight + 1 === line || _sourceLines.length === 1, + highlight: highlight + +!isClient === line || _sourceLines.length === 1, }; if (classes.highlight) highlightI = i; const _text = classes.empty ? "" : text.substring(dedent); lines[i] = ( -
- {classes.highlight ? ( - <> - {_text.substring(0, highlightColumnStart - dedent)} - - {_text.substring( - highlightColumnStart - dedent, - highlightColumnEnd - dedent - )} - - {_text.substring(highlightColumnEnd - dedent)} - - ) : ( - _text - )} +
+ + {line.toString(10).padStart(leftPad, " ")} + +
+ {classes.highlight ? ( + <> + {_text.substring(0, highlightColumnStart - dedent)} + + {_text.substring( + highlightColumnStart - dedent, + highlightColumnEnd - dedent + )} + + {_text.substring(highlightColumnEnd - dedent)} + + ) : ( + _text + )} +
); - numbers[i] = ( - - {line} - - ); - - if (classes.highlight && children) { - i++; - - numbers.push( - ...childrenArray.map((child, index) => ( -
- )) - ); - lines.push( - ...childrenArray.map((child, index) => ( -
- {childrenArray[index]} -
- )) - ); - } } return ( @@ -341,8 +595,7 @@ const SourceLines = ({ className={`BunError-SourceLines-highlighter--${highlightI}`} >
-
{numbers}
-
{lines}
+ {lines} ); @@ -381,7 +634,7 @@ const BuildErrorStackTrace = ({ location }: { location: Location }) => { {filename}:{line}:{column} @@ -394,12 +647,16 @@ const BuildErrorStackTrace = ({ location }: { location: Location }) => { const StackFrameIdentifier = ({ functionName, scope, + markdown = false, }: { functionName?: string; + markdown: boolean; scope: StackFrameScope; }) => { switch (scope) { case StackFrameScope.Constructor: { + functionName = + markdown && functionName ? "`" + functionName + "`" : functionName; return functionName ? `new ${functionName}` : "new (anonymous)"; break; } @@ -425,7 +682,11 @@ const StackFrameIdentifier = ({ } default: { - return functionName ? functionName : "λ()"; + return functionName + ? markdown + ? "`" + functionName + "`" + : functionName + : "λ()"; break; } } @@ -434,6 +695,7 @@ const StackFrameIdentifier = ({ const NativeStackFrame = ({ frame, isTop, + urlBuilder, }: { frame: StackFrame; isTop: boolean; @@ -461,15 +723,17 @@ const NativeStackFrame = ({
{fileName}
- {line > -1 && ( -
:{line + 1}
- )} + {line > -1 &&
:{line}
} {column > -1 && (
:{column}
)} @@ -479,10 +743,12 @@ const NativeStackFrame = ({ ); }; -const NativeStackFrames = ({ frames }) => { +const NativeStackFrames = ({ frames, urlBuilder }) => { const items = new Array(frames.length); for (let i = 0; i < frames.length; i++) { - items[i] = ; + items[i] = ( + + ); } return
{items}
; @@ -491,27 +757,72 @@ const NativeStackFrames = ({ frames }) => { const NativeStackTrace = ({ frames, sourceLines, + setSourceLines, children, + isClient = false, }: { frames: StackFrame[]; sourceLines: SourceLine[]; + isClient: boolean; + setSourceLines: (sourceLines: SourceLine[]) => void; }) => { const { file = "", position } = frames[0]; const { cwd } = useContext(ErrorGroupContext); const filename = normalizedFilename(file, cwd); + const urlBuilder = isClient ? clientURL : blobFileURL; + // const [isFocused, setFocused] = React.useState(false); + const ref = React.useRef(); const buildURL = React.useCallback( - (line, column) => blobFileURL(file, line, column), - [file, blobFileURL] + (line, column) => urlBuilder(file, line, column), + [file, urlBuilder] ); + // React.useLayoutEffect(() => { + // var handler1, handler2; + + // handler1 = document.addEventListener( + // "selectionchange", + // (event) => { + // if (event.target && ref.current && ref.current.contains(event.target)) { + // setFocused(true); + // } + // }, + // { passive: true } + // ); + // handler2 = document.addEventListener( + // "selectstart", + // (event) => { + // console.log(event); + // if (event.target && ref.current && ref.current.contains(event.target)) { + // setFocused(true); + // } + // }, + // { passive: true } + // ); + + // return () => { + // if (handler1) { + // document.removeEventListener("selectionchange", handler1); + // handler1 = null; + // } + + // if (handler2) { + // document.removeEventListener("selectstart", handler2); + // handler2 = null; + // } + // }; + // }, [setFocused, ref]); return ( -
+
- {filename}:{position.line + 1}:{position.column_start} + {filename}:{position.line}:{position.column_start} {sourceLines.length > 0 && ( {children} )} - {frames.length > 0 && } + {sourceLines.length === 0 && isClient && ( + + {children} + + )} + {frames.length > 1 && ( + + )}
); }; @@ -551,7 +878,30 @@ const Indent = ({ by, children }) => { ); }; -const JSException = ({ value }: { value: JSExceptionType }) => { +const JSException = ({ + value, + isClient = false, +}: { + value: JSExceptionType; + isClient: boolean; +}) => { + const tag = isClient ? ErrorTagType.client : ErrorTagType.server; + const [sourceLines, _setSourceLines] = React.useState( + value?.stack?.source_lines ?? [] + ); + + // mutating a prop is sacrilege + function setSourceLines(sourceLines: SourceLine[]) { + _setSourceLines(sourceLines); + if (!value.stack) { + value.stack = { + frames: [], + source_lines: sourceLines, + }; + } else { + value.stack.source_lines = sourceLines; + } + } switch (value.code) { case JSErrorCode.TypeError: { const fancyTypeError = new FancyTypeError(value); @@ -563,7 +913,7 @@ const JSException = ({ value }: { value: JSExceptionType }) => { >
TypeError
- {errorTags[ErrorTagType.server]} + {errorTags[tag]}
@@ -583,7 +933,9 @@ const JSException = ({ value }: { value: JSExceptionType }) => { {value.stack && ( @@ -607,7 +959,7 @@ const JSException = ({ value }: { value: JSExceptionType }) => {
{value.name}
- {errorTags[ErrorTagType.server]} + {errorTags[tag]}
{message}
@@ -616,7 +968,9 @@ const JSException = ({ value }: { value: JSExceptionType }) => { {value.stack && ( )}
@@ -628,15 +982,17 @@ const JSException = ({ value }: { value: JSExceptionType }) => {
{value.name}
- {errorTags[ErrorTagType.server]} + {errorTags[tag]}
{value.message}
{value.stack && ( )}
@@ -659,6 +1015,32 @@ const Summary = ({ {errorCount} error{errorCount > 1 ? "s" : ""} on this page
+ + + + + + + + + + + + Want help? + +
@@ -728,6 +1110,7 @@ const OverlayMessageContainer = ({ problems, reason, router, + isClient = false, }: FallbackMessageContainer) => { return (
@@ -737,12 +1120,13 @@ const OverlayMessageContainer = ({ errorCount={problems.exceptions.length + problems.build.errors} onClose={onClose} problems={problems} + isClient={isClient} reason={reason} />
{problems.exceptions.map((problem, index) => ( - + ))} {problems.build.msgs.map((buildMessage, index) => { if (buildMessage.on.build) { @@ -754,14 +1138,278 @@ const OverlayMessageContainer = ({ } })}
-
-
-
+
); }; +function problemsToMarkdown(problems: Problems) { + var markdown = ""; + if (problems?.build?.msgs?.length) { + markdown += messagesToMarkdown(problems.build.msgs); + } + + if (problems?.exceptions?.length) { + markdown += exceptionsToMarkdown(problems.exceptions); + } + + return markdown; +} + +// we can ignore the synchronous copy to clipboard API...I think +function copyToClipboard(input: string | Promise) { + if (!input) return; + + if (input && typeof input === "object" && "then" in input) { + return input.then((str) => copyToClipboard(str)); + } + + return navigator.clipboard.writeText(input).then(() => {}); +} + +function messagesToMarkdown(messages: Message[]): string { + return messages + .map(messageToMarkdown) + .map((a) => a.trim()) + .join("\n"); +} + +function exceptionsToMarkdown(exceptions: JSExceptionType[]): string { + return exceptions + .map(exceptionToMarkdown) + .map((a) => a.trim()) + .join("\n"); +} + +function exceptionToMarkdown(exception: JSException): string { + const { name, message, stack } = exception; + + let markdown = ""; + + if (name === "Error" || name === "RangeError" || name === "TypeError") { + markdown += `**${message}**\n`; + } else { + markdown += `**${name}**\n ${message}\n`; + } + + if (stack.frames.length > 0) { + var frames = stack.frames; + if (stack.source_lines.length > 0) { + const { + file = "", + function_name = "", + position: { + line = -1, + column_start: column = -1, + column_stop: columnEnd = column, + } = { + line: -1, + column_start: -1, + column_stop: -1, + }, + scope = 0, + } = stack.frames[0]; + + if (file) { + if (function_name.length > 0) { + markdown += `In \`${function_name}\` – ${file}`; + } else if (scope > 0 && scope < StackFrameScope.Constructor + 1) { + markdown += `${StackFrameIdentifier({ + functionName: function_name, + scope, + markdown: true, + })} ${file}`; + } else { + markdown += `In ${file}`; + } + + if (line > -1) { + markdown += `:${line}`; + if (column > -1) { + markdown += `:${column}`; + } + } + + if (stack.source_lines.length > 0) { + // TODO: include loader + const extnameI = file.lastIndexOf("."); + const extname = extnameI > -1 ? file.slice(extnameI + 1) : ""; + + markdown += "\n```"; + markdown += extname; + markdown += "\n"; + stack.source_lines.forEach((sourceLine) => { + markdown += sourceLine.text + "\n"; + if (sourceLine.line === line && stack.source_lines.length > 1) { + markdown += + ("/* " + "^".repeat(Math.max(columnEnd - column, 1))).padStart( + Math.max(column + 2, 0) + ) + " happened here */\n"; + } + }); + markdown += "\n```"; + } + } + } + + if (frames.length > 0) { + markdown += "\nStack trace:\n"; + var padding = 0; + for (let frame of frames) { + const { + function_name = "", + position: { line = -1, column_start: column = -1 } = { + line: -1, + column_start: -1, + }, + scope = 0, + } = frame; + padding = Math.max( + padding, + StackFrameIdentifier({ + scope, + functionName: function_name, + markdown: true, + }).length + ); + } + + markdown += "```js\n"; + + for (let frame of frames) { + const { + file = "", + function_name = "", + position: { line = -1, column_start: column = -1 } = { + line: -1, + column_start: -1, + }, + scope = 0, + } = frame; + + markdown += ` +${StackFrameIdentifier({ + scope, + functionName: function_name, + markdown: true, +}).padEnd(padding, " ")}`; + + if (file) { + markdown += ` ${file}`; + if (line > -1) { + markdown += `:${line}`; + if (column > -1) { + markdown += `:${column}`; + } + } + } + } + + markdown += "\n```\n"; + } + } + + return markdown; +} + +function messageToMarkdown(message: Message): string { + var tag = "Error"; + if (message.on.build) { + tag = "BuildError"; + } + var lines = (message.data.text ?? "").split("\n"); + + var markdown = ""; + if (message?.on?.resolve) { + markdown += `**ResolveError**: "${message.on.resolve}" failed to resolve\n`; + } else { + var firstLine = lines[0]; + lines = lines.slice(1); + if (firstLine.length > 120) { + const words = firstLine.split(" "); + var end = 0; + for (let i = 0; i < words.length; i++) { + if (end + words[i].length >= 120) { + firstLine = words.slice(0, i).join(" "); + lines.unshift(words.slice(i).join(" ")); + break; + } + } + } + + markdown += `**${tag}**${firstLine.length > 0 ? ": " + firstLine : ""}\n`; + } + + if (message.data?.location?.file) { + markdown += `In ${normalizedFilename(message.data.location.file, thisCwd)}`; + if (message.data.location.line > -1) { + markdown += `:${message.data.location.line}`; + if (message.data.location.column > -1) { + markdown += `:${message.data.location.column}`; + } + } + + if (message.data.location.line_text.length) { + const extnameI = message.data.location.file.lastIndexOf("."); + const extname = + extnameI > -1 ? message.data.location.file.slice(extnameI + 1) : ""; + + markdown += + "\n```" + extname + "\n" + message.data.location.line_text + "\n```\n"; + } else { + markdown += "\n"; + } + + if (lines.length > 0) { + markdown += lines.join("\n"); + } + } + + return markdown; +} + +const withBunInfo = (text) => { + const bunInfo = getBunInfo(); + + const trimmed = text.trim(); + + if (bunInfo && "then" in bunInfo) { + return bunInfo.then( + (info) => { + const markdown = bunInfoToMarkdown(info).trim(); + return trimmed + "\n" + markdown + "\n"; + }, + () => trimmed + "\n" + ); + } + + if (bunInfo) { + const markdown = bunInfoToMarkdown(bunInfo).trim(); + + return trimmed + "\n" + markdown + "\n"; + } + + return trimmed + "\n"; +}; + +const Footer = ({ toMarkdown, data }) => ( +
+
copyToClipboard(withBunInfo(String(toMarkdown(data))))} + > + + + {" "} + Copy as markdown +
+
+
+); + const BuildFailureMessageContainer = ({ messages, }: { @@ -784,14 +1432,12 @@ const BuildFailureMessageContainer = ({ } })} -
-
-
+