Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion src/App/Lab/LanguageServicesClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ internal sealed class LanguageServicesClient(
BlazorMonacoInterop blazorMonacoInterop)
{
private Dictionary<string, string> modelUrlToFileName = [];
private IDisposable? completionProvider, semanticTokensProvider, codeActionProvider;
private IDisposable? completionProvider, semanticTokensProvider, codeActionProvider, hoverProvider, signatureHelpProvider;
private string? currentModelUrl;
private DebounceInfo completionDebounce = new(new CancellationTokenSource());
private DebounceInfo diagnosticsDebounce = new(new CancellationTokenSource());
Expand Down Expand Up @@ -128,6 +128,16 @@ private async Task RegisterAsync()
return result;
},
});

hoverProvider = await blazorMonacoInterop.RegisterHoverProviderAsync(cSharpLanguageSelector, new(loggerFactory)
{
ProvideHover = worker.ProvideHoverAsync,
});

signatureHelpProvider = await blazorMonacoInterop.RegisterSignatureHelpProviderAsync(cSharpLanguageSelector, new(loggerFactory)
{
ProvideSignatureHelp = worker.ProvideSignatureHelpAsync,
});
}

private void Unregister()
Expand All @@ -138,6 +148,10 @@ private void Unregister()
semanticTokensProvider = null;
codeActionProvider?.Dispose();
codeActionProvider = null;
hoverProvider?.Dispose();
hoverProvider = null;
signatureHelpProvider?.Dispose();
signatureHelpProvider = null;
InvalidateCaches();
}

Expand Down
5 changes: 3 additions & 2 deletions src/App/Lab/Page.razor
Original file line number Diff line number Diff line change
Expand Up @@ -103,9 +103,10 @@
</CascadingValue>

@* Input / output panels *@
@* Panels have `overflow: hidden` by default, which would cause Monaco Editor popups to get cropped, hence we reset it to `overflow: initial`. *@
<FluentMultiSplitter Orientation="orientation" Style="flex-grow: 1; overflow: hidden">
@* Input panel *@
<FluentMultiSplitterPane Collapsible>
<FluentMultiSplitterPane Collapsible Style="overflow: initial">
<div style="display: flex; flex-direction: column; height: 100%">
@* Input tabs *@
<FluentTabs @bind-ActiveTabId="activeInputTabId" @bind-ActiveTabId:after="OnActiveInputTabIdChangedAsync"
Expand Down Expand Up @@ -161,7 +162,7 @@
</FluentMultiSplitterPane>

@* Output panel *@
<FluentMultiSplitterPane Collapsible>
<FluentMultiSplitterPane Collapsible Style="overflow: initial">
<div style="display: flex; flex-direction: column; height: 100%">
@* Worker status message *@
@if (workerError != null)
Expand Down
16 changes: 16 additions & 0 deletions src/App/Lab/WorkerController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -469,6 +469,22 @@ public Task<string> ProvideCompletionItemsAsync(string modelUri, Position positi
cancellationToken: cancellationToken);
}

public Task<string?> ProvideHoverAsync(string modelUri, string positionJson, CancellationToken cancellationToken)
{
return PostAndReceiveMessageAsync(
new WorkerInputMessage.ProvideHover(modelUri, positionJson) { Id = messageId++ },
deserializeAs: default(string),
cancellationToken: cancellationToken);
}

public Task<string?> ProvideSignatureHelpAsync(string modelUri, string positionJson, string contextJson, CancellationToken cancellationToken)
{
return PostAndReceiveMessageAsync(
new WorkerInputMessage.ProvideSignatureHelp(modelUri, positionJson, contextJson) { Id = messageId++ },
deserializeAs: default(string),
cancellationToken: cancellationToken);
}

public void OnDidChangeWorkspace(ImmutableArray<ModelInfo> models)
{
PostMessage(
Expand Down
57 changes: 57 additions & 0 deletions src/App/Utils/Monaco/BlazorMonacoInterop.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,16 @@ private static partial JSObject RegisterCodeActionProvider(
string language,
[JSMarshalAs<JSType.Any>] object provider);

[JSImport("registerHoverProvider", moduleName)]
private static partial JSObject RegisterHoverProvider(
string language,
[JSMarshalAs<JSType.Any>] object provider);

[JSImport("registerSignatureHelpProvider", moduleName)]
private static partial JSObject RegisterSignatureHelpProvider(
string language,
[JSMarshalAs<JSType.Any>] object provider);

[JSImport("dispose", moduleName)]
private static partial void DisposeDisposable(JSObject disposable);

Expand Down Expand Up @@ -97,6 +107,31 @@ internal static async Task<string> ProvideCompletionItemsAsync(
return json;
}

[JSExport]
internal static async Task<string?> ProvideHoverAsync(
[JSMarshalAs<JSType.Any>] object providerReference,
string modelUri,
string positionJson,
JSObject token)
{
var provider = ((DotNetObjectReference<HoverProvider>)providerReference).Value;
string? json = await provider.ProvideHover(modelUri, positionJson, ToCancellationToken(token, provider.Logger));
return json;
}

[JSExport]
internal static async Task<string?> ProvideSignatureHelpAsync(
[JSMarshalAs<JSType.Any>] object providerReference,
string modelUri,
string positionJson,
string contextJson,
JSObject token)
{
var provider = ((DotNetObjectReference<SignatureHelpProvider>)providerReference).Value;
string? json = await provider.ProvideSignatureHelp(modelUri, positionJson, contextJson, ToCancellationToken(token, provider.Logger));
return json;
}

public async Task<IDisposable> RegisterCompletionProviderAsync(
LanguageSelector language,
CompletionItemProviderAsync completionItemProvider)
Expand Down Expand Up @@ -138,6 +173,28 @@ public async Task<IDisposable> RegisterCodeActionProviderAsync(
return new Disposable(disposable);
}

public async Task<IDisposable> RegisterHoverProviderAsync(
LanguageSelector language,
HoverProvider provider)
{
await EnsureInitializedAsync();
JSObject disposable = RegisterHoverProvider(
JsonSerializer.Serialize(language, BlazorMonacoJsonContext.Default.LanguageSelector),
DotNetObjectReference.Create(provider));
return new Disposable(disposable);
}

public async Task<IDisposable> RegisterSignatureHelpProviderAsync(
LanguageSelector language,
SignatureHelpProvider provider)
{
await EnsureInitializedAsync();
JSObject disposable = RegisterSignatureHelpProvider(
JsonSerializer.Serialize(language, BlazorMonacoJsonContext.Default.LanguageSelector),
DotNetObjectReference.Create(provider));
return new Disposable(disposable);
}

public static CancellationToken ToCancellationToken(JSObject token, ILogger logger, [CallerMemberName] string memberName = "")
{
// No need to initialize, we already must have called other APIs.
Expand Down
13 changes: 13 additions & 0 deletions src/App/Utils/Monaco/HoverProvider.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
namespace DotNetLab;

internal sealed class HoverProvider(ILoggerFactory loggerFactory)
{
public ILogger<HoverProvider> Logger { get; } = loggerFactory.CreateLogger<HoverProvider>();

public delegate Task<string?> ProvideHoverDelegate(
string modelUri,
string positionJson,
CancellationToken cancellationToken);

public required ProvideHoverDelegate ProvideHover { get; init; }
}
14 changes: 14 additions & 0 deletions src/App/Utils/Monaco/SignatureHelpProvider.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
namespace DotNetLab;

internal sealed class SignatureHelpProvider(ILoggerFactory loggerFactory)
{
public ILogger<SignatureHelpProvider> Logger { get; } = loggerFactory.CreateLogger<SignatureHelpProvider>();

public delegate Task<string?> ProvideSignatureHelpDelegate(
string modelUri,
string positionJson,
string contextJson,
CancellationToken cancellationToken);

public required ProvideSignatureHelpDelegate ProvideSignatureHelp { get; init; }
}
54 changes: 54 additions & 0 deletions src/App/wwwroot/js/BlazorMonacoInterop.js
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,60 @@ export function registerCodeActionProvider(language, codeActionProvider) {
});
}

export function registerHoverProvider(language, hoverProvider) {
// https://microsoft.github.io/monaco-editor/typedoc/functions/languages.registerHoverProvider.html
return monaco.languages.registerHoverProvider(JSON.parse(language), {
provideHover: async (model, position, token, context) => {
const result = await globalThis.DotNetLab.BlazorMonacoInterop.ProvideHoverAsync(
hoverProvider, decodeURI(model.uri.toString()), JSON.stringify(position), token);

if (result === null) {
// If null result is returned, it means the request should be ignored, so we need to throw
// (as opposed to returning no code actions).
// The text 'busy' is recommended for this purpose (e.g., it avoids sending telemetry).
throw new Error('busy');
}

return { contents: [{ value: result }] };
},
});
}

export function registerSignatureHelpProvider(language, hoverProvider) {
// https://microsoft.github.io/monaco-editor/typedoc/functions/languages.registerSignatureHelpProvider.html
return monaco.languages.registerSignatureHelpProvider(JSON.parse(language), {
signatureHelpTriggerCharacters: ['(', ','],
provideSignatureHelp: async (model, position, token, context) => {
const contextLight = {
...context,
// Avoid sending currently unused data.
activeSignatureHelp: undefined,
};

const result = await globalThis.DotNetLab.BlazorMonacoInterop.ProvideSignatureHelpAsync(
hoverProvider, decodeURI(model.uri.toString()), JSON.stringify(position), JSON.stringify(contextLight), token);

if (result === null) {
// If null result is returned, it means the request should be ignored, so we need to throw
// (as opposed to returning no code actions).
// The text 'busy' is recommended for this purpose (e.g., it avoids sending telemetry).
throw new Error('busy');
}

const parsed = JSON.parse(result);

if (parsed === null) {
return null;
}

return {
value: parsed,
dispose: () => { }, // Currently not used.
};
},
});
}

/**
* @param {monaco.IDisposable} disposable
*/
Expand Down
82 changes: 82 additions & 0 deletions src/Compiler/LanguageServices.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
using Microsoft.CodeAnalysis.Completion;
using Microsoft.CodeAnalysis.Diagnostics;
using Microsoft.CodeAnalysis.Host.Mef;
using Microsoft.CodeAnalysis.QuickInfo;
using Microsoft.CodeAnalysis.Text;
using Microsoft.Extensions.Logging;
using System.Runtime.CompilerServices;
Expand Down Expand Up @@ -471,6 +472,87 @@ static string addPrefix(string? prefix, string nested)
}
}

/// <returns>
/// Markdown or <see langword="null"/> if cancelled.
/// </returns>
/// <remarks>
/// For inspiration, see <see href="https://github.com/dotnet/roslyn/blob/ad14335550de1134f0b5a59b6cd040001d0d8c8d/src/LanguageServer/Protocol/Handler/Hover/HoverHandler.cs#L26"/>.
/// </remarks>
public async Task<string?> ProvideHoverAsync(string modelUri, string positionJson, CancellationToken cancellationToken)
{
if (!TryGetDocument(modelUri, out var document))
{
return "";
}

var sw = Stopwatch.StartNew();
var position = JsonSerializer.Deserialize(positionJson, BlazorMonacoJsonContext.Default.Position)!;
try
{
var text = await document.GetTextAsync(cancellationToken);
int caretPosition = text.Lines.GetPosition(position.ToLinePosition());
var quickInfoService = document.Project.Services.GetRequiredService<QuickInfoService>();
var quickInfo = await quickInfoService.GetQuickInfoAsync(document, caretPosition, cancellationToken);
if (quickInfo == null)
{
return "";
}

// Insert line breaks in between sections to ensure we get double spacing between sections.
var tags = quickInfo.Sections.SelectMany(static s =>
s.TaggedParts.Add(new TaggedText(TextTags.LineBreak, Environment.NewLine)));

var markdown = MonacoConversions.GetMarkdown(tags, document.Project.Language);

logger.LogDebug("Got hover ({Length}) for {Position} in {Time} ms", markdown.Length, position.Stringify(), sw.ElapsedMilliseconds.SeparateThousands());

return markdown;
}
catch (OperationCanceledException)
{
logger.LogDebug("Canceled hover for {Position} in {Time} ms", position.Stringify(), sw.ElapsedMilliseconds.SeparateThousands());

return null;
}
}

/// <returns>
/// JSON-serialized <see cref="SignatureHelp"/>.
/// We serialize here to avoid serializing twice unnecessarily
/// (first on Worker to App interface, then on App to Monaco interface).
/// </returns>
/// <remarks>
/// For inspiration, see <see href="https://github.com/dotnet/roslyn/blob/ad14335550de1134f0b5a59b6cd040001d0d8c8d/src/LanguageServer/Protocol/Handler/SignatureHelp/SignatureHelpHandler.cs#L25"/>.
/// </remarks>
public async Task<string?> ProvideSignatureHelpAsync(string modelUri, string positionJson, string contextJson, CancellationToken cancellationToken)
{
if (!TryGetDocument(modelUri, out var document))
{
return "null";
}

var sw = Stopwatch.StartNew();
var position = JsonSerializer.Deserialize(positionJson, BlazorMonacoJsonContext.Default.Position)!;
try
{
var text = await document.GetTextAsync(cancellationToken);
int caretPosition = text.Lines.GetPosition(position.ToLinePosition());
var context = JsonSerializer.Deserialize(contextJson, BlazorMonacoJsonContext.Default.SignatureHelpContext)!;
var signatureHelp = await document.GetSignatureHelpAsync(caretPosition, context.ToReason(), context.TriggerCharacter, cancellationToken);
var signatureHelpJson = JsonSerializer.Serialize(signatureHelp, BlazorMonacoJsonContext.Default.SignatureHelp);

logger.LogDebug("Got signature help ({Length}) for {Position} in {Time} ms", signatureHelpJson.Length, position.Stringify(), sw.ElapsedMilliseconds.SeparateThousands());

return signatureHelpJson;
}
catch (OperationCanceledException)
{
logger.LogDebug("Canceled signature help for {Position} in {Time} ms", position.Stringify(), sw.ElapsedMilliseconds.SeparateThousands());

return null;
}
}

public async void OnCompilationFinished()
{
try
Expand Down
Loading