Skip to content
Open
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
1 change: 1 addition & 0 deletions docs/release-notes/.VisualStudio/18.vNext.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
* Reduce allocations in the VS project options reactor: the command-line options and project options caches and the mailbox reply payloads now hold struct tuples, and `IProjectSite.CompilationBinOutputPath` returns `string voption` picked with a new `Array.tryPickV`. ([PR #20413](https://github.com/dotnet/fsharp/pull/20413))
* Build a single-file project's `OtherOptions` reference flags with one array comprehension instead of two `Array.ofSeq` calls and an `Array.append`. ([PR #20499](https://github.com/dotnet/fsharp/pull/20499))
* Fix syntax coloring being lost for a whole file when one symbol resolves into metadata that could not be read. ([Issue #20269](https://github.com/dotnet/fsharp/issues/20269), [PR #20274](https://github.com/dotnet/fsharp/pull/20274))
* Fix a hang when the UI thread waits on project options for a script or a file in F# Miscellaneous Files: the project options reactor reads the caret the UI thread publishes instead of asking the UI thread for it, and only for scripts. ([Issue #20522](https://github.com/dotnet/fsharp/issues/20522), [PR #20523](https://github.com/dotnet/fsharp/pull/20523))

### Changed

Expand Down
86 changes: 0 additions & 86 deletions vsintegration/src/FSharp.Editor/Common/Extensions.fs
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,9 @@ open System
open System.IO
open System.Collections.Immutable
open System.Collections.Generic
open System.Runtime.InteropServices
open System.Threading
open System.Threading.Tasks

open Microsoft.VisualStudio
open Microsoft.VisualStudio.Shell
open Microsoft.VisualStudio.Shell.Interop
open Microsoft.VisualStudio.TextManager.Interop

open Microsoft.CodeAnalysis
open Microsoft.CodeAnalysis.Text
open Microsoft.CodeAnalysis.Host
Expand All @@ -25,10 +19,6 @@ open FSharp.Compiler.Syntax
open FSharp.Compiler.Text

open Microsoft.VisualStudio.FSharp.Editor
open Microsoft.VisualStudio.Editor
open Microsoft.VisualStudio.Text.Editor
open Microsoft.VisualStudio
open Microsoft.VisualStudio.OLE.Interop

type private FSharpGlyph = FSharp.Compiler.EditorServices.FSharpGlyph
type private FSharpRoslynGlyph = Microsoft.CodeAnalysis.ExternalAccess.FSharp.FSharpGlyph
Expand Down Expand Up @@ -69,56 +59,6 @@ type Project with

member this.IsFSharp = this.Language = LanguageNames.FSharp

type TextViewEventsHandler
(
onChangeCaretHandler: (IVsTextView * int * int -> unit) option,
onKillFocus: (IVsTextView -> unit) option,
onSetFocus: (IVsTextView -> unit) option
) =
interface IVsTextViewEvents with
member this.OnChangeCaretLine(view: IVsTextView, newline: int, oldline: int) =
onChangeCaretHandler
|> Option.iter (fun handler -> handler (view, newline, oldline))

member this.OnChangeScrollInfo
(_view: IVsTextView, _iBar: int, _iMinUnit: int, _iMaxUnits: int, _iVisibleUnits: int, _iFirstVisibleUnit: int)
=
()

member this.OnKillFocus(view: IVsTextView) =
onKillFocus |> Option.iter (fun handler -> handler (view))

member this.OnSetBuffer(_view: IVsTextView, _buffer: IVsTextLines) = ()

member this.OnSetFocus(view: IVsTextView) =
onSetFocus |> Option.iter (fun handler -> handler (view))

type ConnectionPointSubscription = System.IDisposable option

// Usage example:
// If a handler is None, to not handle that event
// let subscription = subscribeToTextViewEvents (textView, onChangeCaretHandler, onKillFocus, onSetFocus)
// Unsubscribe using subscription.Dispose()
let subscribeToTextViewEvents (textView: IVsTextView, onChangeCaretHandler, onKillFocus, onSetFocus) : ConnectionPointSubscription =
let handler = TextViewEventsHandler(onChangeCaretHandler, onKillFocus, onSetFocus)

match textView with
| :? IConnectionPointContainer as cpContainer ->
let riid = typeof<IVsTextViewEvents>.GUID
let mutable cookie = 0u

match cpContainer.FindConnectionPoint(ref riid) with
| null -> None
| cp ->
Some(
cp.Advise(handler, &cookie)

{ new IDisposable with
member _.Dispose() = cp.Unadvise(cookie)
}
)
| _ -> None

type Document with

member this.TryGetLanguageService<'T when 'T :> ILanguageService>() =
Expand All @@ -129,32 +69,6 @@ type Document with
| null -> None
| languageServices -> languageServices.GetService<'T>() |> Some

member this.TryGetIVsTextView() : IVsTextView option =
match ServiceProvider.GlobalProvider.GetService(typeof<SVsTextManager>) with
| :? IVsTextManager as textManager ->
// Grab IVsRunningDocumentTable
match ServiceProvider.GlobalProvider.GetService(typeof<SVsRunningDocumentTable>) with
| :? IVsRunningDocumentTable as rdt ->
match rdt.FindAndLockDocument(uint32 _VSRDTFLAGS.RDT_NoLock, this.FilePath) with
| hr, _, _, docData, _ when ErrorHandler.Succeeded(hr) && docData <> IntPtr.Zero ->
match Marshal.GetObjectForIUnknown docData with
| :? IVsTextBuffer as ivsTextBuffer ->
match textManager.GetActiveView(0, ivsTextBuffer) with
| hr, vsTextView when ErrorHandler.Succeeded(hr) -> Some vsTextView
| _ -> None
| _ -> None
| _ -> None
| _ -> None
| _ -> None

member this.TryGetTextViewAndCaretPos() : (IVsTextView * Position) option =
match this.TryGetIVsTextView() with
| Some textView ->
match textView.GetCaretPos() with
| hr, line, column when ErrorHandler.Succeeded(hr) -> Some(textView, Position.fromZ line column)
| _ -> None
| None -> None

member this.IsFSharpScript = isScriptFile this.FilePath

member this.IsFSharpSignatureFile = isSignatureFile this.FilePath
Expand Down
1 change: 1 addition & 0 deletions vsintegration/src/FSharp.Editor/FSharp.Editor.fsproj
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
<Compile Include="LanguageService\ProvideBraceCompletionAttribute.fs" />
<Compile Include="LanguageService\FSharpEditorFactory.fs" />
<Compile Include="LanguageService\TextViewCreationListener.fs" />
<Compile Include="LanguageService\FocusedCaret.fs" />
<Compile Include="LanguageService\Tokenizer.fs" />
<Compile Include="LanguageService\Symbols.fs" />
<Compile Include="LanguageService\IProjectSite.fs" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ open Microsoft.VisualStudio.FSharp.Editor.Extensions
open System.Windows
open Microsoft.VisualStudio
open FSharp.Compiler.Text
open Microsoft.VisualStudio.TextManager.Interop

#nowarn "57"

Expand Down Expand Up @@ -129,7 +128,7 @@ type private FSharpProjectOptionsReactor(checker: FSharpChecker) =
ConcurrentDictionary<ProjectId, struct (Project * FSharpParsingOptions * FSharpProjectOptions)>()

let singleFileCache =
ConcurrentDictionary<DocumentId, Project * VersionStamp * FSharpParsingOptions * FSharpProjectOptions * ConnectionPointSubscription>()
ConcurrentDictionary<DocumentId, Project * VersionStamp * FSharpParsingOptions * FSharpProjectOptions * IDisposable option>()

// This is used to not constantly emit the same compilation.
let weakPEReferences = ConditionalWeakTable<Compilation, FSharpReferencedProject>()
Expand Down Expand Up @@ -204,36 +203,29 @@ type private FSharpProjectOptionsReactor(checker: FSharpChecker) =
cancellableTask {
let! ct = CancellableTask.getCancellationToken ()
let! fileStamp = document.GetTextVersionAsync(ct)
let textViewAndCaret () : (IVsTextView * Position) option = document.TryGetTextViewAndCaretPos()

match singleFileCache.TryGetValue(document.Id) with
| false, _ ->
let! sourceText = document.GetTextAsync(ct)

let getProjectOptionsFromScript textViewAndCaret =
let caret = textViewAndCaret ()

match caret with
| None ->
checker.GetProjectOptionsFromScript(
document.FilePath,
sourceText.ToFSharpSourceText(),
previewEnabled = SessionsProperties.fsiPreview,
assumeDotNetFramework = not SessionsProperties.fsiUseNetCore,
userOpName = userOpName
)

| Some(_, caret) ->
checker.GetProjectOptionsFromScript(
document.FilePath,
sourceText.ToFSharpSourceText(),
caret,
previewEnabled = SessionsProperties.fsiPreview,
assumeDotNetFramework = not SessionsProperties.fsiUseNetCore,
userOpName = userOpName
)

let! scriptProjectOptions, _ = getProjectOptionsFromScript textViewAndCaret
// FCS reads the caret only to skip resolving the `#r "nuget: …"` line being typed, and only scripts have those.
let focusedCaret =
if isScriptFile document.FilePath then
FocusedCaret.TryGet sourceText
else
ValueNone

let getProjectOptionsFromScript () =
checker.GetProjectOptionsFromScript(
document.FilePath,
sourceText.ToFSharpSourceText(),
?caret = (focusedCaret |> ValueOption.toOption |> Option.bind _.Position),
previewEnabled = SessionsProperties.fsiPreview,
assumeDotNetFramework = not SessionsProperties.fsiUseNetCore,
userOpName = userOpName
)

let! scriptProjectOptions, _ = getProjectOptionsFromScript ()
let project = document.Project

let otherOptions =
Expand Down Expand Up @@ -270,25 +262,20 @@ type private FSharpProjectOptionsReactor(checker: FSharpChecker) =

let updateProjectOptions () =
async {
let! scriptProjectOptions, _ = getProjectOptionsFromScript textViewAndCaret
let! scriptProjectOptions, _ = getProjectOptionsFromScript ()

checker.NotifyFileChanged(document.FilePath, scriptProjectOptions)
|> Async.Start
}
|> Async.Start

let onChangeCaretHandler (_, _newline: int, _oldline: int) = updateProjectOptions ()
let onKillFocus (_) = updateProjectOptions ()
let onSetFocus (_) = updateProjectOptions ()

let addToCacheAndSubscribe value =
match value with
| projectId, fileStamp, parsingOptions, projectOptions, _ ->
let subscription =
match textViewAndCaret () with
| Some(textView, _) ->
subscribeToTextViewEvents (textView, (Some onChangeCaretHandler), (Some onKillFocus), (Some onSetFocus))
| None -> None
focusedCaret
|> ValueOption.map _.LineChanged.Subscribe(updateProjectOptions)
|> ValueOption.toOption
Comment on lines +276 to +278

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The lifecycle is real but it cannot leave a reference pending, because the suppression and the subscription have the same precondition. FCS skips a #r "nuget: …" line only when it is given a caret, and a caret can only come from the same FocusedCaret object whose LineChanged the entry subscribes to. When TryGet answers ValueNone — no view yet — no caret is passed, so that computation resolves every reference rather than holding one back, and there is nothing for a later notification to re-resolve. Once the user does type in the view that appears, the text version changes, the entry is dropped by fileStamp <> oldFileStamp, and the recomputation finds the caret and subscribes.

What the same lifecycle does break is the other direction, and that is fixed in 1826a7e2a6: the caret lives on the buffer, which outlives the view, so a view closed while it held focus left its line published. Options computed after that were given a caret on a line no editor was on, and the #r there stayed unresolved until some other view moved a caret. Closed now clears the caret when the view being closed is the one holding focus — a view that does not hold focus published nothing to clear, and a sibling view's caret is left alone.


(projectId, fileStamp, parsingOptions, projectOptions, subscription)

Expand Down
85 changes: 85 additions & 0 deletions vsintegration/src/FSharp.Editor/LanguageService/FocusedCaret.fs
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
// Copyright (c) Microsoft Corporation. All Rights Reserved. See License.txt in the project root for license information.

namespace Microsoft.VisualStudio.FSharp.Editor

open System.ComponentModel.Composition

open Microsoft.CodeAnalysis.Text
open Microsoft.VisualStudio.Text.Editor
open Microsoft.VisualStudio.Utilities

open FSharp.Compiler.Text

/// The caret of the focused editor on a text buffer, published by the UI thread for the project options
/// reactor: the UI thread can be blocked waiting for the reactor, so the reactor must never wait for it.
[<Sealed>]
type internal FocusedCaret() =

// A reference, not an option: the reactor reads it while the UI thread writes, and must never see a torn struct.
[<VolatileField>]
let mutable position: Position option = None

let lineChanged = Event<unit>()

/// None while no editor on the buffer has focus.
member _.Position = position

/// Raised on the UI thread when the caret moves to another line, or focus enters or leaves the buffer's editors.
member _.LineChanged = lineChanged.Publish

member _.Update(newPosition: Position option) =
let hasLineChanged =
(position |> Option.map _.Line) <> (newPosition |> Option.map _.Line)

position <- newPosition

if hasLineChanged then
lineChanged.Trigger()
Comment on lines +30 to +37

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in 1826a7e2a6: FocusedCaretTests covers what the reactor reads and when it is told to read again — a move to another line raises the change and publishes the position, a move along the same line publishes the column without raising it, focus leaving raises it and clears the caret, focus already gone raises nothing, and a text no view is open on has no caret at all. Five tests, all passing.

The tracker itself is not tested: it is an IWpfTextViewCreationListener whose whole body is subscriptions to ITextView events, and standing up a text view in these tests would test the editor rather than this code — the same line the repository draws around the other OLE and view plumbing. What it does with what it observes is the Update behaviour above, and the reactor side of it is the subscription in tryComputeOptionsBySingleScriptOrFile, which needs a real FSharpChecker script resolution to observe.


static member TryGet(sourceText: SourceText) =
match sourceText.Container.TryGetTextBuffer() with
| null -> ValueNone
| buffer ->
match buffer.Properties.TryGetProperty<FocusedCaret>(typeof<FocusedCaret>) with
| true, caret -> ValueSome caret
| _ -> ValueNone

[<Export(typeof<IWpfTextViewCreationListener>)>]
[<ContentType(FSharpConstants.FSharpContentTypeName)>]
[<TextViewRole(PredefinedTextViewRoles.Editable)>]
type internal FocusedCaretTracker() =

let caretOf (textView: ITextView) =
let caret = textView.Caret.Position.BufferPosition
let line = caret.GetContainingLine()
Position.fromZ line.LineNumber (caret.Position - line.Start.Position)

interface IWpfTextViewCreationListener with
member _.TextViewCreated(textView) =
let focusedCaret =
textView.TextBuffer.Properties.GetOrCreateSingletonProperty(fun () -> FocusedCaret())

let publish _ =
focusedCaret.Update(Some(caretOf textView))

let subscriptions =
[
textView.Caret.PositionChanged.Subscribe(fun _ ->
if textView.HasAggregateFocus then
publish ())
textView.GotAggregateFocus.Subscribe publish
textView.LostAggregateFocus.Subscribe(fun _ -> focusedCaret.Update None)
]

if textView.HasAggregateFocus then
publish ()

textView.Closed.Add(fun _ ->
for subscription in subscriptions do
subscription.Dispose()

// The caret belongs to the buffer, which outlives the view. A view closed while it held focus
// would leave its line published, and the reactor would go on skipping the `#r` on a line no
// editor is on. A view that does not hold focus published nothing to clear.
if textView.HasAggregateFocus then
focusedCaret.Update None)
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
<Compile Include="QuickInfoTests.fs" />
<Compile Include="TaskListServiceTests.fs" />
<Compile Include="NavigateToSearchServiceTests.fs" />
<Compile Include="FocusedCaretTests.fs" />
<Compile Include="CodeFixes\CodeFixTestFramework.fs" />
<Compile Include="CodeFixes\AddInstanceMemberParameterTests.fs" />
<Compile Include="CodeFixes\ConvertToAnonymousRecordTests.fs" />
Expand Down
63 changes: 63 additions & 0 deletions vsintegration/tests/FSharp.Editor.Tests/FocusedCaretTests.fs
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
// Copyright (c) Microsoft Corporation. All Rights Reserved. See License.txt in the project root for license information.

/// What the project options reactor reads off the focused editor, and when it is told to read it again.
/// Only the line matters: the caret decides which `#r "nuget: …"` line is still being typed, and a script's
/// references are resolved again once it leaves that line or the editors lose focus.
module FSharp.Editor.Tests.FocusedCaretTests

open Xunit
open Microsoft.CodeAnalysis.Text
open Microsoft.VisualStudio.FSharp.Editor
open FSharp.Compiler.Text

let private caretWithRecordedChanges () =
let caret = FocusedCaret()
let changes = ResizeArray()
caret.LineChanged.Add(fun () -> changes.Add caret.Position)
caret, changes

[<Fact>]
let ``a caret that moves to another line is published as a line change`` () =
let caret, changes = caretWithRecordedChanges ()

caret.Update(Some(Position.mkPos 7 0))
caret.Update(Some(Position.mkPos 8 4))

Assert.Equal<int list>([ 7; 8 ], [ for position in changes -> position.Value.Line ])
Assert.Equal(8, caret.Position.Value.Line)

[<Fact>]
let ``a caret that moves along its line is not a line change`` () =
let caret, changes = caretWithRecordedChanges ()

caret.Update(Some(Position.mkPos 7 0))
caret.Update(Some(Position.mkPos 7 12))

Assert.Equal(1, changes.Count)
// The position is still published: the reactor reads the column of the line it skips.
Assert.Equal(12, caret.Position.Value.Column)

[<Fact>]
let ``editors losing focus is a line change, and the caret is gone`` () =
let caret, changes = caretWithRecordedChanges ()

caret.Update(Some(Position.mkPos 7 0))
caret.Update None

Assert.Equal(2, changes.Count)
Assert.True(caret.Position.IsNone)

[<Fact>]
let ``no editor has focus twice over is one line change`` () =
let caret, changes = caretWithRecordedChanges ()

caret.Update None
caret.Update None

Assert.Empty changes
Assert.True(caret.Position.IsNone)

[<Fact>]
let ``a text with no editor behind it has no caret`` () =
// What the reactor sees for a document Visual Studio has not opened a view on.
Assert.True((FocusedCaret.TryGet(SourceText.From "#r \"nuget: Newtonsoft.Json\"\n")).IsNone)
Loading