提交 d4a81920 编写于 作者: A Artur Spychaj

Refactor send to interactive

Base selection on the currently opened Document
上级 ab1b4fcf
......@@ -14,42 +14,50 @@ namespace Microsoft.CodeAnalysis.Editor.CSharp.Interactive
{
[Export(typeof(ISendToInteractiveSubmissionProvider))]
internal sealed class CSharpSendToInteractiveSubmissionProvider
: SendToInteractiveSubmissionProvider
: AbstractSendToInteractiveSubmissionProvider
{
protected override bool CanParseSubmission(string code)
{
SourceText sourceCode = SourceText.From(code);
ParseOptions options = CSharpParseOptions.Default.WithKind(SourceCodeKind.Script);
SyntaxTree tree = SyntaxFactory.ParseSyntaxTree(sourceCode, options);
if (tree == null)
{
return false;
}
return tree.HasCompilationUnitRoot && !tree.GetDiagnostics().Any();
SyntaxTree tree = SyntaxFactory.ParseSyntaxTree(code, options);
return tree.HasCompilationUnitRoot &&
!tree.GetDiagnostics().Any(diagnostic => diagnostic.Severity == DiagnosticSeverity.Error);
}
protected override IEnumerable<TextSpan> GetExecutableSyntaxTreeNodeSelection(TextSpan selectionSpan, SourceText source, SyntaxNode root, SemanticModel model)
protected override IEnumerable<TextSpan> GetExecutableSyntaxTreeNodeSelection(TextSpan selectionSpan, SyntaxNode root)
{
SyntaxNode expandedNode = GetExecutableSyntaxTreeNode(selectionSpan, source, root, model);
SyntaxNode expandedNode = GetSyntaxNodeForSubmission(selectionSpan, root);
return expandedNode != null
? new TextSpan[] { expandedNode.Span }
: Array.Empty<TextSpan>();
}
private SyntaxNode GetExecutableSyntaxTreeNode(TextSpan selectionSpan, SourceText source, SyntaxNode root, SemanticModel model)
/// <summary>
/// Finds a <see cref="SyntaxNode"/> that should be submitted to REPL.
/// </summary>
/// <param name="selectionSpan">Selection that user has originally made.</param>
/// <param name="root">Root of the syntax tree.</param>
private SyntaxNode GetSyntaxNodeForSubmission(TextSpan selectionSpan, SyntaxNode root)
{
Tuple<SyntaxToken, SyntaxToken> tokens = GetSelectedTokens(selectionSpan, root);
var startToken = tokens.Item1;
var endToken = tokens.Item2;
SyntaxToken startToken, endToken;
GetSelectedTokens(selectionSpan, root, out startToken, out endToken);
// Ensure that the first token comes before the last token.
// Otherwise selection did not contain any tokens.
if (startToken != endToken && startToken.Span.End > endToken.SpanStart)
{
return null;
}
// If a selection falls within a single executable statement then execute that statement.
var startNode = GetGlobalExecutableStatement(startToken);
var endNode = GetGlobalExecutableStatement(endToken);
if (startToken == endToken)
{
return GetSyntaxNodeForSubmission(startToken.Parent);
}
var startNode = GetSyntaxNodeForSubmission(startToken.Parent);
var endNode = GetSyntaxNodeForSubmission(endToken.Parent);
// If there is no SyntaxNode worth sending to the REPL return null.
if (startNode == null || endNode == null)
{
return null;
......@@ -67,16 +75,14 @@ private SyntaxNode GetExecutableSyntaxTreeNode(TextSpan selectionSpan, SourceTex
// Selection spans multiple statements.
// In this case find common parent and find a span of statements within that parent.
var commonNode = root.FindNode(TextSpan.FromBounds(startNode.Span.Start, endNode.Span.End));
return commonNode;
return GetSyntaxNodeForSubmission(startNode.GetCommonRoot(endNode));
}
private static SyntaxNode GetGlobalExecutableStatement(SyntaxToken token)
{
return GetGlobalExecutableStatement(token.Parent);
}
private static SyntaxNode GetGlobalExecutableStatement(SyntaxNode node)
/// <summary>
/// Finds a <see cref="SyntaxNode"/> that should be submitted to REPL.
/// </summary>
/// <param name="node">The currently selected node.</param>
private static SyntaxNode GetSyntaxNodeForSubmission(SyntaxNode node)
{
SyntaxNode candidate = node.GetAncestorOrThis<StatementSyntax>();
if (candidate != null)
......@@ -85,7 +91,7 @@ private static SyntaxNode GetGlobalExecutableStatement(SyntaxNode node)
}
candidate = node.GetAncestorsOrThis<SyntaxNode>()
.Where(n => IsGlobalExecutableStatement(n)).FirstOrDefault();
.Where(IsSubmissionNode).FirstOrDefault();
if (candidate != null)
{
return candidate;
......@@ -94,7 +100,8 @@ private static SyntaxNode GetGlobalExecutableStatement(SyntaxNode node)
return null;
}
private static bool IsGlobalExecutableStatement(SyntaxNode node)
/// <summary>Returns <c>true</c> if <c>node</c> could be treated as a REPL submission.</summary>
private static bool IsSubmissionNode(SyntaxNode node)
{
var kind = node.Kind();
return SyntaxFacts.IsTypeDeclaration(kind)
......@@ -102,23 +109,16 @@ private static bool IsGlobalExecutableStatement(SyntaxNode node)
|| node.IsKind(SyntaxKind.UsingDirective);
}
private Tuple<SyntaxToken, SyntaxToken> GetSelectedTokens(TextSpan selectionSpan, SyntaxNode root)
private void GetSelectedTokens(
TextSpan selectionSpan,
SyntaxNode root,
out SyntaxToken startToken,
out SyntaxToken endToken)
{
if (selectionSpan.Length == 0)
{
var selectedToken = root.FindTokenOnLeftOfPosition(selectionSpan.End);
return Tuple.Create(
selectedToken,
selectedToken);
}
else
{
// For a selection find the first and the last token of the selection.
// Ensure that the first token comes before the last token.
return Tuple.Create(
root.FindTokenOnRightOfPosition(selectionSpan.Start),
root.FindTokenOnLeftOfPosition(selectionSpan.End));
}
endToken = root.FindTokenOnLeftOfPosition(selectionSpan.End);
startToken = selectionSpan.Length == 0
? endToken
: root.FindTokenOnRightOfPosition(selectionSpan.Start);
}
}
}
......@@ -62,7 +62,11 @@ void ICommandHandler<ExecuteInInteractiveCommandArgs>.ExecuteCommand(ExecuteInIn
allowCancel: true,
action: context =>
{
window.SubmitAsync(new[] { GetSelectedText(args, context.CancellationToken) });
string submission = GetSelectedText(args, context.CancellationToken);
if (!String.IsNullOrWhiteSpace(submission))
{
window.SubmitAsync(new string[] { submission });
}
});
}
......
......@@ -9,6 +9,8 @@
using Microsoft.VisualStudio.InteractiveWindow;
using System.Collections.Immutable;
using Microsoft.CodeAnalysis.Editor;
using Microsoft.VisualStudio.Text.Editor;
using Microsoft.VisualStudio.Text.Editor.OptionsExtensionMethods;
namespace Microsoft.VisualStudio.LanguageServices.Interactive
{
......@@ -22,10 +24,13 @@ internal abstract class ResetInteractive
private readonly Func<string, string> _createImport;
private readonly IEditorOptionsFactoryService _editorOptionsFactoryService;
internal event EventHandler ExecutionCompleted;
internal ResetInteractive(Func<string, string> createReference, Func<string, string> createImport)
internal ResetInteractive(IEditorOptionsFactoryService editorOptionsFactoryService, Func<string, string> createReference, Func<string, string> createImport)
{
_editorOptionsFactoryService = editorOptionsFactoryService;
_createReference = createReference;
_createImport = createImport;
}
......@@ -104,8 +109,9 @@ internal Task Execute(IInteractiveWindow interactiveWindow, string title)
await interactiveEvaluator.SetPathsAsync(referenceSearchPaths, sourceSearchPaths, projectDirectory).ConfigureAwait(true);
}
var editorOptions = _editorOptionsFactoryService.GetOptions(interactiveWindow.CurrentLanguageBuffer);
var importReferencesCommand = referencePaths.Select(_createReference);
var importNamespacesCommand = namespacesToImport.Select(_createImport).Join("\r\n");
var importNamespacesCommand = namespacesToImport.Select(_createImport).Join(editorOptions.GetNewLineCharacter());
await interactiveWindow.SubmitAsync(importReferencesCommand.Concat(new[]
{
importNamespacesCommand
......
......@@ -16,79 +16,71 @@ namespace Microsoft.CodeAnalysis.Editor.Interactive
/// <summary>
/// Implementers of this interface are responsible for retrieving source code that
/// should be sent to the REPL given the user's selection.
///
///
/// If the user does not make a selection then a line should be selected.
/// If the user selects code that fails to be parsed then the selection gets expanded
/// to a syntax node.
/// </summary>
internal abstract class SendToInteractiveSubmissionProvider : ISendToInteractiveSubmissionProvider
internal abstract class AbstractSendToInteractiveSubmissionProvider : ISendToInteractiveSubmissionProvider
{
/// <summary>Expands the selection span of an invalid selection to a span that should be sent to REPL.</summary>
protected abstract IEnumerable<TextSpan> GetExecutableSyntaxTreeNodeSelection(TextSpan selectedSpan, SourceText source, SyntaxNode node, SemanticModel model);
protected abstract IEnumerable<TextSpan> GetExecutableSyntaxTreeNodeSelection(TextSpan selectedSpan, SyntaxNode node);
/// <summary>Returns whether the submission can be parsed in interactive.</summary>
protected abstract bool CanParseSubmission(string code);
public string GetSelectedText(IEditorOptions editorOptions, CommandArgs args, CancellationToken cancellationToken)
string ISendToInteractiveSubmissionProvider.GetSelectedText(IEditorOptions editorOptions, CommandArgs args, CancellationToken cancellationToken)
{
IEnumerable<SnapshotSpan> selectedSpans = args.TextView.Selection.IsEmpty
? GetExpandedLineAsync(editorOptions, args, cancellationToken).WaitAndGetResult(cancellationToken)
: args.TextView.Selection.GetSnapshotSpansOnBuffer(args.SubjectBuffer).Where(ss => ss.Length > 0);
IEnumerable<SnapshotSpan> selectedSpans = args.TextView.Selection.GetSnapshotSpansOnBuffer(args.SubjectBuffer).Where(ss => ss.Length > 0);
// If there is no selection select the current line.
if (!selectedSpans.Any())
{
selectedSpans = GetSelectedLine(args);
}
return GetSubmissionFromSelectedSpans(editorOptions, selectedSpans);
}
// Send the selection as is if it does not contain any parsing errors.
/// <summary>Returns the span for the selected line. Extends it if it is a part of a multi line statement or declaration.</summary>
private Task<IEnumerable<SnapshotSpan>> GetExpandedLineAsync(IEditorOptions editorOptions, CommandArgs args, CancellationToken cancellationToken)
{
IEnumerable<SnapshotSpan> selectedSpans = GetSelectedLine(args.TextView);
var candidateSubmission = GetSubmissionFromSelectedSpans(editorOptions, selectedSpans);
if (CanParseSubmission(candidateSubmission))
{
return candidateSubmission;
}
// Otherwise heuristically try to expand it.
return GetSubmissionFromSelectedSpans(editorOptions, ExpandSelection(selectedSpans, args, cancellationToken));
return CanParseSubmission(candidateSubmission)
? Task.FromResult(selectedSpans)
: ExpandSelectionAsync(selectedSpans, args, cancellationToken);
}
/// <summary>Returns the span for the currently selected line.</summary>
private static IEnumerable<SnapshotSpan> GetSelectedLine(CommandArgs args)
private static IEnumerable<SnapshotSpan> GetSelectedLine(ITextView textView)
{
SnapshotPoint? caret = args.TextView.GetCaretPoint(args.SubjectBuffer);
int caretPosition = args.TextView.Caret.Position.BufferPosition.Position;
ITextSnapshotLine containingLine = caret.Value.GetContainingLine();
return new SnapshotSpan[] {
new SnapshotSpan(containingLine.Start, containingLine.End)
};
ITextSnapshotLine snapshotLine = textView.Caret.Position.VirtualBufferPosition.Position.GetContainingLine();
SnapshotSpan span = new SnapshotSpan(snapshotLine.Start, snapshotLine.LengthIncludingLineBreak);
return new NormalizedSnapshotSpanCollection(span);
}
private async Task<IEnumerable<SnapshotSpan>> GetExecutableSyntaxTreeNodeSelection(
private async Task<IEnumerable<SnapshotSpan>> GetExecutableSyntaxTreeNodeSelectionAsync(
TextSpan selectionSpan,
CommandArgs args,
ITextSnapshot snapshot,
CancellationToken cancellationToken)
{
Document doc = args.SubjectBuffer.GetRelatedDocuments().FirstOrDefault();
var semanticDocument = await SemanticDocument.CreateAsync(doc, cancellationToken).ConfigureAwait(false);
var text = semanticDocument.Text;
Document doc = args.SubjectBuffer.CurrentSnapshot.GetOpenDocumentInCurrentContextWithChanges();
var semanticDocument = await SemanticDocument.CreateAsync(doc, cancellationToken).ConfigureAwait(true);
var root = semanticDocument.Root;
var model = semanticDocument.SemanticModel;
return GetExecutableSyntaxTreeNodeSelection(selectionSpan, text, root, model)
return GetExecutableSyntaxTreeNodeSelection(selectionSpan, root)
.Select(span => new SnapshotSpan(snapshot, span.Start, span.Length));
}
private IEnumerable<SnapshotSpan> ExpandSelection(IEnumerable<SnapshotSpan> selectedSpans, CommandArgs args, CancellationToken cancellationToken)
private async Task<IEnumerable<SnapshotSpan>> ExpandSelectionAsync(IEnumerable<SnapshotSpan> selectedSpans, CommandArgs args, CancellationToken cancellationToken)
{
var selectedSpansStart = selectedSpans.Min(span => span.Start);
var selectedSpansEnd = selectedSpans.Max(span => span.End);
ITextSnapshot snapshot = args.TextView.TextSnapshot;
IEnumerable<SnapshotSpan> newSpans = GetExecutableSyntaxTreeNodeSelection(
IEnumerable<SnapshotSpan> newSpans = await GetExecutableSyntaxTreeNodeSelectionAsync(
TextSpan.FromBounds(selectedSpansStart, selectedSpansEnd),
args,
snapshot,
cancellationToken).WaitAndGetResult(cancellationToken);
cancellationToken).ConfigureAwait(true);
return newSpans.Any()
? newSpans.Select(n => new SnapshotSpan(snapshot, n.Span.Start, n.Span.Length))
......
......@@ -70,7 +70,7 @@ internal class InteractiveEditorFeaturesResources {
}
/// <summary>
/// Looks up a localized string similar to Copying selection to interactive window..
/// Looks up a localized string similar to Copying selection to Interactive Window..
/// </summary>
internal static string CopyToInteractiveDescription {
get {
......@@ -79,7 +79,7 @@ internal class InteractiveEditorFeaturesResources {
}
/// <summary>
/// Looks up a localized string similar to Executing selection in interactive window..
/// Looks up a localized string similar to Executing selection in Interactive Window..
/// </summary>
internal static string ExecuteInInteractiveDescription {
get {
......
......@@ -121,10 +121,10 @@
<value>Building Project</value>
</data>
<data name="CopyToInteractiveDescription" xml:space="preserve">
<value>Copying selection to interactive window.</value>
<value>Copying selection to Interactive Window.</value>
</data>
<data name="ExecuteInInteractiveDescription" xml:space="preserve">
<value>Executing selection in interactive window.</value>
<value>Executing selection in Interactive Window.</value>
</data>
<data name="ReferencesCommandDescription" xml:space="preserve">
<value>Print a list of referenced assemblies.</value>
......
......@@ -8,14 +8,14 @@ Namespace Microsoft.CodeAnalysis.Editor.VisualBasic.Interactive
<Export(GetType(ISendToInteractiveSubmissionProvider))>
Friend NotInheritable Class VisualBasicSendToInteractiveSubmissionProvider
Inherits SendToInteractiveSubmissionProvider
Inherits AbstractSendToInteractiveSubmissionProvider
Protected Overrides Function CanParseSubmission(code As String) As Boolean
' Return True to send the direct selection.
Return True
End Function
Protected Overrides Function GetExecutableSyntaxTreeNodeSelection(position As TextSpan, source As SourceText, node As SyntaxNode, model As SemanticModel) As IEnumerable(Of TextSpan)
Protected Overrides Function GetExecutableSyntaxTreeNodeSelection(position As TextSpan, node As SyntaxNode) As IEnumerable(Of TextSpan)
Return Nothing
End Function
End Class
......
......@@ -36,6 +36,10 @@ internal class InteractiveCommandHandlerTests
public void TestExecuteInInteractiveWithoutSelection()
{
AssertExecuteInInteractive(Caret, new string[0]);
AssertExecuteInInteractive(
@"var x = 1;
$$
var y = 2;", new string[0]);
AssertExecuteInInteractive(ExampleCode1 + Caret, ExampleCode1);
AssertExecuteInInteractive(ExampleCode1.Insert(3, Caret), ExampleCode1);
AssertExecuteInInteractive(ExampleCode2 + Caret, ExampleCode2Line2);
......@@ -46,6 +50,9 @@ public void TestExecuteInInteractiveWithoutSelection()
[Trait(Traits.Feature, Traits.Features.Interactive)]
public void TestExecuteInInteractiveWithEmptyBuffer()
{
AssertExecuteInInteractive(
@"{|Selection:|}var x = 1;
{|Selection:$$|}var y = 2;", new string[0]);
AssertExecuteInInteractive($@"{{|Selection:{ExampleCode1}$$|}}", ExampleCode1);
AssertExecuteInInteractive($@"{{|Selection:{ExampleCode2}$$|}}", ExampleCode2);
AssertExecuteInInteractive(
......@@ -88,6 +95,29 @@ public void TestExecuteInInteractiveWithNonEmptyBuffer()
submissionBuffer: "var x = 1;");
}
[WpfFact]
public void TestExecuteInInteractiveWithDefines()
{
string exampleWithIfDirective =
@"#if DEF
public void $$Run()
{
}
#endif";
AssertExecuteInInteractive(exampleWithIfDirective,
@"public void Run()");
string exampleWithDefine =
$@"#define DEF
{exampleWithIfDirective}";
AssertExecuteInInteractive(exampleWithDefine,
@"public void Run()
{
}");
}
[WpfFact]
[Trait(Traits.Feature, Traits.Features.Interactive)]
public void TestCopyToInteractiveWithoutSelection()
......@@ -156,12 +186,10 @@ private static void AssertExecuteInInteractive(string code, string[] expectedSub
private static void PrepareSubmissionBuffer(string submissionBuffer, InteractiveWindowCommandHandlerTestState workspace)
{
if (string.IsNullOrEmpty(submissionBuffer))
if (!string.IsNullOrEmpty(submissionBuffer))
{
return;
workspace.WindowCurrentLanguageBuffer.Insert(0, submissionBuffer);
}
workspace.WindowCurrentLanguageBuffer.Insert(0, submissionBuffer);
}
}
}
......@@ -8,6 +8,8 @@
using System;
using System.Collections.Immutable;
using System.Linq;
using Microsoft.VisualStudio.Text.Editor;
using Microsoft.VisualStudio.Text.Editor.OptionsExtensionMethods;
namespace Microsoft.CodeAnalysis.Editor.CSharp.UnitTests.Interactive.Commands
{
......@@ -37,13 +39,12 @@ public async void TestResetREPLWithProjectContext()
Assert.True(replReferenceCommands.Any(rc => rc.EndsWith(@"ResetInteractiveTestsAssembly.dll""")));
Assert.True(replReferenceCommands.Any(rc => rc.EndsWith(@"ResetInteractiveVisualBasicSubproject.dll""")));
var expectedSubmissions = new List<string>();
expectedSubmissions.AddRange(replReferenceCommands.Select(r => r + "\r\n"));
expectedSubmissions.Add(string.Join("\r\n", @"using ""ns1"";", @"using ""ns2"";") + "\r\n");
AssertResetInteractive(workspace, project, buildSucceeds: true, expectedSubmissions: expectedSubmissions);
var expectedReferences = replReferenceCommands.ToList();
var expectedUsings = new List<string> { @"using ""ns1"";", @"using ""ns2"";" };
AssertResetInteractive(workspace, project, buildSucceeds: true, expectedReferences: expectedReferences, expectedUsings: expectedUsings);
// Test that no submissions are executed if the build fails.
AssertResetInteractive(workspace, project, buildSucceeds: false, expectedSubmissions: new List<string>());
AssertResetInteractive(workspace, project, buildSucceeds: false, expectedReferences: new List<string>());
}
}
......@@ -51,8 +52,12 @@ public async void TestResetREPLWithProjectContext()
TestWorkspace workspace,
Project project,
bool buildSucceeds,
List<string> expectedSubmissions)
List<string> expectedReferences = null,
List<string> expectedUsings = null)
{
expectedReferences = expectedReferences ?? new List<string>();
expectedUsings = expectedUsings ?? new List<string>();
InteractiveWindowTestHost testHost = new InteractiveWindowTestHost();
List<string> executedSubmissionCalls = new List<string>();
EventHandler<string> ExecuteSubmission = (_, code) => { executedSubmissionCalls.Add(code); };
......@@ -60,9 +65,13 @@ public async void TestResetREPLWithProjectContext()
testHost.Evaluator.OnExecute += ExecuteSubmission;
IWaitIndicator waitIndicator = workspace.GetService<IWaitIndicator>();
IEditorOptionsFactoryService editorOptionsFactoryService = workspace.GetService<IEditorOptionsFactoryService>();
var editorOptions = editorOptionsFactoryService.GetOptions(testHost.Window.CurrentLanguageBuffer);
var newLineCharacter = editorOptions.GetNewLineCharacter();
TestResetInteractive resetInteractive = new TestResetInteractive(
waitIndicator,
editorOptionsFactoryService,
CreateReplReferenceCommand,
CreateImport,
buildSucceeds: buildSucceeds)
......@@ -80,6 +89,16 @@ public async void TestResetREPLWithProjectContext()
Assert.Equal(1, resetInteractive.BuildProjectCount);
Assert.Equal(0, resetInteractive.CancelBuildProjectCount);
var expectedSubmissions = new List<string>();
if (expectedReferences.Any())
{
expectedSubmissions.AddRange(expectedReferences.Select(r => r + newLineCharacter));
}
if (expectedUsings.Any())
{
expectedSubmissions.Add(string.Join(newLineCharacter, expectedUsings) + newLineCharacter);
}
AssertEx.Equal(expectedSubmissions, executedSubmissionCalls);
testHost.Evaluator.OnExecute -= ExecuteSubmission;
......
......@@ -2,6 +2,7 @@
using Microsoft.CodeAnalysis.Editor.Host;
using Microsoft.VisualStudio.LanguageServices.Interactive;
using Microsoft.VisualStudio.Text.Editor;
using System;
using System.Collections.Immutable;
using System.Threading.Tasks;
......@@ -28,8 +29,13 @@ internal class TestResetInteractive : ResetInteractive
internal string ProjectDirectory { get; set; }
public TestResetInteractive(IWaitIndicator waitIndicator, Func<string, string> createReference, Func<string, string> createImport, bool buildSucceeds)
: base(createReference, createImport)
public TestResetInteractive(
IWaitIndicator waitIndicator,
IEditorOptionsFactoryService editorOptionsFactoryService,
Func<string, string> createReference,
Func<string, string> createImport,
bool buildSucceeds)
: base(editorOptionsFactoryService, createReference, createImport)
{
_waitIndicator = waitIndicator;
_buildSucceeds = buildSucceeds;
......
......@@ -16,6 +16,7 @@
using VSLangProj;
using Project = EnvDTE.Project;
using System.Collections.Immutable;
using Microsoft.VisualStudio.Text.Editor;
namespace Microsoft.VisualStudio.LanguageServices.Interactive
{
......@@ -26,8 +27,14 @@ internal sealed class VsResetInteractive : ResetInteractive
private readonly IVsMonitorSelection _monitorSelection;
private readonly IVsSolutionBuildManager _buildManager;
internal VsResetInteractive(DTE dte, IComponentModel componentModel, IVsMonitorSelection monitorSelection, IVsSolutionBuildManager buildManager, Func<string, string> createReference, Func<string, string> createImport)
: base(createReference, createImport)
internal VsResetInteractive(
DTE dte,
IComponentModel componentModel,
IVsMonitorSelection monitorSelection,
IVsSolutionBuildManager buildManager,
Func<string, string> createReference,
Func<string, string> createImport)
: base(componentModel.GetService<IEditorOptionsFactoryService>(), createReference, createImport)
{
_dte = dte;
_componentModel = componentModel;
......
Markdown is supported
0% .
You are about to add 0 people to the discussion. Proceed with caution.
先完成此消息的编辑!
想要评论请 注册