Highlighted code blocks inside the app (#1153)

This commit is contained in:
Thorsten Sommer authored and GitHub committed 2026-11-04 07:58:57 +01:00
1 parent 0b42c0288e
commit f4a86c2fb8
60 files changed
+2550 -41

No files matched your search

@@ -0,0 +1,97 @@
using AIStudio.Tools.PluginSystem;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Rendering;
using Microsoft.AspNetCore.Components.Web;
namespace AIStudio.Components;
/// <summary>
/// The copy button of a code block which AI Studio highlights itself, made like the one of
/// MudBlazor.Markdown, which is internal: the same classes, so that it appears at the same place while
/// the pointer is on the block, and the same confirmation for a moment after a click.
/// </summary>
/// <remarks>
/// It copies through the clipboard service of the app, the native clipboard of the runtime, which the
/// button of the library falls back to as well.
/// </remarks>
public sealed class CodeBlockCopyButton : ComponentBase
{
private const string CSS_CLASS = "ma-2 mud-markdown-code-highlight-copybtn";
private static readonly TimeSpan CONFIRMATION_DURATION = TimeSpan.FromSeconds(1.25);
private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(CodeBlockCopyButton).Namespace, nameof(CodeBlockCopyButton));
/// <summary>
/// The code to copy.
/// </summary>
[Parameter]
public string Text { get; set; } = string.Empty;
[Inject]
private IMudMarkdownClipboardService ClipboardService { get; init; } = null!;
private bool isCopied;
/// <summary>
/// What a copy button of a code block confirms with, those of MudBlazor.Markdown included.
/// </summary>
internal static string ConfirmationText => TB("Copied!");
#region Overrides of ComponentBase
protected override void BuildRenderTree(RenderTreeBuilder builder)
{
if (!this.isCopied)
{
builder.OpenComponent<MudIconButton>(0);
builder.AddComponentParameter(1, nameof(MudIconButton.OnClick), EventCallback.Factory.Create<MouseEventArgs>(this, this.CopyAsync));
builder.AddComponentParameter(2, nameof(MudIconButton.Class), CSS_CLASS);
builder.AddComponentParameter(3, nameof(MudIconButton.Icon), Icons.Material.Rounded.ContentCopy);
builder.AddComponentParameter(4, nameof(MudIconButton.Color), Color.Primary);
builder.AddComponentParameter(5, nameof(MudIconButton.Variant), Variant.Filled);
builder.AddComponentParameter(6, nameof(MudIconButton.Size), Size.Medium);
builder.CloseComponent();
return;
}
builder.OpenComponent<MudTooltip>(10);
builder.AddComponentParameter(11, nameof(MudTooltip.Text), ConfirmationText);
builder.AddComponentParameter(12, nameof(MudTooltip.Arrow), true);
builder.AddComponentParameter(13, nameof(MudTooltip.Placement), Placement.Left);
builder.AddComponentParameter(14, nameof(MudTooltip.Visible), true);
builder.AddComponentParameter(15, nameof(MudTooltip.ShowOnClick), false);
builder.AddComponentParameter(16, nameof(MudTooltip.ShowOnFocus), false);
builder.AddComponentParameter(17, nameof(MudTooltip.ShowOnHover), false);
builder.AddComponentParameter(18, nameof(MudTooltip.RootClass), CSS_CLASS);
builder.AddComponentParameter(19, nameof(MudTooltip.ChildContent), (RenderFragment)(content =>
{
content.OpenComponent<MudIconButton>(0);
content.AddComponentParameter(1, nameof(MudIconButton.Icon), Icons.Material.Rounded.DoneAll);
content.AddComponentParameter(2, nameof(MudIconButton.Color), Color.Success);
content.AddComponentParameter(3, nameof(MudIconButton.Variant), Variant.Filled);
content.AddComponentParameter(4, nameof(MudIconButton.Size), Size.Medium);
content.CloseComponent();
}));
builder.CloseComponent();
}
#endregion
private async Task CopyAsync()
{
if (string.IsNullOrEmpty(this.Text))
return;
await this.ClipboardService.CopyToClipboardAsync(this.Text);
this.isCopied = true;
this.StateHasChanged();
await Task.Delay(CONFIRMATION_DURATION);
this.isCopied = false;
this.StateHasChanged();
}
}
@@ -36,7 +36,7 @@
break;
case ContentView.FORMATTED:
<MudMarkdown Value="@this.shownText" Props="Markdown.DefaultConfig" Styling="@this.MarkdownStyling" MarkdownPipeline="Markdown.CONTENT_MARKDOWN_PIPELINE"/>
<HighlightedMarkdown Value="@this.shownText" Props="Markdown.DefaultConfig" MarkdownPipeline="Markdown.CONTENT_MARKDOWN_PIPELINE"/>
break;
default:
@@ -4,14 +4,14 @@ using Microsoft.AspNetCore.Components.Rendering;
namespace AIStudio.Components;
/// <summary>
/// Renders Markdown the way MudMarkdown does, and shows actions next to every code block which is
/// one of the files of its message, such as a button to save that block.
/// Renders Markdown the way HighlightedMarkdown does, and shows actions next to every code block which
/// is one of the files of its message, such as a button to save that block.
/// </summary>
/// <remarks>
/// In a long answer, the export menu at the top of the message lies far away from the code block
/// the user is reading. MudMarkdown renders every code block through RenderCodeBlock, the highlighted
/// code together with its copy button, and lets us override that. We wrap what it renders and put our
/// actions beside it, so the copy button stays exactly as the library made it.
/// code together with its copy button, and lets us override that. We wrap what HighlightedMarkdown
/// renders and put our actions beside it, so the copy button stays exactly as it was made.
///
/// A block finds its file by where it starts. The chat renders an answer in parts, cut around its
/// formulas, and Markdig reads each part on its own: a block knows where it starts within its part
@@ -19,7 +19,7 @@ namespace AIStudio.Components;
/// A block without a file, such as a formula or a block the model never closed, gets no actions,
/// just as the export menu does not offer it.
/// </remarks>
public sealed class ExportableMarkdown : MudMarkdown
public sealed class ExportableMarkdown : HighlightedMarkdown
{
/// <summary>
/// The files of the message, as PlainFileExport.ExtractFiles read them out of its text. Without
@@ -48,7 +48,7 @@ public sealed class ExportableMarkdown : MudMarkdown
/// <returns>The file, or null when no file starts there.</returns>
internal static MessageFile? FileAt(IReadOnlyList<MessageFile> files, int position) => files.FirstOrDefault(file => file.Position == position);
#region Overrides of MudMarkdown
#region Overrides of HighlightedMarkdown
/// <inheritdoc />
protected override void RenderCodeBlock(in RenderTreeBuilder builder, ref int elementIndex, in Markdig.Syntax.CodeBlock code, in string? info)
@@ -0,0 +1,83 @@
using AIStudio.Tools.CodeHighlighting;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Rendering;
namespace AIStudio.Components;
/// <summary>
/// A code block in a language which AI Studio highlights itself, see CodeHighlighters. It is rendered
/// in the markup of MudBlazor.Markdown, so that it looks like the blocks the library highlights: the
/// same classes, the same colors of the theme, and the same copy button.
/// </summary>
/// <remarks>
/// Its parameters are strings only. Blazor then renders it again only when the code or the language
/// change, so while an answer streams in, only the block which grows is highlighted again.
/// </remarks>
public sealed class HighlightedCodeBlock : ComponentBase
{
/// <summary>
/// The code of the block.
/// </summary>
[Parameter]
public string Code { get; set; } = string.Empty;
/// <summary>
/// The language behind the opening fence, as the model wrote it, for example py.
/// </summary>
[Parameter]
public string Language { get; set; } = string.Empty;
#region Overrides of ComponentBase
protected override void BuildRenderTree(RenderTreeBuilder builder)
{
builder.OpenElement(0, "div");
builder.AddAttribute(1, "class", "hljs mud-markdown-code-highlight");
builder.OpenComponent<CodeBlockCopyButton>(2);
builder.AddComponentParameter(3, nameof(CodeBlockCopyButton.Text), this.Code);
builder.CloseComponent();
//
// MudMarkdown leaves the typography of its code blocks at the default value, and so do we:
//
builder.OpenComponent<MudText>(4);
builder.AddComponentParameter(5, nameof(MudText.HtmlTag), "pre");
builder.AddComponentParameter(6, nameof(MudText.Typo), default(Typo));
builder.AddComponentParameter(7, nameof(MudText.ChildContent), (RenderFragment)this.RenderCode);
builder.CloseComponent();
builder.CloseElement();
}
#endregion
private void RenderCode(RenderTreeBuilder builder)
{
builder.OpenElement(0, "code");
builder.AddAttribute(1, "class", $"hljs language-{this.Language}");
if (CodeHighlighters.TryGetRules(this.Language, out var rules))
{
var position = 0;
foreach (var token in CodeTokenizer.Tokenize(this.Code, rules))
{
if (token.Start > position)
builder.AddContent(2, this.Code[position..token.Start]);
builder.OpenElement(3, "span");
builder.AddAttribute(4, "class", token.Kind.ToCssClass());
builder.AddContent(5, this.Code.Substring(token.Start, token.Length));
builder.CloseElement();
position = token.Start + token.Length;
}
if (position < this.Code.Length)
builder.AddContent(6, this.Code[position..]);
}
else
builder.AddContent(7, this.Code);
builder.CloseElement();
}
}
@@ -0,0 +1,55 @@
using AIStudio.Tools.CodeHighlighting;
using Microsoft.AspNetCore.Components.Rendering;
namespace AIStudio.Components;
/// <summary>
/// Renders Markdown the way MudMarkdown does, and highlights the code blocks of more languages than
/// the library does, such as Python or SQL.
/// </summary>
/// <remarks>
/// MudBlazor.Markdown highlights only a few languages itself, and its highlighter is internal.
/// MudMarkdown renders every code block through RenderCodeBlock, though, and lets us override that.
/// A block in one of the languages of CodeHighlighters becomes a HighlightedCodeBlock; any other block
/// stays as the library renders it. A block which names no language gets the one its code is written in,
/// see CodeLanguageDetector, the same as when it is saved as a file.
/// </remarks>
public class HighlightedMarkdown : MudMarkdown
{
/// <summary>
/// The text of a code block: its lines, each with the line break it came with.
/// </summary>
/// <param name="code">The code block, as Markdig read it.</param>
/// <returns>The text of the block.</returns>
internal static string TextOf(Markdig.Syntax.CodeBlock code) => code.Lines.ToString();
#region Overrides of MudMarkdown
/// <inheritdoc />
protected override void RenderCodeBlock(in RenderTreeBuilder builder, ref int elementIndex, in Markdig.Syntax.CodeBlock code, in string? info)
{
var text = TextOf(code);
var language = CodeLanguageDetector.FenceLanguageOf(info, text);
if (!CodeHighlighters.TryGetRules(language, out _))
{
//
// The copy button of the library confirms in the language of the app, as ours does:
//
this.Styling.CodeBlock.CopyButtonText = CodeBlockCopyButton.ConfirmationText;
base.RenderCodeBlock(builder, ref elementIndex, code, language);
return;
}
//
// Our frames are numbered by their place in this method, as Blazor asks for. The frames of
// the library keep the running count it hands through every render method instead:
//
builder.OpenComponent<HighlightedCodeBlock>(0);
builder.AddComponentParameter(1, nameof(HighlightedCodeBlock.Code), text);
builder.AddComponentParameter(2, nameof(HighlightedCodeBlock.Language), language);
builder.CloseComponent();
}
#endregion
}
@@ -23,19 +23,6 @@ public abstract class MSGComponentBase : ComponentBase, IDisposable, IAsyncDispo
private ILanguagePlugin Lang { get; set; } = PluginFactory.BaseLanguage;
/// <summary>
/// How Markdown rendered by this component looks: its code blocks follow the color theme of the app.
/// </summary>
/// <remarks>
/// Every component which renders Markdown wants the same, so it is written down once here instead
/// of in each of them. Read on every render: a change of the color theme renders every component
/// again, cf. ProcessMessage, and the code blocks change along with it.
/// </remarks>
protected MudMarkdownStyling MarkdownStyling => new()
{
CodeBlock = { Theme = this.SettingsManager.IsDarkMode ? CodeBlockTheme.Dark : CodeBlockTheme.Default },
};
#region Overrides of ComponentBase
protected override async Task OnInitializedAsync()