diff --git a/components/blocks/code.js b/components/blocks/code.js index 875aec603..0ea7f0ee1 100644 --- a/components/blocks/code.js +++ b/components/blocks/code.js @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useRef, useState } from "react"; +import React, { useCallback, useEffect, useRef } from "react"; import classNames from "classnames"; import Prism from "prismjs"; import "prismjs/plugins/line-numbers/prism-line-numbers"; @@ -7,8 +7,13 @@ import "prismjs/plugins/line-highlight/prism-line-highlight.css"; import "prismjs/plugins/toolbar/prism-toolbar"; import "prismjs/plugins/copy-to-clipboard/prism-copy-to-clipboard"; import "prismjs/plugins/normalize-whitespace/prism-normalize-whitespace"; +import "prismjs/plugins/diff-highlight/prism-diff-highlight"; +import "prismjs/plugins/diff-highlight/prism-diff-highlight.css"; import Image from "./image"; +import languageDisplayNames, { + getPrismLanguage, +} from "../../lib/languageDisplayNames"; import styles from "./code.module.css"; @@ -68,10 +73,6 @@ const TryMeButton = ({ code }) => { ); }; -import languageDisplayNames, { - getPrismLanguage, -} from "../../lib/languageDisplayNames"; - // Initialize the cache for imported languages. const languageImports = new Map(); @@ -123,7 +124,10 @@ const Code = ({ // Extract language identifier for display const langId = languageClass?.substring(9) || language || "python"; - const displayLanguage = languageDisplayNames[langId] || langId; + const diffMatch = langId.match(/^diff-([\w-]+)$/); + const displayLanguage = diffMatch + ? languageDisplayNames[diffMatch[1]] || diffMatch[1] + : languageDisplayNames[langId] || langId; const showLanguage = langId.toLowerCase() !== "none" && (showAll || !filename); @@ -168,6 +172,89 @@ const Code = ({ ); }; +// Strip deleted lines and diff prefixes (+/=) to produce copy-friendly text. +function getCleanDiffText(textContent) { + return textContent + .split(/\r?\n/) + .filter((line) => !line.startsWith("-")) + .map((line) => line.substring(1)) + .join("\n"); +} + +// Add +/- markers into the left margin of the code block. +function addDiffMarkers(container, codeElement) { + const pre = codeElement.closest("pre"); + if (!pre) return; + + const lines = codeElement.textContent.split(/\r?\n/); + // Drop trailing empty line from a final newline + if (lines.length > 0 && lines[lines.length - 1] === "") { + lines.pop(); + } + + const markerEl = document.createElement("div"); + markerEl.className = `diff-markers ${styles.DiffMarkers}`; + markerEl.setAttribute("aria-hidden", "true"); + + for (const line of lines) { + const span = document.createElement("span"); + if (line.startsWith("+")) { + span.textContent = "+"; + span.className = "diff-marker-insert"; + } else if (line.startsWith("-")) { + span.textContent = "\u2212"; // minus sign instead of hyphen; visually balanced with + + span.className = "diff-marker-delete"; + } else { + // Non-breaking space keeps this span from collapsing so + // subsequent +/- markers stay vertically aligned with their code lines. + span.textContent = "\u00A0"; + } + markerEl.appendChild(span); + } + + const topOffset = + pre.offsetTop + parseFloat(getComputedStyle(pre).paddingTop); + markerEl.style.top = `${topOffset}px`; + + container.appendChild(markerEl); +} + +function overrideDiffCopyButton(container, codeElement) { + const copyButton = container.querySelector(".copy-to-clipboard-button"); + if (!copyButton) return; + + const timeout = + parseInt(codeElement.getAttribute("data-prismjs-copy-timeout")) || 5000; + + const newButton = copyButton.cloneNode(true); + copyButton.parentNode.replaceChild(newButton, copyButton); + + newButton.addEventListener("click", async () => { + const cleanText = getCleanDiffText(codeElement.textContent); + const span = newButton.querySelector("span"); + + try { + await navigator.clipboard.writeText(cleanText); + if (span) { + span.textContent = "Copied!"; + newButton.setAttribute("data-copy-state", "copy-success"); + } + } catch { + if (span) { + span.textContent = "Press Ctrl+C to copy"; + newButton.setAttribute("data-copy-state", "copy-error"); + } + } + + if (span) { + setTimeout(() => { + span.textContent = "Copy"; + newButton.setAttribute("data-copy-state", "copy"); + }, timeout); + } + }); +} + async function highlightElement( importLanguage, languageImports, @@ -175,8 +262,41 @@ async function highlightElement( hideCopyButton, ) { if (typeof window !== "undefined") { - // Only import the language if it hasn't been imported before. - if (!languageImports.has(importLanguage)) { + const isDiff = importLanguage.startsWith("diff-"); + if (isDiff) { + const baseLang = importLanguage.substring(5); + for (const lang of ["diff", baseLang]) { + if (!languageImports.has(lang)) { + try { + await import(`prismjs/components/prism-${lang}`); + languageImports.set(lang, true); + } catch (error) { + console.error(`Prism doesn't support this language: ${lang}`); + } + } + } + // Prism's diff grammar only recognizes +/- prefixes. We use "=" for + // unchanged lines to keep code aligned (no visual shift) and to preserve + // leading whitespace that markdown processing would otherwise strip. + if (!Prism.languages.diff["unchanged-equal"]) { + Prism.languages.diff["unchanged-equal"] = { + pattern: /^(?:=.*(?:\r\n?|\n|(?![\s\S])))+/m, + alias: ["unchanged"], + inside: { + line: { + pattern: /(.)(?=[\s\S]).*(?:\r\n?|\n)?/, + lookbehind: true, + }, + prefix: { + pattern: /[\s\S]/, + alias: "unchanged", + }, + }, + }; + Prism.languages.diff.PREFIXES["unchanged-equal"] = "="; + } + languageImports.set(importLanguage, true); + } else if (!languageImports.has(importLanguage)) { try { await import(`prismjs/components/prism-${importLanguage}`); languageImports.set(importLanguage, true); @@ -185,16 +305,18 @@ async function highlightElement( } } - // Highlight the code block and conditionally enable toolbar plugins (including copy button) if (codeElement) { - // First highlight the element Prism.highlightElement(codeElement); - // Then activate toolbar plugins on the parent container if copy button is not hidden if (!hideCopyButton) { const container = codeElement.closest(`.${styles.Container}`); if (container) { Prism.highlightAllUnder(container); + + if (isDiff) { + addDiffMarkers(container, codeElement); + overrideDiffCopyButton(container, codeElement); + } } } } diff --git a/components/blocks/code.module.css b/components/blocks/code.module.css index 5188741bf..ed6ceb3e6 100644 --- a/components/blocks/code.module.css +++ b/components/blocks/code.module.css @@ -26,6 +26,57 @@ @apply p-6 text-white font-medium relative leading-relaxed; } +/* Must match .Pre code typography so markers align line-by-line */ +.DiffMarkers { + position: absolute; + left: 0; + width: 1.5rem; + text-align: center; + pointer-events: none; + @apply font-mono leading-relaxed; +} + +/* Fade overlays on Container (outside scroll area so they don't scroll) */ +.Container::before, +.Container::after { + content: ""; + position: absolute; + bottom: 0.75rem; + width: 1.5rem; + @apply z-sidebar; + pointer-events: none; +} + +.Container:has(.Header)::before, +.Container:has(.Header)::after { + top: 2.5rem; +} + +.Container:not(:has(.Header))::before, +.Container:not(:has(.Header))::after { + top: 0; +} + +.Container::before { + left: 0; + background: linear-gradient( + to right, + theme("colors.gray.90"), + theme("colors.gray.90") 0.375rem, + transparent + ); +} + +.Container::after { + right: 0; + background: linear-gradient( + to left, + theme("colors.gray.90"), + theme("colors.gray.90") 0.375rem, + transparent + ); +} + /* Keep in sync with components/blocks/autofunction.module.css */ .Pre, .Container code { @@ -47,6 +98,7 @@ @apply absolute top-0 right-0 flex items-center justify-end px-3 h-10 + z-header text-gray-80 text-xs font-medium tracking-wide; } @@ -122,6 +174,11 @@ } } +:global(.refcard) .Container::before, +:global(.refcard) .Container::after { + display: none; +} + :global(.refcard) .Container code { @apply block p-4 h-full; } diff --git a/content/deploy/community-cloud/get-started/quickstart.md b/content/deploy/community-cloud/get-started/quickstart.md index 2307a68b7..747522d81 100644 --- a/content/deploy/community-cloud/get-started/quickstart.md +++ b/content/deploy/community-cloud/get-started/quickstart.md @@ -81,7 +81,7 @@ You will sign in to your GitHub account during this process. Community Cloud wil 1. Go to the app's entrypoint file (`streamlit_app.py`) in the left pane, and change line 3 by adding "Streamlit" inside `st.title`. - ```diff + ```diff-python -st.title("🎈 My new app") +st.title("🎈 My new Streamlit app") ``` diff --git a/content/develop/tutorials/authentication/google.md b/content/develop/tutorials/authentication/google.md index a41fd69ea..bf55e88d1 100644 --- a/content/develop/tutorials/authentication/google.md +++ b/content/develop/tutorials/authentication/google.md @@ -207,10 +207,10 @@ To create an app with user authentication, you'll need to configure your secrets If you don't want to use a callback, you can replace the last line with an equivalent `if` statement: - ```diff - - st.button("Log in with Google", on_click=st.login) - + if st.button("Log in with Google"): - + st.login() + ```diff-python + - st.button("Log in with Google", on_click=st.login) + + if st.button("Log in with Google"): + + st.login() ``` @@ -233,10 +233,10 @@ To create an app with user authentication, you'll need to configure your secrets 1. Replace `st.user` with a personalized greeting: - ```diff - else: - - st.user - + st.header(f"Welcome, {st.user.name}!") + ```diff-python + =else: + - st.user + + st.header(f"Welcome, {st.user.name}!") ``` 1. Add a logout button: diff --git a/content/develop/tutorials/authentication/microsoft.md b/content/develop/tutorials/authentication/microsoft.md index b0d5d4dc7..1880d5231 100644 --- a/content/develop/tutorials/authentication/microsoft.md +++ b/content/develop/tutorials/authentication/microsoft.md @@ -193,10 +193,10 @@ To create an app with user authentication, you'll need to configure your secrets If you don't want to use a callback, you can replace the last line with an equivalent `if` statement: - ```diff - - st.button("Log in with Microsoft", on_click=st.login) - + if st.button("Log in with Microsoft"): - + st.login() + ```diff-python + - st.button("Log in with Microsoft", on_click=st.login) + + if st.button("Log in with Microsoft"): + + st.login() ``` @@ -219,10 +219,10 @@ To create an app with user authentication, you'll need to configure your secrets 1. Replace `st.user` with a personalized greeting: - ```diff - else: - - st.user - + st.header(f"Welcome, {st.user.name}!") + ```diff-python + =else: + - st.user + + st.header(f"Welcome, {st.user.name}!") ``` 1. Add a logout button: diff --git a/content/develop/tutorials/llms/chat-response-feedback.md b/content/develop/tutorials/llms/chat-response-feedback.md index 43af56f36..3529f91e7 100644 --- a/content/develop/tutorials/llms/chat-response-feedback.md +++ b/content/develop/tutorials/llms/chat-response-feedback.md @@ -216,14 +216,14 @@ To make your chat app stateful, you'll save the conversation history into Sessio 1. Add the callback and index argument to your `st.feedback` widget: - ```diff - st.feedback( - "thumbs", - key=f"feedback_{i}", - disabled=feedback is not None, - + on_change=save_feedback, - + args=[i], - ) + ```diff-python + = st.feedback( + = "thumbs", + = key=f"feedback_{i}", + = disabled=feedback is not None, + + on_change=save_feedback, + + args=[i], + = ) ``` When a user interacts with the feedback widget, the callback will update the chat history before the app reruns. @@ -277,36 +277,36 @@ Your app currently allows users to rate any response once. They can submit their If you want users to rate only the _most recent_ response, you can remove the widgets from the chat history: -```diff - for i, message in enumerate(st.session_state.history): - with st.chat_message(message["role"]): - st.write(message["content"]) -- if message["role"] == "assistant": -- feedback = message.get("feedback", None) -- st.session_state[f"feedback_{i}"] = feedback -- st.feedback( -- "thumbs", -- key=f"feedback_{i}", -- disabled=feedback is not None, -- on_change=save_feedback, -- args=[i], -- ) +```diff-python += for i, message in enumerate(st.session_state.history): += with st.chat_message(message["role"]): += st.write(message["content"]) +- if message["role"] == "assistant": +- feedback = message.get("feedback", None) +- st.session_state[f"feedback_{i}"] = feedback +- st.feedback( +- "thumbs", +- key=f"feedback_{i}", +- disabled=feedback is not None, +- on_change=save_feedback, +- args=[i], +- ) ``` Or, if you want to allow users to change their responses, you can just remove the `disabled` parameter: -```diff - for i, message in enumerate(st.session_state.history): - with st.chat_message(message["role"]): - st.write(message["content"]) - if message["role"] == "assistant": - feedback = message.get("feedback", None) - st.session_state[f"feedback_{i}"] = feedback - st.feedback( - "thumbs", - key=f"feedback_{i}", -- disabled=feedback is not None, - on_change=save_feedback, - args=[i], - ) +```diff-python += for i, message in enumerate(st.session_state.history): += with st.chat_message(message["role"]): += st.write(message["content"]) += if message["role"] == "assistant": += feedback = message.get("feedback", None) += st.session_state[f"feedback_{i}"] = feedback += st.feedback( += "thumbs", += key=f"feedback_{i}", +- disabled=feedback is not None, += on_change=save_feedback, += args=[i], += ) ``` diff --git a/content/develop/tutorials/llms/chat-response-revision.md b/content/develop/tutorials/llms/chat-response-revision.md index 3936933ae..f99e729fc 100644 --- a/content/develop/tutorials/llms/chat-response-revision.md +++ b/content/develop/tutorials/llms/chat-response-revision.md @@ -762,17 +762,17 @@ To see another edge case, try this in the running example: When you click a button with an unsubmitted value in another widget, Streamlit will update that widget's value and the button's value in succession before triggering the rerun. Because there isn't a rerun between updating the text area and updating the button, the "**Update**" button doesn't get disabled as expected. To correct this, you can add an extra check for an empty text area within the `"rewrite"` stage: -```diff -- if st.button( -- "Update", type="primary", disabled=new is None or new.strip(". ") == "" -- ): -+ is_empty = new is None or new.strip(". ") == "" -+ if st.button("Update", type="primary", disabled=is_empty) and not is_empty: - st.session_state.history.append({"role": "assistant", "content": new}) - st.session_state.pending = None - st.session_state.validation = {} - st.session_state.stage = "user" - st.rerun() +```diff-python +- if st.button( +- "Update", type="primary", disabled=new is None or new.strip(". ") == "" +- ): ++ is_empty = new is None or new.strip(". ") == "" ++ if st.button("Update", type="primary", disabled=is_empty) and not is_empty: += st.session_state.history.append({"role": "assistant", "content": new}) += st.session_state.pending = None += st.session_state.validation = {} += st.session_state.stage = "user" += st.rerun() ``` Now, if you repeat the listed steps, when the app reruns, the conditional block won't be executed even though the button triggered the rerun. The button will be disabled and the user can proceed as if they had just clicked or tabbed out of the text area. diff --git a/content/get-started/installation/cloud-quickstart.md b/content/get-started/installation/cloud-quickstart.md index 2bbb7c798..634382c4d 100644 --- a/content/get-started/installation/cloud-quickstart.md +++ b/content/get-started/installation/cloud-quickstart.md @@ -81,7 +81,7 @@ If you already created a Community Cloud account and connected GitHub, jump ahea 1. Go to the app's entrypoint file (`streamlit_app.py`) in the left pane, and change line 3 by adding "Streamlit" inside `st.title`. - ```diff + ```diff-python -st.title("🎈 My new app") +st.title("🎈 My new Streamlit app") ``` diff --git a/lib/languageDisplayNames.js b/lib/languageDisplayNames.js index b4d87e8df..60893b499 100644 --- a/lib/languageDisplayNames.js +++ b/lib/languageDisplayNames.js @@ -36,6 +36,7 @@ export const languageDisplayNames = { r: "R", docker: "Docker", dockerfile: "Dockerfile", + diff: "Diff", text: "Text", none: "", }; @@ -53,7 +54,12 @@ export const languageToPrism = { }; // Helper to get the Prism-compatible language name -export const getPrismLanguage = (language) => - languageToPrism[language] || language; +export const getPrismLanguage = (language) => { + if (language?.startsWith("diff-")) { + const base = language.substring(5); + return `diff-${languageToPrism[base] || base}`; + } + return languageToPrism[language] || language; +}; export default languageDisplayNames; diff --git a/styles/syntax-highlighting.scss b/styles/syntax-highlighting.scss index b78b9ff94..7fbd8af5f 100644 --- a/styles/syntax-highlighting.scss +++ b/styles/syntax-highlighting.scss @@ -284,8 +284,8 @@ code .cdata { font-style: italic !important; } -pre code .deleted, -code .deleted { +pre code .deleted:not(.prefix), +code .deleted:not(.prefix) { color: var(--syntax-deleted) !important; background-color: rgba( 255, @@ -295,8 +295,8 @@ code .deleted { ) !important; /* red-70 with 20% opacity */ } -pre code .inserted, -code .inserted { +pre code .inserted:not(.prefix), +code .inserted:not(.prefix) { color: var(--syntax-inserted) !important; background-color: rgba( 33, @@ -306,6 +306,46 @@ code .inserted { ) !important; /* green-70 with 20% opacity */ } +/* Diff blocks: expand code element to content width so block children fill scrollable area */ +code[class*="language-diff-"] { + display: block; + overflow: visible !important; + max-width: none !important; + width: max-content; + min-width: 100%; +} + +/* Diff blocks: make all line-level tokens block-level */ +code[class*="language-diff-"] > .token.deleted:not(.prefix), +code[class*="language-diff-"] > .token.inserted:not(.prefix), +code[class*="language-diff-"] > .token.unchanged:not(.prefix) { + display: block; +} + +/* Diff prefix: hidden; replaced by fixed markers added via JS */ +code[class*="language-diff-"] .token.prefix { + display: none; +} + +/* Fixed diff markers in the left margin */ +.diff-markers { + display: flex; + flex-direction: column; + z-index: theme("zIndex.overlay_code"); +} + +.diff-markers span { + display: block; +} + +.diff-marker-insert { + color: var(--syntax-inserted); +} + +.diff-marker-delete { + color: var(--syntax-deleted); +} + /* Table token for inline display */ pre code .table, code .table { diff --git a/tailwind.config.js b/tailwind.config.js index d9460764b..4ef59ec98 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -187,6 +187,7 @@ module.exports = { base: "0", // Default layer, code line highlights elevated: "10", // Code blocks, search, floating nav, nav icons sidebar: "20", // Sidebar, refcards, lightbox close button + overlay_code: "25", // Diff markers, code block overlays above fades header: "30", // Header, lightbox overlay, chat sticky dropdown: "50", // Version selector, tooltips, dropdown menus overlay: "90", // Full-screen overlays (search modal backdrop)